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) {
|
||||
|
||||
@@ -29,6 +29,9 @@ func registerSearchRoutes(cfg *Config) {
|
||||
// Facet-value listings for the filter panel (faceted-navigation
|
||||
// Batch C): /api/media-items/facets?facet=X&library_id=...
|
||||
protected.GET("/media-items/facets", cfg.MediaHandler.GetFacets)
|
||||
// Rule-builder live count (faceted plan Phase 9): the composed
|
||||
// tree's true match count (COUNT(*)), rules + library scope.
|
||||
protected.GET("/media-items/filter-count", cfg.MediaHandler.FilterCount)
|
||||
protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks)
|
||||
}
|
||||
|
||||
@@ -45,6 +48,12 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
||||
// parser fixes that too.)
|
||||
params := handlers.ParseMediaSearchParams(c)
|
||||
params.UserID = user.ID
|
||||
|
||||
// Filter rule builder: a malformed qf is a 400, never a silently
|
||||
// ignored token.
|
||||
if params.FilterParseErr != "" {
|
||||
return c.HTML(http.StatusBadRequest, `<div style="color: red;">Invalid filter</div>`)
|
||||
}
|
||||
limit := params.Limit
|
||||
offset := params.Offset
|
||||
libraryID := c.QueryParam("library_id")
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,548 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Filter rule builder — the field registry (faceted plan Phase 9).
|
||||
//
|
||||
// The mandate: EVERYTHING is filterable — every attribute on a book and
|
||||
// everything that pertains to one. This registry is the completeness
|
||||
// check: fields and operators are typed constants (the allow-list — no
|
||||
// user string ever reaches a field/operator position), every value is a
|
||||
// bound pgx parameter, and each entry carries its own match-clause
|
||||
// closure so the composed engines (search + suggestions + count) grow
|
||||
// by adding entries, not SQL.
|
||||
//
|
||||
// User-relative fields read the VIEWER's rows: the user id is
|
||||
// server-injected (never a client param) — two users get different
|
||||
// results for the same tree (the "my data" weld).
|
||||
//
|
||||
// Holding structures (library, collections, series — primary AND
|
||||
// alternate) are fields like any other; any future structure that
|
||||
// "holds" a book must join this registry.
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Filter operators (the allow-list).
|
||||
const (
|
||||
OpContains = "contains"
|
||||
OpNotContains = "not_contains"
|
||||
OpIs = "is"
|
||||
OpIsNot = "is_not"
|
||||
OpEmpty = "empty"
|
||||
OpIsTrue = "is_true"
|
||||
OpIsFalse = "is_false"
|
||||
OpBetween = "between"
|
||||
OpAtLeast = "at_least"
|
||||
OpAtMost = "at_most"
|
||||
OpWithinDays = "within_days"
|
||||
OpHas = "has"
|
||||
OpMissing = "missing"
|
||||
OpUnrated = "unrated"
|
||||
OpIn = "in"
|
||||
OpNotIn = "not_in"
|
||||
OpInAny = "in_any"
|
||||
OpInNone = "in_none"
|
||||
)
|
||||
|
||||
// FilterKind — how a field's values match and what the value cell renders.
|
||||
type FilterKind string
|
||||
|
||||
const (
|
||||
KindScalarFuzzy FilterKind = "scalar_fuzzy" // word_similarity on a text column
|
||||
KindArrayFuzzy FilterKind = "array_fuzzy" // word_similarity per element of a text[]
|
||||
KindText FilterKind = "text" // fuzzy contains + exact is + empty (title-like)
|
||||
KindEnum FilterKind = "enum" // exact scalar, = ANY(values)
|
||||
KindBool FilterKind = "bool"
|
||||
KindNumber FilterKind = "number"
|
||||
KindDate FilterKind = "date"
|
||||
KindPresence FilterKind = "presence" // has / missing
|
||||
KindIdentifier FilterKind = "identifier" // exact is/is_not + has/missing
|
||||
KindNoteText FilterKind = "note_text" // joined-table ILIKE substring (viewer's rows)
|
||||
KindStatus FilterKind = "status" // unread / reading / finished
|
||||
KindLibrary FilterKind = "library"
|
||||
KindCollection FilterKind = "collection"
|
||||
)
|
||||
|
||||
// Suggestion sources — where a field's value menu comes from. A source
|
||||
// of suggestNone means the value cell is free input (numbers, dates,
|
||||
// bools, fixed enums).
|
||||
type SuggestSource string
|
||||
|
||||
const (
|
||||
suggestNone SuggestSource = ""
|
||||
suggestAuthor SuggestSource = "author"
|
||||
suggestNarrator SuggestSource = "narrator"
|
||||
suggestContrib SuggestSource = "contributor"
|
||||
suggestPublisher SuggestSource = "publisher"
|
||||
suggestTags SuggestSource = "tags"
|
||||
suggestColumn SuggestSource = "column" // the field's own scalar column
|
||||
suggestSeries SuggestSource = "series" // primary + alternate_info
|
||||
suggestNoteText SuggestSource = "note_text" // the viewer's note snippets
|
||||
suggestHighlight SuggestSource = "highlight_text"
|
||||
suggestLibrary SuggestSource = "library" // visibility-scoped libraries
|
||||
suggestCollection SuggestSource = "collection" // the viewer's collections
|
||||
suggestAddedBy SuggestSource = "added_by" // admins who added books in scope
|
||||
)
|
||||
|
||||
// Filter menu groups (display order for the field menu).
|
||||
const (
|
||||
GroupPeople = "People"
|
||||
GroupPublish = "Publishing"
|
||||
GroupTopics = "Topics"
|
||||
GroupBookFacts = "Book facts"
|
||||
GroupComics = "Comics & manga"
|
||||
GroupMyReading = "My reading"
|
||||
GroupLibrary = "Library"
|
||||
)
|
||||
|
||||
// FilterRule is one leaf: field + operator + values (all bound params).
|
||||
type FilterRule struct {
|
||||
Field string
|
||||
Op string
|
||||
Values []string
|
||||
}
|
||||
|
||||
// FilterTree is the qf wire shape: a group (mode + children) whose
|
||||
// children are rules or nested groups. Groups AND/OR their children
|
||||
// together; a group ANDs into its parent.
|
||||
type FilterTree struct {
|
||||
Mode string `json:"mode"`
|
||||
Children []FilterChild `json:"children"`
|
||||
}
|
||||
|
||||
// FilterChild is one node: a rule (Field set) or a group (Mode set).
|
||||
type FilterChild struct {
|
||||
Field string `json:"field,omitempty"`
|
||||
Op string `json:"op,omitempty"`
|
||||
Values []string `json:"values,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Children []FilterChild `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// FilterField is one registry entry.
|
||||
type FilterField struct {
|
||||
Key string // wire name (the qf "field" value)
|
||||
Label string // menu label
|
||||
Group string // menu group
|
||||
Kind FilterKind
|
||||
Ops []string // allowed operators, display order
|
||||
DefaultOp string // operator a new rule starts on (field-switch reset)
|
||||
Clause func(r FilterRule, b *filterBuilder) string
|
||||
Suggest SuggestSource
|
||||
}
|
||||
|
||||
// opsWithValue / opsWithoutValue drive validation.
|
||||
var opsNoValue = map[string]bool{
|
||||
OpEmpty: true, OpIsTrue: true, OpIsFalse: true, OpUnrated: true,
|
||||
OpInAny: true, OpInNone: true, OpHas: true, OpMissing: true,
|
||||
}
|
||||
|
||||
// fixedDomains are the operator value domains that are STATIC (validated
|
||||
// here) rather than data-driven suggestions.
|
||||
var fixedDomains = map[string][]string{
|
||||
"status": {"unread", "reading", "finished"},
|
||||
"file_availability": {"available", "missing"},
|
||||
}
|
||||
|
||||
// filterFields is THE registry. Order within a group = menu order.
|
||||
var filterFields = []FilterField{
|
||||
// ── People ────────────────────────────────────────────────────
|
||||
{Key: "author", Label: "Author", Group: GroupPeople, Kind: KindArrayFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseAuthorArray, Suggest: suggestAuthor},
|
||||
{Key: "narrator", Label: "Narrator", Group: GroupPeople, Kind: KindArrayFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseArrayFuzzyFactory("mi.narrators"), Suggest: suggestNarrator},
|
||||
{Key: "contributor", Label: "Contributor", Group: GroupPeople, Kind: KindArrayFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseArrayFuzzyFactory("mi.contributors"), Suggest: suggestContrib},
|
||||
|
||||
// ── Publishing ────────────────────────────────────────────────
|
||||
{Key: "publisher", Label: "Publisher", Group: GroupPublish, Kind: KindArrayFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseArrayFuzzyFactory("mi.publishers"), Suggest: suggestPublisher},
|
||||
{Key: "imprint", Label: "Imprint", Group: GroupPublish, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.imprint"), Suggest: suggestColumn},
|
||||
{Key: "edition", Label: "Edition", Group: GroupPublish, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.edition"), Suggest: suggestColumn},
|
||||
{Key: "published_year", Label: "Published year", Group: GroupPublish, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clausePublishedYear, Suggest: suggestNone},
|
||||
|
||||
// ── Topics ────────────────────────────────────────────────────
|
||||
{Key: "genre", Label: "Genre", Group: GroupTopics, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.genre"), Suggest: suggestColumn},
|
||||
{Key: "tags", Label: "Tags", Group: GroupTopics, Kind: KindArrayFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseArrayFuzzyFactory("mi.tags_search"), Suggest: suggestTags},
|
||||
{Key: "story_arc", Label: "Story arc", Group: GroupTopics, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.story_arc"), Suggest: suggestColumn},
|
||||
{Key: "age_rating", Label: "Age rating", Group: GroupTopics, Kind: KindEnum,
|
||||
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
||||
Clause: clauseEnumFactory("mi.age_rating"), Suggest: suggestColumn},
|
||||
|
||||
// ── Book facts ────────────────────────────────────────────────
|
||||
{Key: "title", Label: "Title", Group: GroupBookFacts, Kind: KindText,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.title"), Suggest: suggestColumn},
|
||||
{Key: "subtitle", Label: "Subtitle", Group: GroupBookFacts, Kind: KindText,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.subtitle"), Suggest: suggestColumn},
|
||||
{Key: "description", Label: "Description", Group: GroupBookFacts, Kind: KindText,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.description"), Suggest: suggestNone},
|
||||
{Key: "summary", Label: "Summary", Group: GroupBookFacts, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clausePresenceFactory("mi.summary"), Suggest: suggestNone},
|
||||
{Key: "language", Label: "Language", Group: GroupBookFacts, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseLanguage, Suggest: suggestColumn},
|
||||
{Key: "page_count", Label: "Page count", Group: GroupBookFacts, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.page_count", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "chapter_count", Label: "Chapter count", Group: GroupBookFacts, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.chapter_count", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "total_characters", Label: "Total characters", Group: GroupBookFacts, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.total_characters", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "community_rating", Label: "Community rating", Group: GroupBookFacts, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.community_rating", parseNumberFloat), Suggest: suggestNone},
|
||||
{Key: "added_at", Label: "Date added", Group: GroupBookFacts, Kind: KindDate,
|
||||
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
|
||||
Clause: clauseDateFactory("mi.created_at"), Suggest: suggestNone},
|
||||
{Key: "imported_at", Label: "Date imported", Group: GroupBookFacts, Kind: KindDate,
|
||||
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
|
||||
Clause: clauseDateFactory("mi.imported_at"), Suggest: suggestNone},
|
||||
{Key: "web_url", Label: "Web URL", Group: GroupBookFacts, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clausePresenceFactory("mi.web_url"), Suggest: suggestNone},
|
||||
{Key: "metadata_notes", Label: "Metadata notes", Group: GroupBookFacts, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clausePresenceFactory("mi.metadata_notes"), Suggest: suggestNone},
|
||||
{Key: "isbn", Label: "ISBN", Group: GroupBookFacts, Kind: KindIdentifier,
|
||||
Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseIdentifierFactory("mi.isbn"), Suggest: suggestNone},
|
||||
{Key: "asin", Label: "ASIN", Group: GroupBookFacts, Kind: KindIdentifier,
|
||||
Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseIdentifierFactory("mi.asin"), Suggest: suggestNone},
|
||||
{Key: "file_size", Label: "File size", Group: GroupBookFacts, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.file_size", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "file_availability", Label: "File availability", Group: GroupBookFacts, Kind: KindStatus,
|
||||
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
||||
Clause: clauseFileAvailability, Suggest: suggestNone},
|
||||
{Key: "explicit", Label: "Explicit", Group: GroupBookFacts, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.explicit"), Suggest: suggestNone},
|
||||
{Key: "abridged", Label: "Abridged", Group: GroupBookFacts, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.abridged"), Suggest: suggestNone},
|
||||
{Key: "cover", Label: "Cover", Group: GroupBookFacts, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseCover, Suggest: suggestNone},
|
||||
{Key: "added_by", Label: "Added by", Group: GroupBookFacts, Kind: KindIdentifier,
|
||||
Ops: []string{OpIs, OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseAddedBy, Suggest: suggestAddedBy},
|
||||
|
||||
// ── Comics & manga ────────────────────────────────────────────
|
||||
{Key: "series", Label: "Series", Group: GroupComics, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
// Full series membership: the primary series column AND the
|
||||
// additional series held in alternate_info — a book whose
|
||||
// membership of X is secondary must not hide.
|
||||
Clause: clauseSeriesMembership, Suggest: suggestSeries},
|
||||
{Key: "series_number", Label: "Series number", Group: GroupComics, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.series_number", parseNumberFloat), Suggest: suggestNone},
|
||||
{Key: "series_count", Label: "Series count", Group: GroupComics, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.series_count", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "volume", Label: "Volume", Group: GroupComics, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.volume", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "format_group", Label: "Format group", Group: GroupComics, Kind: KindEnum,
|
||||
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
||||
Clause: clauseEnumFactory("mi.format_group"), Suggest: suggestColumn},
|
||||
{Key: "mime_type", Label: "File type (mime)", Group: GroupComics, Kind: KindEnum,
|
||||
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
||||
Clause: clauseEnumFactory("mi.mime_type"), Suggest: suggestColumn},
|
||||
{Key: "is_reflowable", Label: "Reflowable", Group: GroupComics, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.is_reflowable"), Suggest: suggestNone},
|
||||
{Key: "has_fixed_layout", Label: "Fixed layout", Group: GroupComics, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.has_fixed_layout"), Suggest: suggestNone},
|
||||
{Key: "is_black_and_white", Label: "Black & white", Group: GroupComics, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.is_black_and_white"), Suggest: suggestNone},
|
||||
{Key: "manga_type", Label: "Manga type", Group: GroupComics, Kind: KindEnum,
|
||||
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
||||
Clause: clauseEnumFactory("mi.manga_type"), Suggest: suggestColumn},
|
||||
{Key: "reading_direction", Label: "Reading direction", Group: GroupComics, Kind: KindEnum,
|
||||
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
||||
Clause: clauseEnumFactory("mi.reading_direction"), Suggest: suggestColumn},
|
||||
|
||||
// ── My reading (user-relative — the VIEWER's rows) ────────────
|
||||
{Key: "status", Label: "Reading status", Group: GroupMyReading, Kind: KindStatus,
|
||||
Ops: []string{OpIs}, DefaultOp: OpIs,
|
||||
Clause: clauseStatus, Suggest: suggestNone},
|
||||
{Key: "progress_percent", Label: "Progress percent", Group: GroupMyReading, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseProgressPercent, Suggest: suggestNone},
|
||||
{Key: "last_read", Label: "Last read", Group: GroupMyReading, Kind: KindDate,
|
||||
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
|
||||
Clause: clauseLastRead, Suggest: suggestNone},
|
||||
{Key: "my_rating", Label: "My rating", Group: GroupMyReading, Kind: KindNumber,
|
||||
Ops: []string{OpIs, OpAtLeast, OpAtMost, OpUnrated}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseMyRating, Suggest: suggestNone},
|
||||
{Key: "review", Label: "Has review", Group: GroupMyReading, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseReviewPresence, Suggest: suggestNone},
|
||||
{Key: "notes", Label: "Notes", Group: GroupMyReading, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing, OpWithinDays}, DefaultOp: OpHas,
|
||||
Clause: clauseNotesPresence, Suggest: suggestNone},
|
||||
{Key: "highlights", Label: "Highlights", Group: GroupMyReading, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing, OpWithinDays}, DefaultOp: OpHas,
|
||||
Clause: clauseHighlightsPresence, Suggest: suggestNone},
|
||||
{Key: "note_text", Label: "Note text", Group: GroupMyReading, Kind: KindNoteText,
|
||||
// "Contains" here is substring ILIKE, NOT the system-wide fuzzy:
|
||||
// your own words deserve exact-ish. A book matches when ANY of
|
||||
// the viewer's notes on it match (EXISTS + bound values).
|
||||
Ops: []string{OpContains, OpNotContains, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseNoteText, Suggest: suggestNoteText},
|
||||
{Key: "highlight_text", Label: "Highlight text", Group: GroupMyReading, Kind: KindNoteText,
|
||||
Ops: []string{OpContains, OpNotContains, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseHighlightText, Suggest: suggestHighlight},
|
||||
{Key: "time_spent", Label: "Time spent (minutes)", Group: GroupMyReading, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseTimeSpent, Suggest: suggestNone},
|
||||
{Key: "pages_read", Label: "Pages read", Group: GroupMyReading, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
|
||||
Clause: clausePagesRead, Suggest: suggestNone},
|
||||
{Key: "collection", Label: "In collection", Group: GroupMyReading, Kind: KindCollection,
|
||||
// Membership mirrors the collection page: excluded items and
|
||||
// deleted collections never count; playlists are collections.
|
||||
Ops: []string{OpIn, OpNotIn, OpInAny, OpInNone}, DefaultOp: OpIn,
|
||||
Clause: clauseCollection, Suggest: suggestCollection},
|
||||
|
||||
// ── Library (holding structure + family lens) ─────────────────
|
||||
{Key: "library", Label: "Library", Group: GroupLibrary, Kind: KindLibrary,
|
||||
// Values are library IDs; the menu only ever offers the viewer's
|
||||
// VISIBLE libraries (family lens — a hidden library can not even
|
||||
// be offered).
|
||||
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
||||
Clause: clauseLibrary, Suggest: suggestLibrary},
|
||||
}
|
||||
|
||||
// FilterFieldByKey returns the registry entry for a wire key.
|
||||
func FilterFieldByKey(key string) (FilterField, bool) {
|
||||
for _, f := range filterFields {
|
||||
if f.Key == key {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return FilterField{}, false
|
||||
}
|
||||
|
||||
// FilterFieldsByGroup returns the registry grouped for the field menu,
|
||||
// groups in display order.
|
||||
func FilterFieldsByGroup() []struct {
|
||||
Group string
|
||||
Fields []FilterField
|
||||
} {
|
||||
order := []string{GroupPeople, GroupPublish, GroupTopics, GroupBookFacts, GroupComics, GroupMyReading, GroupLibrary}
|
||||
out := make([]struct {
|
||||
Group string
|
||||
Fields []FilterField
|
||||
}, 0, len(order))
|
||||
for _, g := range order {
|
||||
group := struct {
|
||||
Group string
|
||||
Fields []FilterField
|
||||
}{Group: g}
|
||||
for _, f := range filterFields {
|
||||
if f.Group == g {
|
||||
group.Fields = append(group.Fields, f)
|
||||
}
|
||||
}
|
||||
out = append(out, group)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterFieldKeys returns every registered key (the completeness check
|
||||
// for tests).
|
||||
func FilterFieldKeys() []string {
|
||||
keys := make([]string, 0, len(filterFields))
|
||||
for _, f := range filterFields {
|
||||
keys = append(keys, f.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// fieldOpsAllowed reports whether op is allowed on the field.
|
||||
func fieldOpsAllowed(f FilterField, op string) bool {
|
||||
for _, o := range f.Ops {
|
||||
if o == op {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// maxFilterDepth bounds group nesting (BookOrbit allows 5; the UI
|
||||
// discourages depth but the model allows a little headroom).
|
||||
const maxFilterDepth = 8
|
||||
|
||||
// ParseFilterTree parses + validates the qf wire JSON. Validation is
|
||||
// the allow-list enforcement: unknown fields/operators and malformed
|
||||
// values are a parse error (→ 400), never a silently-ignored token.
|
||||
func ParseFilterTree(raw string) (*FilterTree, error) {
|
||||
var t FilterTree
|
||||
if err := json.Unmarshal([]byte(raw), &t); err != nil {
|
||||
return nil, fmt.Errorf("invalid qf JSON: %w", err)
|
||||
}
|
||||
if err := validateGroup(&FilterChild{Mode: t.Mode, Children: t.Children}, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func validateGroup(g *FilterChild, depth int) error {
|
||||
if depth > maxFilterDepth {
|
||||
return fmt.Errorf("filter group nesting deeper than %d", maxFilterDepth)
|
||||
}
|
||||
if g.Mode != "all" && g.Mode != "any" {
|
||||
return fmt.Errorf("filter group mode must be all|any, got %q", g.Mode)
|
||||
}
|
||||
if len(g.Children) == 0 {
|
||||
return fmt.Errorf("filter group has no children")
|
||||
}
|
||||
for _, c := range g.Children {
|
||||
if c.Field == "" && c.Mode != "" {
|
||||
if err := validateGroup(&c, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := validateRule(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// numericParsers map each numeric field's wire values to the Go value
|
||||
// bound into SQL — validation and clause-building share one parser per
|
||||
// field, so a value that passes validation always binds.
|
||||
var numericParsers = map[string]func(string) (any, error){
|
||||
"published_year": parseNumberInt,
|
||||
"page_count": parseNumberInt,
|
||||
"chapter_count": parseNumberInt,
|
||||
"total_characters": parseNumberInt,
|
||||
"file_size": parseNumberInt,
|
||||
"series_count": parseNumberInt,
|
||||
"volume": parseNumberInt,
|
||||
"my_rating": parseNumberInt,
|
||||
"series_number": parseNumberFloat,
|
||||
"community_rating": parseNumberFloat,
|
||||
"progress_percent": parseNumberFloat,
|
||||
"time_spent": parseNumberFloat,
|
||||
"pages_read": parseNumberFloat,
|
||||
}
|
||||
|
||||
func validateRule(c FilterChild) error {
|
||||
f, ok := FilterFieldByKey(c.Field)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown filter field %q", c.Field)
|
||||
}
|
||||
if !fieldOpsAllowed(f, c.Op) {
|
||||
return fmt.Errorf("operator %q not allowed on field %q", c.Op, c.Field)
|
||||
}
|
||||
if opsNoValue[c.Op] {
|
||||
if len(c.Values) != 0 {
|
||||
return fmt.Errorf("operator %q takes no values", c.Op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(c.Values) == 0 {
|
||||
return fmt.Errorf("operator %q on field %q needs a value", c.Op, c.Field)
|
||||
}
|
||||
if c.Op == OpBetween && len(c.Values) != 2 {
|
||||
return fmt.Errorf("between takes exactly two values")
|
||||
}
|
||||
// Numeric fields: values must parse (they bind as numbers).
|
||||
if parse, ok := numericParsers[f.Key]; ok {
|
||||
for _, v := range c.Values {
|
||||
if _, err := parse(v); err != nil {
|
||||
return fmt.Errorf("field %q needs numeric values, got %q", f.Key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Date within_days: a positive integer day count.
|
||||
if c.Op == OpWithinDays {
|
||||
n, err := strconv.Atoi(c.Values[0])
|
||||
if err != nil || n <= 0 {
|
||||
return fmt.Errorf("within_days needs a positive day count, got %q", c.Values[0])
|
||||
}
|
||||
}
|
||||
// Static domains (status, file_availability).
|
||||
if domain, ok := fixedDomains[f.Key]; ok {
|
||||
for _, v := range c.Values {
|
||||
found := false
|
||||
for _, d := range domain {
|
||||
if d == v {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("field %q value must be one of %v, got %q", f.Key, domain, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
// UUID-valued fields (collection, library, added_by "is").
|
||||
switch f.Key {
|
||||
case "library", "collection":
|
||||
for _, v := range c.Values {
|
||||
if _, err := uuid.Parse(v); err != nil {
|
||||
return fmt.Errorf("field %q values must be UUIDs, got %q", f.Key, v)
|
||||
}
|
||||
}
|
||||
case "added_by":
|
||||
if c.Op == OpIs {
|
||||
if _, err := uuid.Parse(c.Values[0]); err != nil {
|
||||
return fmt.Errorf("added_by \"is\" needs a user UUID, got %q", c.Values[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseNumberInt(v string) (any, error) {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func parseNumberFloat(v string) (any, error) {
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package services
|
||||
|
||||
// Filter registry unit tests (faceted plan Phase 9): every entry is
|
||||
// complete (kind, operators, default operator, clause, suggestion
|
||||
// source), the mandated surfaces are present (including the three
|
||||
// holding structures), and the qf parse is a strict allow-list.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFilterRegistryCompleteness(t *testing.T) {
|
||||
keys := FilterFieldKeys()
|
||||
require.NotEmpty(t, keys)
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, key := range keys {
|
||||
require.False(t, seen[key], "duplicate registry key %q", key)
|
||||
seen[key] = true
|
||||
|
||||
f, ok := FilterFieldByKey(key)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, f.Label, "%s: label", key)
|
||||
require.NotEmpty(t, f.Group, "%s: menu group", key)
|
||||
require.NotEmpty(t, f.Kind, "%s: kind", key)
|
||||
require.NotEmpty(t, f.Ops, "%s: operators", key)
|
||||
require.NotNil(t, f.Clause, "%s: match clause", key)
|
||||
require.Contains(t, f.Ops, f.DefaultOp, "%s: default operator must be allowed", key)
|
||||
}
|
||||
|
||||
// The mandated surfaces — every book attribute class, the user-
|
||||
// relative class, and the three HOLDING STRUCTURES (library,
|
||||
// collections, series — the registry is the completeness check).
|
||||
mandated := []string{
|
||||
// people / publishing / topics
|
||||
"author", "narrator", "contributor", "publisher", "imprint",
|
||||
"edition", "published_year", "genre", "tags", "story_arc", "age_rating",
|
||||
// reading surface (comics & manga)
|
||||
"format_group", "mime_type", "is_reflowable", "has_fixed_layout",
|
||||
"is_black_and_white", "manga_type", "reading_direction", "explicit", "abridged",
|
||||
// series facts + numbers + dates + text + presence + identifiers
|
||||
"series", "series_number", "series_count", "volume",
|
||||
"page_count", "chapter_count", "total_characters", "file_size",
|
||||
"community_rating", "added_at", "imported_at",
|
||||
"title", "subtitle", "description", "summary", "language",
|
||||
"web_url", "metadata_notes", "isbn", "asin", "cover",
|
||||
"file_availability", "added_by",
|
||||
// user-relative (everything that pertains to a book)
|
||||
"status", "progress_percent", "last_read", "my_rating", "review",
|
||||
"notes", "highlights", "note_text", "highlight_text", "time_spent",
|
||||
"pages_read", "collection",
|
||||
// holding structure
|
||||
"library",
|
||||
}
|
||||
for _, key := range mandated {
|
||||
assert.True(t, seen[key], "mandated filter field %q missing from the registry", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterParseValidation(t *testing.T) {
|
||||
valid := func(raw string) {
|
||||
_, err := ParseFilterTree(raw)
|
||||
require.NoError(t, err, "expected %s to parse", raw)
|
||||
}
|
||||
invalid := func(raw, wantContain string) {
|
||||
_, err := ParseFilterTree(raw)
|
||||
require.Error(t, err, "expected %s to be rejected", raw)
|
||||
if wantContain != "" {
|
||||
require.Contains(t, err.Error(), wantContain)
|
||||
}
|
||||
}
|
||||
|
||||
valid(`{"mode":"all","children":[{"field":"author","op":"contains","values":["Voss"]}]}`)
|
||||
valid(`{"mode":"any","children":[
|
||||
{"field":"author","op":"contains","values":["Voss"]},
|
||||
{"mode":"all","children":[{"field":"genre","op":"is","values":["fantasy"]}]}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"page_count","op":"between","values":["10","20"]}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"cover","op":"has"}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"status","op":"is","values":["reading","finished"]}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"added_at","op":"within_days","values":["14"]}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"my_rating","op":"unrated"}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"collection","op":"in_any"}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"library","op":"is","values":["` +
|
||||
"12345678-1234-1234-1234-123456789012" + `"]}]}`)
|
||||
|
||||
invalid(`{not json`, "invalid qf JSON")
|
||||
invalid(`{"mode":"all","children":[]}`, "no children")
|
||||
invalid(`{"mode":"sometimes","children":[{"field":"author","op":"contains","values":["x"]}]}`, "mode must be all|any")
|
||||
invalid(`{"mode":"all","children":[{"field":"not_a_field","op":"is","values":["x"]}]}`, "unknown filter field")
|
||||
invalid(`{"mode":"all","children":[{"field":"author","op":"explodes","values":["x"]}]}`, "not allowed on field")
|
||||
invalid(`{"mode":"all","children":[{"field":"page_count","op":"contains","values":["1"]}]}`, "not allowed on field")
|
||||
invalid(`{"mode":"all","children":[{"field":"page_count","op":"at_least","values":["abc"]}]}`, "numeric")
|
||||
invalid(`{"mode":"all","children":[{"field":"page_count","op":"between","values":["1"]}]}`, "exactly two")
|
||||
invalid(`{"mode":"all","children":[{"field":"cover","op":"has","values":["x"]}]}`, "takes no values")
|
||||
invalid(`{"mode":"all","children":[{"field":"added_at","op":"within_days","values":["0"]}]}`, "positive day count")
|
||||
invalid(`{"mode":"all","children":[{"field":"status","op":"is","values":["platypus"]}]}`, "must be one of")
|
||||
invalid(`{"mode":"all","children":[{"field":"library","op":"is","values":["not-a-uuid"]}]}`, "must be UUIDs")
|
||||
invalid(`{"mode":"all","children":[{"field":"author","op":"contains"}]}`, "needs a value")
|
||||
}
|
||||
|
||||
func TestFilterParseDepthCap(t *testing.T) {
|
||||
// Nesting beyond maxFilterDepth is rejected.
|
||||
raw := `{"mode":"all","children":[`
|
||||
for i := 0; i < maxFilterDepth+2; i++ {
|
||||
raw += `{"mode":"all","children":[`
|
||||
}
|
||||
raw += `{"field":"author","op":"contains","values":["x"]}`
|
||||
for i := 0; i < maxFilterDepth+2; i++ {
|
||||
raw += `]}`
|
||||
}
|
||||
raw += `]}`
|
||||
_, err := ParseFilterTree(raw)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "nesting deeper")
|
||||
}
|
||||
|
||||
func TestFilterFieldsByGroupOrdering(t *testing.T) {
|
||||
groups := FilterFieldsByGroup()
|
||||
require.NotEmpty(t, groups)
|
||||
first := groups[0].Group
|
||||
if !strings.EqualFold(first, GroupPeople) {
|
||||
t.Fatalf("first menu group should be %q, got %q", GroupPeople, first)
|
||||
}
|
||||
for _, g := range groups {
|
||||
require.NotEmpty(t, g.Fields, "group %s must not be empty", g.Group)
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,16 @@ import (
|
||||
|
||||
// SearchService handles all search and filter operations
|
||||
type SearchService struct {
|
||||
db *database.Queries
|
||||
db *database.Queries
|
||||
raw database.DBTX // the raw pgx handle — the composed filter engines' path
|
||||
}
|
||||
|
||||
// NewSearchService creates a new search service instance
|
||||
func NewSearchService(db *database.Queries) *SearchService {
|
||||
return &SearchService{db: db}
|
||||
// NewSearchService creates a new search service instance. raw is the
|
||||
// same pool db wraps (database.DBTX) — the composed filter engines run
|
||||
// hand-built, allow-listed SQL with bound parameters, which static sqlc
|
||||
// queries cannot express.
|
||||
func NewSearchService(db *database.Queries, raw database.DBTX) *SearchService {
|
||||
return &SearchService{db: db, raw: raw}
|
||||
}
|
||||
|
||||
// SearchParams contains parameters for unified search
|
||||
@@ -52,6 +56,14 @@ type SearchParams struct {
|
||||
ReadingDirectionFilter string
|
||||
ExplicitFilter pgtype.Bool
|
||||
AbridgedFilter pgtype.Bool
|
||||
|
||||
// Filter rule builder (faceted plan Phase 9). When FilterTree is
|
||||
// set the composed engine runs INSTEAD of the flat facet params
|
||||
// (qf wins over flat on a hand-crafted URL — precedence pinned by
|
||||
// test); FilterParseErr carries a qf parse/validation failure for
|
||||
// the endpoints to answer 400.
|
||||
FilterTree *FilterTree
|
||||
FilterParseErr string
|
||||
}
|
||||
|
||||
// parseSearchQuery detects quoted strings for exact match search
|
||||
@@ -272,11 +284,15 @@ type FieldSearchParams struct {
|
||||
Offset int
|
||||
}
|
||||
|
||||
// FieldValue represents a single field value with metadata
|
||||
// FieldValue is a single field value with metadata. Display carries a
|
||||
// human label when the wire value is an ID (library/collection/added_by
|
||||
// suggestions) — omitted everywhere else, so the classic static shape
|
||||
// {value, count, score} is unchanged.
|
||||
type FieldValue struct {
|
||||
Value string `json:"value"`
|
||||
Count int64 `json:"count"`
|
||||
Score float64 `json:"score"`
|
||||
Value string `json:"value"`
|
||||
Count int64 `json:"count"`
|
||||
Score float64 `json:"score"`
|
||||
Display string `json:"display,omitempty"`
|
||||
}
|
||||
|
||||
// SearchFieldValues handles field-specific search for autocomplete dropdowns
|
||||
@@ -399,8 +415,22 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
|
||||
}
|
||||
|
||||
// ExecuteSearch performs search and returns results with count
|
||||
// Shared between JSON endpoint and HTML rendering
|
||||
// Shared between JSON endpoint and HTML rendering.
|
||||
// Routing: a qf filter tree → the composed engine (flat params ignored
|
||||
// — qf wins); else the static unified query. A parse/validation failure
|
||||
// surfaces as ErrInvalidFilterTree (→ 400 at the endpoints).
|
||||
func (s *SearchService) ExecuteSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
||||
if params.FilterParseErr != "" {
|
||||
return nil, 0, fmt.Errorf("%w: %s", ErrInvalidFilterTree, params.FilterParseErr)
|
||||
}
|
||||
if params.FilterTree != nil {
|
||||
rows, err := s.ComposedSearch(ctx, params)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, 0, err
|
||||
}
|
||||
count := len(rows)
|
||||
return rows, count, nil
|
||||
}
|
||||
results, err := s.SearchMediaItemsUnified(ctx, params)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, 0, err
|
||||
|
||||
Reference in new issue
Block a user