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:
John O'Keefe committed 2026-10-20 14:49:50 -04:00
1 parent ebb7f4a5b0
commit 4ee24e7152
18 files changed
+3788 -376

No files matched your search

+121 -32
View File
@@ -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) {