feat(filters): the rule builder — composed engines over the field registry (faceted plan Phase 9)
- Field registry (services/filter_registry.go): ~55 typed fields — every book column, file/lifecycle facts, the three HOLDING STRUCTURES (library, collections with excluded-aware membership, series with FULL primary+alternate_info membership), and the whole user-relative class (status, progress, last read, my rating, review, notes/highlights presence, note/highlight text, time spent, pages read, collections, added by) reading the VIEWER's rows. Operators are typed constants; negation is an operator. - Composed engines (services/filter_query.go): one Go clause builder over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow shape), the field-aware suggestions, and a true-COUNT live count. Mandatory WHERE head everywhere (family lens + integrity facts). Values are always bound parameters — no user string reaches a field/operator position, ever. - Wire: the qf JSON param (validated strictly — unknown fields/operators/malformed values are 400); qf wins over flat params on hand-crafted URLs; flat deep links keep working unchanged. - Endpoints: GET /api/media-items/filter-count (live count); field-values autocomplete upgraded in place — any registry field key, qf binds the OTHER rules (symmetric under-filter suggestions, zero-match values never offered); classic six without a tree keep their static pinned contract. - UI: the drawer becomes the rule builder — the ONLY filter UI (templ markup, Alpine tree state, sanctioned island; grouped field menus, operator menus with red negations, field-aware value menus, live count, chips per rule with red negations, URL rehydration, smart serialization flat-when-simple else qf). - Tests: composed semantics (AND/OR/negation/nesting/enums/bools/ numbers/dates/empty), every user-relative field class with the my-data weld (two readers, different worlds), note-text substring pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere, 403s), suggestion-under-rules exactness, qf validation + precedence, flat-vs-tree equivalence; registry completeness unit test.
This commit is contained in:
1 parent
ebb7f4a5b0
commit
4ee24e7152
18 files changed
+3788
-376
No files matched your search
+121
-32
@@ -226,11 +226,11 @@ type MediaHandler struct {
|
||||
// NewMediaHandler requires the shared ProgressService — progress writes
|
||||
// must funnel through SaveProgress (the LWW-seeded canonical writer);
|
||||
// a nil service has nowhere to fall back to and must not be wired.
|
||||
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler {
|
||||
func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler {
|
||||
mh := &MediaHandler{
|
||||
db: db,
|
||||
libraryService: libraryService,
|
||||
searchService: services.NewSearchService(db),
|
||||
searchService: services.NewSearchService(db, raw),
|
||||
progressSvc: progressSvc,
|
||||
annotationSvc: annotationSvc,
|
||||
}
|
||||
@@ -2328,30 +2328,29 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
||||
}
|
||||
|
||||
// Detect autocomplete queries first (author=value, genre=value, etc.)
|
||||
if author := c.QueryParam("author"); author != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "author", author)
|
||||
}
|
||||
if genre := c.QueryParam("genre"); genre != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre)
|
||||
}
|
||||
if tags := c.QueryParam("tags"); tags != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags)
|
||||
}
|
||||
if narrator := c.QueryParam("narrator"); narrator != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "narrator", narrator)
|
||||
}
|
||||
if series := c.QueryParam("series"); series != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "series", series)
|
||||
}
|
||||
if language := c.QueryParam("language"); language != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "language", language)
|
||||
// Autocomplete/suggestion queries: ANY registry field key in its
|
||||
// bare form (author=, genre=, note_text=, collection=, ...) routes
|
||||
// to the field-values path — static for the classic six without a
|
||||
// rule tree, composed under the tree otherwise (faceted plan
|
||||
// Phase 9: same route, no new API surface).
|
||||
for _, key := range services.FilterFieldKeys() {
|
||||
if v := c.QueryParam(key); v != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, key, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle media item search with filters
|
||||
params := ParseMediaSearchParams(c)
|
||||
params.UserID = userID.ID
|
||||
|
||||
// Filter rule builder: a malformed qf is a 400 (bad request), never
|
||||
// a silently-ignored token and never a 500.
|
||||
if params.FilterParseErr != "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid filter: " + params.FilterParseErr,
|
||||
})
|
||||
}
|
||||
|
||||
c.Logger().Info("Search parameters",
|
||||
"query", params.SearchQuery,
|
||||
"library_id", libraryIDString(c),
|
||||
@@ -2445,7 +2444,7 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
|
||||
return pgtype.Bool{Bool: c.QueryParam(name) == "true", Valid: true}
|
||||
}
|
||||
|
||||
return services.SearchParams{
|
||||
params := services.SearchParams{
|
||||
SearchQuery: c.QueryParam("q"),
|
||||
LibraryID: libUUID,
|
||||
AuthorFilter: c.QueryParam("author_filter"),
|
||||
@@ -2471,6 +2470,22 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
// Filter rule builder (faceted plan Phase 9): the qf param carries
|
||||
// the rule tree. Parse + validate HERE (the shared parser — every
|
||||
// entry point validates identically); a failure lands in
|
||||
// FilterParseErr for the endpoints to answer 400, never a silently
|
||||
// ignored token. Precedence: qf present → flat facet params are
|
||||
// ignored by the engine (qf wins).
|
||||
if qf := c.QueryParam("qf"); qf != "" {
|
||||
tree, err := services.ParseFilterTree(qf)
|
||||
if err != nil {
|
||||
params.FilterParseErr = err.Error()
|
||||
} else {
|
||||
params.FilterTree = tree
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// libraryVisible reports whether the requested library is inside the
|
||||
@@ -2583,14 +2598,22 @@ func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
|
||||
// Returns distinct field values with counts and similarity scores for dropdown population
|
||||
// handleFieldValuesSearch handles autocomplete/suggestion queries
|
||||
// (author=value, genre=value, note_text=value, …). Returns distinct
|
||||
// field values with counts and similarity scores for dropdown
|
||||
// population.
|
||||
//
|
||||
// library_id is OPTIONAL: absent means "every library the requesting user
|
||||
// can see" — the per-user family lens is enforced inside the query (the
|
||||
// library_visibility join), so the All-Books view's suggestions aggregate
|
||||
// the visible world. An explicit library_id outside the visible set is a
|
||||
// 403 (AGENTS standing rule: never silently pass).
|
||||
//
|
||||
// Routing (faceted plan Phase 9): with a qf rule tree, or for fields
|
||||
// beyond the classic six, suggestions come from the COMPOSED engine —
|
||||
// the other rules bind as filters (symmetric: every rule's menu sees
|
||||
// every other rule; zero-match values are never offered). Without a
|
||||
// tree, the classic six keep their static queries (pinned contract).
|
||||
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
|
||||
var libPg pgtype.UUID
|
||||
if libraryID := c.QueryParam("library_id"); libraryID != "" {
|
||||
@@ -2614,27 +2637,93 @@ func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.U
|
||||
}
|
||||
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
||||
|
||||
params := services.FieldSearchParams{
|
||||
UserID: userID,
|
||||
LibraryID: libPg,
|
||||
FieldType: fieldType,
|
||||
SearchQuery: searchQuery,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
// qf: the OTHER completed rules (the client strips the rule being
|
||||
// edited). Parse + validate here — a malformed tree is a 400.
|
||||
var tree *services.FilterTree
|
||||
if qf := c.QueryParam("qf"); qf != "" {
|
||||
t, err := services.ParseFilterTree(qf)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid filter: " + err.Error()})
|
||||
}
|
||||
tree = t
|
||||
}
|
||||
|
||||
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
|
||||
// Static path: the classic six with no rule tree (unchanged
|
||||
// contract — the tag-dropdown island and deep links still ride it).
|
||||
staticSix := tree == nil
|
||||
if staticSix {
|
||||
switch fieldType {
|
||||
case "author", "genre", "tags", "narrator", "series", "language":
|
||||
params := services.FieldSearchParams{
|
||||
UserID: userID,
|
||||
LibraryID: libPg,
|
||||
FieldType: fieldType,
|
||||
SearchQuery: searchQuery,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
|
||||
if err != nil {
|
||||
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Composed path: registry field under the other rules.
|
||||
results, err := mh.searchService.ComposedFieldValues(c.Request().Context(), services.ComposedSuggestParams{
|
||||
UserID: userID,
|
||||
LibraryID: libPg,
|
||||
FieldKey: fieldType,
|
||||
Typed: searchQuery,
|
||||
Tree: tree,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
// FilterCount handles GET /api/media-items/filter-count — the rule
|
||||
// builder's live match count: the composed rules + the library scope,
|
||||
// no pagination (a true COUNT(*), not the page length).
|
||||
func (mh *MediaHandler) FilterCount(c *echo.Context) error {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
||||
}
|
||||
params := ParseMediaSearchParams(c)
|
||||
if params.FilterParseErr != "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid filter: " + params.FilterParseErr,
|
||||
})
|
||||
}
|
||||
if params.LibraryID.Valid {
|
||||
visible, err := mh.libraryVisible(c, params.LibraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if !visible {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
||||
}
|
||||
}
|
||||
count, err := mh.searchService.ComposedCount(c.Request().Context(), user.ID, params.LibraryID, params.FilterTree)
|
||||
if err != nil {
|
||||
c.Logger().Error("filter count error", "error", err.Error())
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"count": count})
|
||||
}
|
||||
|
||||
// getFullFilePath returns the absolute filesystem path for a media item
|
||||
// Uses LibraryService for resolution (one source of truth)
|
||||
func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
|
||||
|
||||
Reference in new issue
Block a user