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
+3760
-348
No files matched your search
+1
-1
@@ -165,7 +165,7 @@ func main() {
|
|||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService)
|
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,718 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Filter rule builder integration tests (faceted plan Phase 9): the
|
||||||
|
// composed engines behind the qf wire param — tree semantics (AND/OR/
|
||||||
|
// negation/nesting), every user-relative field class (the "my data"
|
||||||
|
// weld), the note-text substring pin, family lens, suggestion-under-
|
||||||
|
// rules exactness, qf validation, and qf-over-flat precedence.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// qfSearch runs the search endpoint with arbitrary params (qf trees go
|
||||||
|
// in the qf param as JSON) and returns (status, decoded rows).
|
||||||
|
func qfSearch(t *testing.T, setup *TestServerSetup, token string, params url.Values) (int, []map[string]interface{}) {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+params.Encode(), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
var rows []map[string]interface{}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
|
||||||
|
return resp.StatusCode, rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// qfRule is a compact rule builder for tests.
|
||||||
|
func qfRule(field, op string, values ...string) map[string]interface{} {
|
||||||
|
return map[string]interface{}{"field": field, "op": op, "values": values}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qfAll(children ...map[string]interface{}) string {
|
||||||
|
return qfGroup("all", children...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qfAny(children ...map[string]interface{}) string {
|
||||||
|
return qfGroup("any", children...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qfGroup(mode string, children ...map[string]interface{}) string {
|
||||||
|
b, _ := json.Marshal(map[string]interface{}{"mode": mode, "children": children})
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func qfTitles(t *testing.T, rows []map[string]interface{}) []string {
|
||||||
|
t.Helper()
|
||||||
|
out := make([]string, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
title, _ := r["title"].(string)
|
||||||
|
out = append(out, title)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// builderFixtureLib builds one test library + four books with
|
||||||
|
// trigram-disjoint values. Returns the library UUID and the book titles.
|
||||||
|
func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, name string) (uuid.UUID, pgtype.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||||
|
require.NoError(t, err)
|
||||||
|
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||||
|
Name: fmt.Sprintf("Test Builder %s %d", name, time.Now().UnixNano()),
|
||||||
|
Description: pgtype.Text{String: "filter builder test library", Valid: true},
|
||||||
|
LibraryTypeID: libType.ID,
|
||||||
|
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
|
||||||
|
|
||||||
|
year := pgtype.Int4{Int32: 1999, Valid: true}
|
||||||
|
datePublished := pgtype.Date{Time: time.Date(1999, 3, 1, 0, 0, 0, 0, time.UTC), Valid: true}
|
||||||
|
books := []database.CreateMediaItemParams{
|
||||||
|
{
|
||||||
|
LibraryID: library.ID, Title: name + " Tide",
|
||||||
|
Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "fantasy", Valid: true},
|
||||||
|
Tags: []string{"space", "epic"}, PageCount: pgtype.Int4{Int32: 300, Valid: true},
|
||||||
|
Explicit: true, Isbn: pgtype.Text{String: "1111111111111", Valid: true},
|
||||||
|
Series: pgtype.Text{String: "Voss Saga", Valid: true},
|
||||||
|
SeriesNumber: pgtype.Float8{Float64: 1, Valid: true},
|
||||||
|
Language: pgtype.Text{String: "en", Valid: true},
|
||||||
|
DatePublished: datePublished, CopyrightYear: year,
|
||||||
|
FilePath: fmt.Sprintf("/tmp/builder/%s-tide-%d.epub", name, time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LibraryID: library.ID, Title: name + " Stone",
|
||||||
|
Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "history", Valid: true},
|
||||||
|
Tags: []string{"space"}, PageCount: pgtype.Int4{Int32: 500, Valid: true},
|
||||||
|
Series: pgtype.Text{String: "Voss Saga", Valid: true},
|
||||||
|
SeriesNumber: pgtype.Float8{Float64: 2, Valid: true},
|
||||||
|
FilePath: fmt.Sprintf("/tmp/builder/%s-stone-%d.epub", name, time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 2048, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LibraryID: library.ID, Title: name + " Fen",
|
||||||
|
Authors: []string{"Benjamin Quill"}, Genre: pgtype.Text{String: "fantasy", Valid: true},
|
||||||
|
Tags: []string{"epic"}, PageCount: pgtype.Int4{Int32: 200, Valid: true},
|
||||||
|
Abridged: true,
|
||||||
|
FilePath: fmt.Sprintf("/tmp/builder/%s-fen-%d.epub", name, time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 512, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
LibraryID: library.ID, Title: name + " Wick",
|
||||||
|
Authors: []string{"Coraline Dar"}, Genre: pgtype.Text{String: "history", Valid: true},
|
||||||
|
PageCount: pgtype.Int4{Int32: 700, Valid: true},
|
||||||
|
FilePath: fmt.Sprintf("/tmp/builder/%s-wick-%d.epub", name, time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 4096, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i := range books {
|
||||||
|
if books[i].Title == "" {
|
||||||
|
books[i].Title = fmt.Sprintf("%s Book %d", name, i)
|
||||||
|
}
|
||||||
|
// created_at binds explicitly — a zero pgtype would insert NULL
|
||||||
|
// and the date fields (added_at within N days) would never match.
|
||||||
|
books[i].CreatedAt = pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true}
|
||||||
|
books[i].ImportedAt = pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true}
|
||||||
|
// Ingest populates the *_search columns (normalized lowercase);
|
||||||
|
// direct-create fixtures must mirror that or the *_search-side
|
||||||
|
// match clauses (tags via tags_search) see nothing.
|
||||||
|
if len(books[i].Authors) > 0 && books[i].AuthorsSearch == nil {
|
||||||
|
lowered := make([]string, len(books[i].Authors))
|
||||||
|
for j, a := range books[i].Authors {
|
||||||
|
lowered[j] = strings.ToLower(a)
|
||||||
|
}
|
||||||
|
books[i].AuthorsSearch = lowered
|
||||||
|
}
|
||||||
|
if len(books[i].Tags) > 0 && books[i].TagsSearch == nil {
|
||||||
|
lowered := make([]string, len(books[i].Tags))
|
||||||
|
for j, tag := range books[i].Tags {
|
||||||
|
lowered[j] = strings.ToLower(tag)
|
||||||
|
}
|
||||||
|
books[i].TagsSearch = lowered
|
||||||
|
}
|
||||||
|
_, err := setup.DB.CreateMediaItem(ctx, books[i])
|
||||||
|
require.NoError(t, err, "create %s", books[i].Title)
|
||||||
|
}
|
||||||
|
return libUUID, library.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondBuilderUser creates a second test user (the "other reader" for
|
||||||
|
// the my-data weld tests) and returns (userID bytes, JWT).
|
||||||
|
func secondBuilderUser(t *testing.T, setup *TestServerSetup) (uuid.UUID, string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
suffix := time.Now().UnixNano()
|
||||||
|
second, err := setup.DB.CreateUser(ctx, database.CreateUserParams{
|
||||||
|
Email: fmt.Sprintf("testbuilder%d@tests.bookhoard.internal", suffix),
|
||||||
|
Username: fmt.Sprintf("testbuilder%d", suffix),
|
||||||
|
PasswordHash: "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq",
|
||||||
|
FirstName: pgtype.Text{String: "Second", Valid: true},
|
||||||
|
LastName: pgtype.Text{String: "Reader", Valid: true},
|
||||||
|
Role: "user",
|
||||||
|
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteUser(ctx, second.ID) })
|
||||||
|
token := loginWithCredentials(t, setup.Server, second.Email, "Test@Pass123!")
|
||||||
|
secondUUID, err := uuid.FromBytes(second.ID.Bytes[:])
|
||||||
|
require.NoError(t, err)
|
||||||
|
return secondUUID, token
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterComposedSemantics(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
userID := getRegularUserID(t, setup.DB)
|
||||||
|
libUUID, _ := builderFixtureLib(t, setup, userID, "Sem")
|
||||||
|
|
||||||
|
titles := func(qf string) []string {
|
||||||
|
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
||||||
|
status, rows := qfSearch(t, setup, setup.RegularToken, p)
|
||||||
|
// 404 is the endpoint's pinned no-results contract — an empty set.
|
||||||
|
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
|
||||||
|
return qfTitles(t, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain rule: author contains (fuzzy, per-element).
|
||||||
|
got := titles(qfAll(qfRule("author", "contains", "Voss")))
|
||||||
|
assert.ElementsMatch(t, []string{"Sem Tide", "Sem Stone"}, got, "author contains")
|
||||||
|
|
||||||
|
// Negation as operator: doesn't contain.
|
||||||
|
got = titles(qfAll(qfRule("author", "not_contains", "Voss")))
|
||||||
|
assert.ElementsMatch(t, []string{"Sem Fen", "Sem Wick"}, got, "author not_contains")
|
||||||
|
|
||||||
|
// ALL group = AND.
|
||||||
|
got = titles(qfAll(
|
||||||
|
qfRule("author", "contains", "Voss"),
|
||||||
|
qfRule("genre", "contains", "fantasy"),
|
||||||
|
))
|
||||||
|
assert.Equal(t, []string{"Sem Tide"}, got, "ALL group")
|
||||||
|
|
||||||
|
// ANY group = OR.
|
||||||
|
got = titles(qfAny(
|
||||||
|
qfRule("genre", "is", "history"),
|
||||||
|
qfRule("abridged", "is_true"),
|
||||||
|
))
|
||||||
|
assert.ElementsMatch(t, []string{"Sem Stone", "Sem Fen", "Sem Wick"}, got, "ANY group")
|
||||||
|
|
||||||
|
// Nesting: group ANDed into its parent.
|
||||||
|
got = titles(qfAll(
|
||||||
|
qfRule("author", "contains", "Voss"),
|
||||||
|
map[string]interface{}{"mode": "any", "children": []map[string]interface{}{
|
||||||
|
qfRule("genre", "is", "history"),
|
||||||
|
qfRule("page_count", "at_least", "400"),
|
||||||
|
}},
|
||||||
|
))
|
||||||
|
assert.Equal(t, []string{"Sem Stone"}, got, "nested ANY inside ALL")
|
||||||
|
|
||||||
|
// Negated subgroup inside ALL: author Voss AND NOT(genre fantasy).
|
||||||
|
got = titles(qfAll(
|
||||||
|
qfRule("author", "contains", "Voss"),
|
||||||
|
map[string]interface{}{"mode": "any", "children": []map[string]interface{}{
|
||||||
|
qfRule("genre", "not_contains", "fantasy"),
|
||||||
|
qfRule("title", "contains", "zzz-no-match"),
|
||||||
|
}},
|
||||||
|
))
|
||||||
|
assert.Equal(t, []string{"Sem Stone"}, got, "negation inside nested group")
|
||||||
|
|
||||||
|
// Array exact + negation.
|
||||||
|
got = titles(qfAll(qfRule("tags", "is", "epic")))
|
||||||
|
assert.ElementsMatch(t, []string{"Sem Tide", "Sem Fen"}, got, "tags is")
|
||||||
|
got = titles(qfAll(qfRule("tags", "is_not", "epic")))
|
||||||
|
assert.ElementsMatch(t, []string{"Sem Stone", "Sem Wick"}, got, "tags isn't")
|
||||||
|
|
||||||
|
// Numbers: between / at_most.
|
||||||
|
got = titles(qfAll(qfRule("page_count", "between", "250", "550")))
|
||||||
|
assert.ElementsMatch(t, []string{"Sem Tide", "Sem Stone"}, got, "page_count between")
|
||||||
|
got = titles(qfAll(qfRule("page_count", "at_most", "250")))
|
||||||
|
assert.ElementsMatch(t, []string{"Sem Fen"}, got, "page_count at_most")
|
||||||
|
|
||||||
|
// Empty operators.
|
||||||
|
got = titles(qfAll(qfRule("series", "empty")))
|
||||||
|
assert.ElementsMatch(t, []string{"Sem Fen", "Sem Wick"}, got, "series is empty")
|
||||||
|
got = titles(qfAll(qfRule("isbn", "missing")))
|
||||||
|
assert.ElementsMatch(t, []string{"Sem Stone", "Sem Fen", "Sem Wick"}, got, "isbn missing")
|
||||||
|
|
||||||
|
// Enums exact + isn't.
|
||||||
|
got = titles(qfAll(qfRule("age_rating", "is", "Teen")))
|
||||||
|
assert.Empty(t, got, "age_rating is (none set)")
|
||||||
|
got = titles(qfAll(qfRule("mime_type", "is", "application/epub+zip")))
|
||||||
|
assert.Len(t, got, 4, "mime_type is")
|
||||||
|
|
||||||
|
// Series full membership: the alternate_info series matches too.
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := setup.DBPool.Exec(ctx,
|
||||||
|
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
|
||||||
|
WHERE title = $1`, "Sem Fen")
|
||||||
|
require.NoError(t, err)
|
||||||
|
got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
|
||||||
|
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
|
||||||
|
got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
|
||||||
|
assert.Equal(t, []string{"Sem Fen"}, got, "series exact matches alternate_info membership")
|
||||||
|
|
||||||
|
// Dates: within the last N days (all fixtures created now).
|
||||||
|
got = titles(qfAll(qfRule("added_at", "within_days", "7")))
|
||||||
|
assert.Len(t, got, 4, "added_at within the last 7 days")
|
||||||
|
|
||||||
|
// Identifiers: is / isn't (isn't includes books without one).
|
||||||
|
got = titles(qfAll(qfRule("isbn", "is", "1111111111111")))
|
||||||
|
assert.Equal(t, []string{"Sem Tide"}, got, "isbn is")
|
||||||
|
got = titles(qfAll(qfRule("isbn", "is_not", "1111111111111")))
|
||||||
|
assert.Len(t, got, 3, "isbn isn't includes books without an ISBN")
|
||||||
|
|
||||||
|
// Presence.
|
||||||
|
got = titles(qfAll(qfRule("cover", "missing")))
|
||||||
|
assert.Len(t, got, 4, "cover missing")
|
||||||
|
|
||||||
|
// Published year (flat year-range semantics: date_published falls
|
||||||
|
// back to copyright_year).
|
||||||
|
got = titles(qfAll(qfRule("published_year", "at_least", "1990")))
|
||||||
|
assert.Equal(t, []string{"Sem Tide"}, got, "published_year at_least")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterUserRelativeFields(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
userA := getRegularUserID(t, setup.DB)
|
||||||
|
userBUUID, tokenB := secondBuilderUser(t, setup)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
libUUID, _ := builderFixtureLib(t, setup, userA, "Mine")
|
||||||
|
|
||||||
|
// Resolve the fixture media item ids by title.
|
||||||
|
itemID := func(title string) pgtype.UUID {
|
||||||
|
var id pgtype.UUID
|
||||||
|
err := setup.DBPool.QueryRow(ctx,
|
||||||
|
`SELECT id FROM media_items WHERE title = $1`, title).Scan(&id)
|
||||||
|
require.NoError(t, err, "fixture item %s", title)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
tideID := itemID("Mine Tide")
|
||||||
|
stoneID := itemID("Mine Stone")
|
||||||
|
fenID := itemID("Mine Fen")
|
||||||
|
wickID := itemID("Mine Wick")
|
||||||
|
|
||||||
|
toPg := func(id uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: id, Valid: true} }
|
||||||
|
aID := toPg(userA)
|
||||||
|
bID := toPg(userBUUID)
|
||||||
|
|
||||||
|
// Reader A: reading Tide (50%), finished Stone, rated Tide 8,
|
||||||
|
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
|
||||||
|
// Stone, time on Wick (2h), collection holding Tide.
|
||||||
|
_, err := setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`,
|
||||||
|
tideID, aID, stoneID, aID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
|
||||||
|
tideID, bID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`,
|
||||||
|
aID, wickID, aID, tideID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var collID pgtype.UUID
|
||||||
|
err = setup.DBPool.QueryRow(ctx,
|
||||||
|
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||||
|
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
titlesFor := func(token string, qf string) []string {
|
||||||
|
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
||||||
|
status, rows := qfSearch(t, setup, token, p)
|
||||||
|
// 404 = the pinned no-results contract (an empty set).
|
||||||
|
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
|
||||||
|
return qfTitles(t, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status: the "my data" weld — same tree, different readers.
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||||
|
titlesFor(setup.RegularToken, qfAll(qfRule("status", "is", "reading"))), "A: Tide is in progress")
|
||||||
|
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("status", "is", "reading"))), "B: nothing in progress")
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||||
|
titlesFor(tokenB, qfAll(qfRule("status", "is", "finished"))), "B finished Tide; A did not")
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Fen", "Mine Wick"},
|
||||||
|
titlesFor(setup.RegularToken, qfAll(qfRule("status", "is", "unread"))), "A unread")
|
||||||
|
|
||||||
|
// Progress percent.
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Stone"},
|
||||||
|
titlesFor(setup.RegularToken, qfAll(qfRule("progress_percent", "at_least", "60"))))
|
||||||
|
|
||||||
|
// Last read.
|
||||||
|
assert.Len(t, titlesFor(setup.RegularToken, qfAll(qfRule("last_read", "within_days", "7"))), 2)
|
||||||
|
|
||||||
|
// My rating: two worlds.
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||||
|
titlesFor(setup.RegularToken, qfAll(qfRule("my_rating", "at_least", "5"))))
|
||||||
|
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("my_rating", "at_least", "5"))), "B rated 3")
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Fen", "Mine Stone", "Mine Wick"},
|
||||||
|
titlesFor(tokenB, qfAll(qfRule("my_rating", "unrated"))), "B rated only Tide")
|
||||||
|
|
||||||
|
// Review presence.
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Fen"},
|
||||||
|
titlesFor(setup.RegularToken, qfAll(qfRule("review", "has"))))
|
||||||
|
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("review", "has"))))
|
||||||
|
|
||||||
|
// Notes presence + recency.
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||||
|
titlesFor(setup.RegularToken, qfAll(qfRule("notes", "has"))))
|
||||||
|
|
||||||
|
// Note text: substring pin + the weld. A's note says "catharsis":
|
||||||
|
// searching "catharsis" matches, "Carthage" does NOT (substring,
|
||||||
|
// not fuzzy), and B — no notes — sees nothing either way.
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||||
|
titlesFor(setup.RegularToken, qfAll(qfRule("note_text", "contains", "catharsis"))))
|
||||||
|
assert.Empty(t, titlesFor(setup.RegularToken, qfAll(qfRule("note_text", "contains", "Carthage"))),
|
||||||
|
"substring semantics: catharsis ≠ Carthage")
|
||||||
|
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("note_text", "contains", "catharsis"))),
|
||||||
|
"my data: B's notes are the only notes in B's world")
|
||||||
|
|
||||||
|
// Highlight text.
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Stone"},
|
||||||
|
titlesFor(setup.RegularToken, qfAll(qfRule("highlight_text", "contains", "dark forest"))))
|
||||||
|
|
||||||
|
// Time spent (minutes) + pages read.
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Wick"},
|
||||||
|
titlesFor(setup.RegularToken, qfAll(qfRule("time_spent", "at_least", "60"))))
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Wick"},
|
||||||
|
titlesFor(setup.RegularToken, qfAll(qfRule("pages_read", "at_least", "100"))))
|
||||||
|
|
||||||
|
// Collections: in / in none.
|
||||||
|
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||||
|
titlesFor(setup.RegularToken, qfAll(qfRule("collection", "in_any"))))
|
||||||
|
assert.Len(t, titlesFor(tokenB, qfAll(qfRule("collection", "in_none"))), 4,
|
||||||
|
"B is in no collections")
|
||||||
|
|
||||||
|
// Library field (holding structure): the fixture library holds all
|
||||||
|
// four books.
|
||||||
|
assert.Len(t, titlesFor(setup.RegularToken, qfAll(qfRule("library", "is", libUUID.String()))), 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterSuggestionsUnderRules(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
userID := getRegularUserID(t, setup.DB)
|
||||||
|
libUUID, _ := builderFixtureLib(t, setup, userID, "Sug")
|
||||||
|
|
||||||
|
// Add a second distinct author+genre pair (trigram-disjoint).
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
|
Title: "Sug Dune",
|
||||||
|
Authors: []string{"Zephyrine Hale"},
|
||||||
|
Genre: pgtype.Text{String: "spacewestern", Valid: true},
|
||||||
|
FilePath: fmt.Sprintf("/tmp/builder/sug-dune-%d.epub", time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 128, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
suggest := func(field, typed, qf string) (int, []map[string]interface{}) {
|
||||||
|
p := url.Values{"library_id": {libUUID.String()}, field: {typed}, "limit": {"20"}}
|
||||||
|
if qf != "" {
|
||||||
|
p.Set("qf", qf)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Results []map[string]interface{} `json:"results"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
return resp.StatusCode, body.Results
|
||||||
|
}
|
||||||
|
valuesOf := func(results []map[string]interface{}) []string {
|
||||||
|
out := make([]string, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
v, _ := r["value"].(string)
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Typed genre menu: both genres surface for their prefixes.
|
||||||
|
status, results := suggest("genre", "fan", "")
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Contains(t, valuesOf(results), "fantasy")
|
||||||
|
|
||||||
|
// The two-worlds example: author contains Voss → the genre menu is
|
||||||
|
// HER genres only; author DOESN'T contain → her-only genres absent.
|
||||||
|
status, results = suggest("genre", "fan", qfAll(qfRule("author", "contains", "Voss")))
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Contains(t, valuesOf(results), "fantasy")
|
||||||
|
|
||||||
|
// NOTE: "fan" + not_contains STILL lists fantasy — Quill's Fen is
|
||||||
|
// fantasy too; the genre is not author-exclusive. The clean
|
||||||
|
// two-worlds demo is spacewestern (only Hale's book), below.
|
||||||
|
|
||||||
|
// ...and the OTHER world's genre appears only when Voss is excluded.
|
||||||
|
status, results = suggest("genre", "space", qfAll(qfRule("author", "not_contains", "Voss")))
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Contains(t, valuesOf(results), "spacewestern", "the other world's genre surfaces under negation")
|
||||||
|
status, results = suggest("genre", "space", qfAll(qfRule("author", "contains", "Voss")))
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.NotContains(t, valuesOf(results), "spacewestern", "zero-match values never offered")
|
||||||
|
|
||||||
|
// Suggestions are symmetric: the genre rule constrains the AUTHOR
|
||||||
|
// menu just the same.
|
||||||
|
status, results = suggest("author", "zeph", qfAll(qfRule("genre", "is", "spacewestern")))
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Contains(t, valuesOf(results), "Zephyrine Hale")
|
||||||
|
status, results = suggest("author", "amara", qfAll(qfRule("genre", "is", "spacewestern")))
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.NotContains(t, valuesOf(results), "Amara Voss", "zero-match values never offered")
|
||||||
|
|
||||||
|
// Library field menu: visibility-scoped, id values + name display
|
||||||
|
// (this test's fixture libraries are named "Test Builder Sug …").
|
||||||
|
status, results = suggest("library", "Test Builder", "")
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
found := false
|
||||||
|
for _, r := range results {
|
||||||
|
if r["value"] == libUUID.String() {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "the visible library is offered")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterFamilyLens(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
userID := getRegularUserID(t, setup.DB)
|
||||||
|
ctx := context.Background()
|
||||||
|
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
makeLib := func(name string) (uuid.UUID, pgtype.UUID) {
|
||||||
|
lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||||
|
Name: fmt.Sprintf("Test Lens %s %d", name, time.Now().UnixNano()),
|
||||||
|
Description: pgtype.Text{String: "family lens test library", Valid: true},
|
||||||
|
LibraryTypeID: libType.ID,
|
||||||
|
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
id, err := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
|
||||||
|
return id, lib.ID
|
||||||
|
}
|
||||||
|
_, visibleID := makeLib("Visible")
|
||||||
|
hiddenUUID, hiddenID := makeLib("Hidden")
|
||||||
|
|
||||||
|
// One book in each, same author so the All-Books menu would
|
||||||
|
// aggregate both if the lens failed.
|
||||||
|
for _, lib := range []struct {
|
||||||
|
id pgtype.UUID
|
||||||
|
title string
|
||||||
|
}{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} {
|
||||||
|
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||||
|
LibraryID: lib.id, Title: lib.title,
|
||||||
|
Authors: []string{"Lens Secret Author"},
|
||||||
|
FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()),
|
||||||
|
FileSize: pgtype.Int8{Int64: 64, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
_, err = setup.DBPool.Exec(ctx,
|
||||||
|
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
|
||||||
|
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
search := func(params url.Values) (int, []map[string]interface{}) {
|
||||||
|
return qfSearch(t, setup, setup.RegularToken, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The composed search never sees the hidden library's books —
|
||||||
|
// even when a rule names it explicitly.
|
||||||
|
qfHide := qfAll(qfRule("library", "is", hiddenUUID.String()))
|
||||||
|
status, rows := search(url.Values{"qf": {qfHide}})
|
||||||
|
// 200-with-empty or the 404 no-results contract — either way: nothing.
|
||||||
|
assert.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
|
||||||
|
assert.Empty(t, rows, "a rule naming a hidden library matches nothing")
|
||||||
|
|
||||||
|
// Explicit out-of-set scope + a tree: 403 (never silent).
|
||||||
|
status, _ = search(url.Values{"qf": {qfAll(qfRule("author", "contains", "Lens"))}, "library_id": {hiddenUUID.String()}})
|
||||||
|
assert.Equal(t, http.StatusForbidden, status)
|
||||||
|
|
||||||
|
// The library suggestion menu never offers hidden libraries (a
|
||||||
|
// real typed prefix — empty typed values skip the autocomplete
|
||||||
|
// branch entirely).
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+url.Values{
|
||||||
|
"library": {"Test Lens"}, "limit": {"50"},
|
||||||
|
}.Encode(), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var body struct {
|
||||||
|
Results []map[string]interface{} `json:"results"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
for _, r := range body.Results {
|
||||||
|
assert.NotEqual(t, hiddenUUID.String(), r["value"], "hidden library never offered")
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter-count: out-of-set scope is a 403 too.
|
||||||
|
req, _ = http.NewRequest("GET", setup.Server.URL+"/api/media-items/filter-count?qf="+url.QueryEscape(qfAll(qfRule("author", "contains", "Lens")))+"&library_id="+hiddenUUID.String(), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err = http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterQFValidationAndPrecedence(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
userID := getRegularUserID(t, setup.DB)
|
||||||
|
libUUID, _ := builderFixtureLib(t, setup, userID, "Val")
|
||||||
|
|
||||||
|
search := func(params url.Values) (int, []map[string]interface{}) {
|
||||||
|
return qfSearch(t, setup, setup.RegularToken, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown field → 400.
|
||||||
|
status, _ := search(url.Values{"qf": {qfAll(qfRule("not_a_field", "is", "x"))}})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, status, "unknown field is a 400")
|
||||||
|
|
||||||
|
// Unknown operator → 400.
|
||||||
|
status, _ = search(url.Values{"qf": {qfAll(qfRule("author", "explodes", "x"))}})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, status, "unknown operator is a 400")
|
||||||
|
|
||||||
|
// Operator not allowed on the field → 400.
|
||||||
|
status, _ = search(url.Values{"qf": {qfAll(qfRule("page_count", "contains", "3"))}})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, status, "operator not allowed on field")
|
||||||
|
|
||||||
|
// between with one value → 400.
|
||||||
|
status, _ = search(url.Values{"qf": {qfAll(qfRule("page_count", "between", "3"))}})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, status, "between needs two values")
|
||||||
|
|
||||||
|
// Malformed JSON → 400.
|
||||||
|
status, _ = search(url.Values{"qf": {"{not json"}})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, status)
|
||||||
|
|
||||||
|
// SQL injection in a VALUE is just a value (bound parameter): no
|
||||||
|
// error, no match.
|
||||||
|
inj := qfAll(qfRule("author", "contains", "x' OR 1=1 --"))
|
||||||
|
status, rows := search(url.Values{"qf": {inj}})
|
||||||
|
assert.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status,
|
||||||
|
"injection attempt is a bound value, not SQL")
|
||||||
|
assert.Empty(t, rows)
|
||||||
|
|
||||||
|
// Precedence: a URL carrying BOTH qf and flat params — qf wins.
|
||||||
|
p := url.Values{
|
||||||
|
"library_id": {libUUID.String()},
|
||||||
|
"qf": {qfAll(qfRule("author", "contains", "Voss"))},
|
||||||
|
"author_filter": {"Coraline Dar"},
|
||||||
|
}
|
||||||
|
status, rows = search(p)
|
||||||
|
require.Equal(t, http.StatusOK, status)
|
||||||
|
assert.ElementsMatch(t, []string{"Val Tide", "Val Stone"}, qfTitles(t, rows),
|
||||||
|
"qf wins over flat on a hand-crafted URL")
|
||||||
|
|
||||||
|
// Live count endpoint: rules + scope, a true COUNT.
|
||||||
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/filter-count?"+url.Values{
|
||||||
|
"qf": {qfAll(qfRule("author", "contains", "Voss"))}, "library_id": {libUUID.String()},
|
||||||
|
}.Encode(), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
var body struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||||
|
assert.Equal(t, 2, body.Count, "live count is the true match count")
|
||||||
|
|
||||||
|
// Empty group → 400 (parse validation).
|
||||||
|
status, _ = search(url.Values{"qf": {`{"mode":"all","children":[]}`}})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterFlatEquivalence(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
t.Cleanup(func() { _ = setup.Close() })
|
||||||
|
|
||||||
|
userID := getRegularUserID(t, setup.DB)
|
||||||
|
libUUID, _ := builderFixtureLib(t, setup, userID, "Eqv")
|
||||||
|
scope := url.Values{"library_id": {libUUID.String()}}
|
||||||
|
|
||||||
|
// The tree and its flat deep link return the SAME rows (the smart
|
||||||
|
// serialization promise: flat links keep their semantics).
|
||||||
|
statusFlat, flatRows := qfSearch(t, setup, setup.RegularToken,
|
||||||
|
mergeParams(scope, url.Values{"author_filter": {"Voss"}}))
|
||||||
|
require.Equal(t, http.StatusOK, statusFlat)
|
||||||
|
statusTree, treeRows := qfSearch(t, setup, setup.RegularToken,
|
||||||
|
mergeParams(scope, url.Values{"qf": {qfAll(qfRule("author", "contains", "Voss"))}}))
|
||||||
|
require.Equal(t, http.StatusOK, statusTree)
|
||||||
|
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows),
|
||||||
|
"tree and flat link return identical rows")
|
||||||
|
|
||||||
|
// Enum: exact flat vs tree is (both may 404-no-results — the empty
|
||||||
|
// set on both sides is still equivalence).
|
||||||
|
statusFlat, flatRows = qfSearch(t, setup, setup.RegularToken,
|
||||||
|
mergeParams(scope, url.Values{"age_rating_filter": {"Teen"}}))
|
||||||
|
statusTree, treeRows = qfSearch(t, setup, setup.RegularToken,
|
||||||
|
mergeParams(scope, url.Values{"qf": {qfAll(qfRule("age_rating", "is", "Teen"))}}))
|
||||||
|
require.Equal(t, statusFlat, statusTree, "same result status")
|
||||||
|
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows))
|
||||||
|
}
|
||||||
@@ -507,7 +507,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
statisticsService := services.NewStatisticsService(queries)
|
statisticsService := services.NewStatisticsService(queries)
|
||||||
statsHandler := handlers.NewStatsHandler(statisticsService)
|
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService, worker)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
|
|
||||||
// Create conversion service for OPDS
|
// Create conversion service for OPDS
|
||||||
|
|||||||
@@ -301,3 +301,44 @@ cover_image_path, has_conflict, format_group, badge`.
|
|||||||
the standing rule forbids). The single-facet
|
the standing rule forbids). The single-facet
|
||||||
`/api/media-items/facets?facet=X` shape still REQUIRES `library_id`
|
`/api/media-items/facets?facet=X` shape still REQUIRES `library_id`
|
||||||
— that pin is unchanged.
|
— that pin is unchanged.
|
||||||
|
|
||||||
|
## 2026-10-20 — Filter rule builder: the `qf` wire param + composed engines (faceted plan Phase 9)
|
||||||
|
|
||||||
|
- NEW wire param on the search endpoint family: `qf` — a JSON rule tree
|
||||||
|
`{mode: all|any, children: [{field, op, values} | {mode, children}]}`.
|
||||||
|
Fields/operators are registry-typed constants
|
||||||
|
(`services/filter_registry.go` — ~55 fields incl. every book column,
|
||||||
|
file/lifecycle facts, the three HOLDING STRUCTURES — library,
|
||||||
|
collections (playlists included, `excluded IS NOT TRUE` membership),
|
||||||
|
series (primary + `alternate_info` full membership) — and the whole
|
||||||
|
user-relative class reading the VIEWER's rows). Unknown fields or
|
||||||
|
operators, disallowed operator/field pairs, malformed values, empty
|
||||||
|
groups, and nesting beyond 8 are a **400** (`ErrInvalidFilterTree`),
|
||||||
|
never a silently-ignored token. Values are always bound pgx
|
||||||
|
parameters — an injection-shaped value is just a value.
|
||||||
|
- **Precedence (pinned)**: when a request carries BOTH `qf` and flat
|
||||||
|
facet params, **qf wins** — the flat params are ignored by the engine.
|
||||||
|
Flat links keep working unchanged server-side (and a tree equivalent
|
||||||
|
to a flat link returns identical rows — pinned by test).
|
||||||
|
- The composed engines (`services/filter_query.go`) run the mandatory
|
||||||
|
WHERE head (visibility join + archived + missing-scan — family lens)
|
||||||
|
for search, suggestions, and count alike; row shape is identical to
|
||||||
|
`SearchMediaItemsUnifiedRow`. A rule naming a hidden library matches
|
||||||
|
nothing; an explicit out-of-set `library_id` stays a 403 on every
|
||||||
|
entry point.
|
||||||
|
- NEW endpoint `GET /api/media-items/filter-count?qf=…[&library_id=…]`
|
||||||
|
— the builder's live count: a true COUNT(*) of the rules + scope
|
||||||
|
(not the page length). Family-lens 403 applies.
|
||||||
|
- Field-values autocomplete UPGRADED in place (same route): ANY registry
|
||||||
|
field key in its bare form (`?note_text=…`, `?collection=…`,
|
||||||
|
`?library=…`) routes to the field-values path; with a `qf` param the
|
||||||
|
OTHER rules bind as filters (symmetric under-filter suggestions,
|
||||||
|
zero-match values never offered). Without a tree, the classic six
|
||||||
|
(author/genre/tags/narrator/series/language) keep their static
|
||||||
|
queries and response shape — pinned contract, unchanged. ID-valued
|
||||||
|
suggestions (library/collection/added_by) add an OPTIONAL
|
||||||
|
`display` field to result items (`value` stays the id; `omitempty`
|
||||||
|
elsewhere).
|
||||||
|
- The Phase-7 facet-panel partial stays removed; the all-facets JSON
|
||||||
|
endpoint and its two shapes are unchanged (they serve flat params —
|
||||||
|
the builder's UI does not use them).
|
||||||
+113
-24
@@ -226,11 +226,11 @@ type MediaHandler struct {
|
|||||||
// NewMediaHandler requires the shared ProgressService — progress writes
|
// NewMediaHandler requires the shared ProgressService — progress writes
|
||||||
// must funnel through SaveProgress (the LWW-seeded canonical writer);
|
// must funnel through SaveProgress (the LWW-seeded canonical writer);
|
||||||
// a nil service has nowhere to fall back to and must not be wired.
|
// 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{
|
mh := &MediaHandler{
|
||||||
db: db,
|
db: db,
|
||||||
libraryService: libraryService,
|
libraryService: libraryService,
|
||||||
searchService: services.NewSearchService(db),
|
searchService: services.NewSearchService(db, raw),
|
||||||
progressSvc: progressSvc,
|
progressSvc: progressSvc,
|
||||||
annotationSvc: annotationSvc,
|
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"})
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect autocomplete queries first (author=value, genre=value, etc.)
|
// Autocomplete/suggestion queries: ANY registry field key in its
|
||||||
if author := c.QueryParam("author"); author != "" {
|
// bare form (author=, genre=, note_text=, collection=, ...) routes
|
||||||
return mh.handleFieldValuesSearch(c, userID.ID, "author", author)
|
// 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)
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle media item search with filters
|
// Handle media item search with filters
|
||||||
params := ParseMediaSearchParams(c)
|
params := ParseMediaSearchParams(c)
|
||||||
params.UserID = userID.ID
|
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",
|
c.Logger().Info("Search parameters",
|
||||||
"query", params.SearchQuery,
|
"query", params.SearchQuery,
|
||||||
"library_id", libraryIDString(c),
|
"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 pgtype.Bool{Bool: c.QueryParam(name) == "true", Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
return services.SearchParams{
|
params := services.SearchParams{
|
||||||
SearchQuery: c.QueryParam("q"),
|
SearchQuery: c.QueryParam("q"),
|
||||||
LibraryID: libUUID,
|
LibraryID: libUUID,
|
||||||
AuthorFilter: c.QueryParam("author_filter"),
|
AuthorFilter: c.QueryParam("author_filter"),
|
||||||
@@ -2471,6 +2470,22 @@ func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
|
|||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
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
|
// 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.)
|
// handleFieldValuesSearch handles autocomplete/suggestion queries
|
||||||
// Returns distinct field values with counts and similarity scores for dropdown population
|
// (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
|
// library_id is OPTIONAL: absent means "every library the requesting user
|
||||||
// can see" — the per-user family lens is enforced inside the query (the
|
// can see" — the per-user family lens is enforced inside the query (the
|
||||||
// library_visibility join), so the All-Books view's suggestions aggregate
|
// library_visibility join), so the All-Books view's suggestions aggregate
|
||||||
// the visible world. An explicit library_id outside the visible set is a
|
// the visible world. An explicit library_id outside the visible set is a
|
||||||
// 403 (AGENTS standing rule: never silently pass).
|
// 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 {
|
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
|
||||||
var libPg pgtype.UUID
|
var libPg pgtype.UUID
|
||||||
if libraryID := c.QueryParam("library_id"); libraryID != "" {
|
if libraryID := c.QueryParam("library_id"); libraryID != "" {
|
||||||
@@ -2614,6 +2637,23 @@ func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.U
|
|||||||
}
|
}
|
||||||
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
offset, _ := strconv.Atoi(c.QueryParam("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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{
|
params := services.FieldSearchParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
LibraryID: libPg,
|
LibraryID: libPg,
|
||||||
@@ -2622,18 +2662,67 @@ func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.U
|
|||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
}
|
}
|
||||||
|
|
||||||
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
|
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
|
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.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"results": results,
|
"results": results,
|
||||||
"total": len(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
|
// getFullFilePath returns the absolute filesystem path for a media item
|
||||||
// Uses LibraryService for resolution (one source of truth)
|
// Uses LibraryService for resolution (one source of truth)
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ func registerSearchRoutes(cfg *Config) {
|
|||||||
// Facet-value listings for the filter panel (faceted-navigation
|
// Facet-value listings for the filter panel (faceted-navigation
|
||||||
// Batch C): /api/media-items/facets?facet=X&library_id=...
|
// Batch C): /api/media-items/facets?facet=X&library_id=...
|
||||||
protected.GET("/media-items/facets", cfg.MediaHandler.GetFacets)
|
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)
|
protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +48,12 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
|||||||
// parser fixes that too.)
|
// parser fixes that too.)
|
||||||
params := handlers.ParseMediaSearchParams(c)
|
params := handlers.ParseMediaSearchParams(c)
|
||||||
params.UserID = user.ID
|
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
|
limit := params.Limit
|
||||||
offset := params.Offset
|
offset := params.Offset
|
||||||
libraryID := c.QueryParam("library_id")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,11 +15,15 @@ import (
|
|||||||
// SearchService handles all search and filter operations
|
// SearchService handles all search and filter operations
|
||||||
type SearchService struct {
|
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
|
// NewSearchService creates a new search service instance. raw is the
|
||||||
func NewSearchService(db *database.Queries) *SearchService {
|
// same pool db wraps (database.DBTX) — the composed filter engines run
|
||||||
return &SearchService{db: db}
|
// 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
|
// SearchParams contains parameters for unified search
|
||||||
@@ -52,6 +56,14 @@ type SearchParams struct {
|
|||||||
ReadingDirectionFilter string
|
ReadingDirectionFilter string
|
||||||
ExplicitFilter pgtype.Bool
|
ExplicitFilter pgtype.Bool
|
||||||
AbridgedFilter 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
|
// parseSearchQuery detects quoted strings for exact match search
|
||||||
@@ -272,11 +284,15 @@ type FieldSearchParams struct {
|
|||||||
Offset int
|
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 {
|
type FieldValue struct {
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Count int64 `json:"count"`
|
Count int64 `json:"count"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
|
Display string `json:"display,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchFieldValues handles field-specific search for autocomplete dropdowns
|
// 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
|
// 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) {
|
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)
|
results, err := s.SearchMediaItemsUnified(ctx, params)
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
|
|||||||
+89
-102
@@ -170,114 +170,98 @@ templ BookShelf(
|
|||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- The rule builder (faceted plan Phase 9): the drawer's
|
||||||
|
ONLY filter UI. Templ owns the look, Alpine owns the
|
||||||
|
rule-tree state (the sanctioned island — same pattern
|
||||||
|
as the chips bar; server JSON rendered by Alpine, no
|
||||||
|
innerHTML templating). Rows are a flat projection of
|
||||||
|
the tree (depth = indent); groups nest via + group. -->
|
||||||
|
<div x-data="filterBuilderExtra" x-cloak
|
||||||
|
x-effect="if (filtersOpen === true) { ensureReadyRule(); }"
|
||||||
|
>
|
||||||
|
<template x-for="row in builderRows" :key="row.key">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Author</label>
|
<template x-if="row.type === 'groupOpen'">
|
||||||
<input
|
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-1" :style="`margin-left:${row.depth * 14}px`">
|
||||||
type="text"
|
<span class="text-[11px] font-semibold uppercase tracking-wide" style="color: var(--text-secondary)" x-text="row.depth === 0 ? 'Match' : 'Match'"></span>
|
||||||
name="author_filter"
|
<button type="button" class="fb-mode-btn" :class="row.group.mode === 'all' ? 'fb-mode-active' : ''" @click="setGroupMode(row.path, 'all')">ALL</button>
|
||||||
placeholder="Filter by author"
|
<button type="button" class="fb-mode-btn" :class="row.group.mode === 'any' ? 'fb-mode-active' : ''" @click="setGroupMode(row.path, 'any')">ANY</button>
|
||||||
class="input"
|
<span class="flex-1"></span>
|
||||||
list="author-datalist"
|
<button type="button" class="fb-mini-btn" @click="addRule(row.path)">+ rule</button>
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
<button type="button" class="fb-mini-btn" @click="addGroup(row.path)">+ group</button>
|
||||||
/>
|
<button type="button" class="fb-mini-btn fb-danger" x-show="row.depth > 0" @click="removeNode(row.path)" aria-label="Remove group">×</button>
|
||||||
<datalist id="author-datalist"></datalist>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="row.type === 'rule'">
|
||||||
|
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-0.5" :style="`margin-left:${(row.depth + 1) * 14}px`">
|
||||||
|
<span class="fb-connective" x-show="!row.isFirst" x-text="row.connective"></span>
|
||||||
|
<!-- field picker: grouped menu -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Tags</label>
|
<button type="button" class="fb-picker" :class="{'fb-picker-active': row.rule.field !== ''}" @click="toggleMenu(row.key + ':field')" x-text="fieldLabelOf(row.rule.field)"></button>
|
||||||
<input
|
<div class="fb-menu" x-show="openMenu === row.key + ':field'" @click.outside="openMenu = ''">
|
||||||
type="text"
|
<template x-for="grp in fieldGroups" :key="grp.group">
|
||||||
name="tags_filter"
|
<div>
|
||||||
placeholder="Filter by tags"
|
<div class="fb-menu-group" x-text="grp.group"></div>
|
||||||
class="input"
|
<template x-for="f in grp.fields" :key="f.key">
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchTagValues($el)"
|
<button type="button" class="fb-menu-item" :class="row.rule.field === f.key ? 'fb-menu-item-active' : ''" @click="setRuleField(row.path, f.key)" x-text="f.label"></button>
|
||||||
@keydown="onTagFilterKeydown($event)"
|
</template>
|
||||||
@blur="hideTagDropdown()"
|
</div>
|
||||||
/>
|
</template>
|
||||||
<div
|
</div>
|
||||||
x-show="showTagDropdown"
|
</div>
|
||||||
x-transition
|
<!-- operator picker (negation lives here) -->
|
||||||
x-cloak
|
<div class="relative">
|
||||||
class="absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
<button type="button" class="fb-picker fb-picker-op" :class="{'fb-negated-text': isNegated(row.rule)}" @click="toggleMenu(row.key + ':op')" x-text="opLabelOf(row.rule)"></button>
|
||||||
style="background-color: var(--bg-primary); border: 1px solid var(--border);"
|
<div class="fb-menu" x-show="openMenu === row.key + ':op'" @click.outside="openMenu = ''">
|
||||||
>
|
<template x-for="o in opsFor(row.rule.field)" :key="o.key">
|
||||||
<template x-for="sug in tagSuggestions" :key="sug.value">
|
<button type="button" class="fb-menu-item" :class="[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']" @click="setRuleOp(row.path, o.key)" x-text="o.label"></button>
|
||||||
<button
|
</template>
|
||||||
type="button"
|
</div>
|
||||||
class="w-full text-left px-3 py-2 text-sm flex justify-between items-center"
|
</div>
|
||||||
:class="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''"
|
<!-- value cell: shape follows the operator + field kind -->
|
||||||
:style="tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'"
|
<template x-if="hasValueCell(row.rule) && valueArity(row.rule) === 'two'">
|
||||||
@click="selectTagSuggestion(sug.value)"
|
<span class="flex items-center gap-1">
|
||||||
>
|
<input type="text" class="input fb-value" :value="row.rule.values[0] || ''" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" @click.outside="suggestionsOpen = ''" placeholder="From"/>
|
||||||
<span x-text="sug.value"></span>
|
<span class="text-xs" style="color: var(--text-secondary)">and</span>
|
||||||
<span class="text-xs" style="color: var(--text-secondary);" x-text="sug.count + ' books'"></span>
|
<input type="text" class="input fb-value" :value="row.rule.values[1] || ''" @input.debounce.250ms="setRuleValue(row.path, 1, $el.value)" placeholder="To"/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')">
|
||||||
|
<span class="relative flex items-center gap-1">
|
||||||
|
<input type="text" class="input fb-value" :value="row.rule.values.join(',')" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" @click.outside="suggestionsOpen = ''" :placeholder="fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'"/>
|
||||||
|
<template x-if="fieldMeta(row.rule.field)?.valueKind === 'days'">
|
||||||
|
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="valueArity(row.rule) === 'one' && fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : ''"></span>
|
||||||
|
</template>
|
||||||
|
<!-- field-aware value menu: server JSON (the other
|
||||||
|
rules bound), or the field's fixed options -->
|
||||||
|
<div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="suggestionsOpen = ''">
|
||||||
|
<template x-for="s in (suggestions[row.key] || [])" :key="s.value">
|
||||||
|
<button type="button" class="fb-menu-item" @click="pickSuggestion(row.key, row.path, s)">
|
||||||
|
<span x-text="s.display || s.value"></span>
|
||||||
|
<span class="text-xs" style="color: var(--text-secondary)" x-show="s.count > 0" x-text="s.count + ' books'"></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</span>
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Series</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="series_filter"
|
|
||||||
placeholder="Filter by series"
|
|
||||||
class="input"
|
|
||||||
list="series-datalist"
|
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
|
|
||||||
/>
|
|
||||||
<datalist id="series-datalist"></datalist>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Narrator</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="narrator_filter"
|
|
||||||
placeholder="Filter by narrator"
|
|
||||||
class="input"
|
|
||||||
list="narrator-datalist"
|
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchNarratorValues($el)"
|
|
||||||
/>
|
|
||||||
<datalist id="narrator-datalist"></datalist>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Language</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="language_filter"
|
|
||||||
placeholder="Filter by language"
|
|
||||||
class="input"
|
|
||||||
list="language-datalist"
|
|
||||||
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
|
|
||||||
/>
|
|
||||||
<datalist id="language-datalist"></datalist>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Year Range</label>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<input type="number" name="year_min" placeholder="From" class="input"/>
|
|
||||||
<input type="number" name="year_max" placeholder="To" class="input"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-2" style="color: var(--text-secondary)">Cover</label>
|
|
||||||
<input
|
|
||||||
type="button"
|
|
||||||
id="has-cover-tristate"
|
|
||||||
class="tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm"
|
|
||||||
:class="{
|
|
||||||
'state-null': hasCoverState === null,
|
|
||||||
'state-true': hasCoverState === true,
|
|
||||||
'state-false': hasCoverState === false
|
|
||||||
}"
|
|
||||||
:value="hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'"
|
|
||||||
@click="cycleHasCover()"
|
|
||||||
/>
|
|
||||||
<template x-if="hasCoverState !== null">
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="has_cover"
|
|
||||||
:value="hasCoverState ? 'true' : 'false'"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
<!-- delete rule -->
|
||||||
|
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove rule">×</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="row.type === 'groupClose' && row.depth > 0">
|
||||||
|
<div class="fb-group-end" :style="`margin-left:${row.depth * 14}px`"></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- footer: add rule/group + the debounced live count
|
||||||
|
(the composed engine's true COUNT — what the grid will
|
||||||
|
show once applied) -->
|
||||||
|
<div class="flex items-center gap-2 pt-2 flex-wrap">
|
||||||
|
<button type="button" class="fb-mini-btn" @click="addRule([])">+ Rule</button>
|
||||||
|
<button type="button" class="fb-mini-btn" @click="addGroup([])">+ Group</button>
|
||||||
|
<span class="flex-1"></span>
|
||||||
|
<span class="text-xs" style="color: var(--text-secondary)" x-show="matchCount !== null" x-text="matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 book matches' : matchCount + ' books match')"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sticky bottom-0 px-6 py-4 border-t" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
<div class="sticky bottom-0 px-6 py-4 border-t" style="background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||||
@@ -300,7 +284,10 @@ templ BookShelf(
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="badge inline-flex items-center gap-1.5 hover:opacity-80 transition-opacity cursor-pointer"
|
class="badge inline-flex items-center gap-1.5 hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
style="background-color: var(--accent-muted); color: var(--accent); border: 1px solid transparent;"
|
:class="chip.negated ? 'fb-chip-negated' : ''"
|
||||||
|
:style="chip.negated
|
||||||
|
? 'background-color: color-mix(in srgb, var(--status-danger) 12%, transparent); color: var(--status-danger); border: 1px solid color-mix(in srgb, var(--status-danger) 40%, transparent);'
|
||||||
|
: 'background-color: var(--accent-muted); color: var(--accent); border: 1px solid transparent;'"
|
||||||
@click="removeFilterChip(chip.param)"
|
@click="removeFilterChip(chip.param)"
|
||||||
>
|
>
|
||||||
<span x-text="chip.label"></span>
|
<span x-text="chip.label"></span>
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ func BookShelf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Narrator</label> <input type=\"text\" name=\"narrator_filter\" placeholder=\"Filter by narrator\" class=\"input\" list=\"narrator-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchNarratorValues($el)\"> <datalist id=\"narrator-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><!-- Active-filter chips: a pure function of the client-side\n\t\t\t\t\tform state (Alpine renders them, templ owns the look —\n\t\t\t\t\tsanctioned TS island). Removing a chip clears that facet\n\t\t\t\t\tand re-applies the form. --><div id=\"active-filter-chips\" class=\"flex flex-wrap items-center gap-2 mb-4\" x-show=\"activeFilters.length > 0\" x-cloak><template x-for=\"chip in activeFilters\" :key=\"chip.param\"><button type=\"button\" class=\"badge inline-flex items-center gap-1.5 hover:opacity-80 transition-opacity cursor-pointer\" style=\"background-color: var(--accent-muted); color: var(--accent); border: 1px solid transparent;\" @click=\"removeFilterChip(chip.param)\"Line truncated
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"if (filtersOpen === true) { ensureReadyRule(); }\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Match'\"></span> <button type=\"button\" class=\"fb-mode-btn\" :class=\"row.group.mode === 'all' ? 'fb-mode-active' : ''\" @click=\"setGroupMode(row.path, 'all')\">ALL</button> <button type=\"button\" class=\"fb-mode-btn\" :class=\"row.group.mode === 'any' ? 'fb-mode-active' : ''\" @click=\"setGroupMode(row.path, 'any')\">ANY</button> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" x-show=\"row.depth > 0\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><span class=\"fb-connective\" x-show=\"!row.isFirst\" x-text=\"row.connective\"></span><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><template x-for=\"grp in fieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"valueArity(row.rule) === 'one' && fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : ''\"></span></template><!-- field-aware value menu: server JSON (the other\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\trules bound), or the field's fixed options --><div class=Line truncated
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -422,7 +422,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 315, Col: 59}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 302, Col: 59}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -445,7 +445,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 324, Col: 126}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 311, Col: 126}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -476,7 +476,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 333, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 320, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -489,7 +489,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 338, Col: 126}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 325, Col: 126}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
+102
-203
@@ -2,26 +2,44 @@
|
|||||||
|
|
||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
import type {
|
||||||
|
FilterGroupState,
|
||||||
|
FilterRuleState,
|
||||||
|
} from "./filter_builder";
|
||||||
|
|
||||||
function clearFilters(): void {
|
function clearFilters(): void {
|
||||||
// Clear all form state (reuses helper)
|
// Clear all form state (reuses helper)
|
||||||
clearFormWithoutSubmit();
|
clearFormWithoutSubmit();
|
||||||
|
|
||||||
|
// Reset the rule builder tree (the builder's wire inputs are
|
||||||
|
// serializer-managed and already dropped by cleanupDynamicHiddenInputs)
|
||||||
|
builderData()?.clearBuilder();
|
||||||
|
|
||||||
// Trigger form submit with cleared filters
|
// Trigger form submit with cleared filters
|
||||||
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||||
if (!filterForm) return;
|
if (!filterForm) return;
|
||||||
window.htmx.trigger(filterForm, "submit");
|
window.htmx.trigger(filterForm, "submit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// builderData returns the rule builder's Alpine data (nested x-data
|
||||||
|
// inside the drawer) — the same DOM-lookup pattern the bookshelf
|
||||||
|
// component uses.
|
||||||
|
function builderData(): any | null {
|
||||||
|
const el = document.querySelector('[x-data*="filterBuilderExtra"]');
|
||||||
|
if (!el || !(el as any)._x_dataStack) return null;
|
||||||
|
return (el as any)._x_dataStack[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove dynamically created hidden inputs from filter form
|
// Remove dynamically created hidden inputs from filter form
|
||||||
// Preserves: limit, offset, has_cover (static/managed inputs)
|
// Preserves: limit, offset (static/managed inputs). The builder's wire
|
||||||
|
// inputs (flat facet params + qf) are serializer-managed and dropped so
|
||||||
|
// the next serialization starts clean.
|
||||||
function cleanupDynamicHiddenInputs(): void {
|
function cleanupDynamicHiddenInputs(): void {
|
||||||
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
const filterForm = document.getElementById("filter-form") as HTMLFormElement;
|
||||||
if (!filterForm) return;
|
if (!filterForm) return;
|
||||||
|
|
||||||
// Names of inputs to preserve (static or Alpine-managed)
|
// Names of inputs to preserve (static or Alpine-managed)
|
||||||
const preserveNames = ["limit", "offset", "has_cover"];
|
const preserveNames = ["limit", "offset"];
|
||||||
|
|
||||||
// Find all hidden inputs
|
// Find all hidden inputs
|
||||||
const hiddenInputs = filterForm.querySelectorAll('input[type="hidden"]');
|
const hiddenInputs = filterForm.querySelectorAll('input[type="hidden"]');
|
||||||
@@ -43,23 +61,6 @@ function clearFormWithoutSubmit(): void {
|
|||||||
// Reset all form fields to their HTML defaults
|
// Reset all form fields to their HTML defaults
|
||||||
filterForm.reset();
|
filterForm.reset();
|
||||||
|
|
||||||
// Remove has_cover input entirely
|
|
||||||
const hasCoverInput = document.querySelector(
|
|
||||||
'[name="has_cover"]',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
if (hasCoverInput) {
|
|
||||||
hasCoverInput.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset Alpine hasCoverState to null
|
|
||||||
const bookshelfComponent = document.querySelector('[x-data*="bookshelf"]');
|
|
||||||
if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
|
|
||||||
const data = (bookshelfComponent as any)._x_dataStack[0];
|
|
||||||
if (data && typeof data.hasCoverState !== "undefined") {
|
|
||||||
data.hasCoverState = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any hidden inputs from previous filter loads
|
// Remove any hidden inputs from previous filter loads
|
||||||
cleanupDynamicHiddenInputs();
|
cleanupDynamicHiddenInputs();
|
||||||
|
|
||||||
@@ -81,12 +82,8 @@ Alpine.data("bookshelf", () => ({
|
|||||||
showSaveModal: false,
|
showSaveModal: false,
|
||||||
showFiltersDropdown: false,
|
showFiltersDropdown: false,
|
||||||
filtersOpen: false,
|
filtersOpen: false,
|
||||||
hasCoverState: null as boolean | null,
|
|
||||||
dropdownAlign: "right" as "left" | "right",
|
dropdownAlign: "right" as "left" | "right",
|
||||||
resizeTimeout: null as number | null,
|
resizeTimeout: null as number | null,
|
||||||
tagSuggestions: [] as TagSuggestion[],
|
|
||||||
showTagDropdown: false,
|
|
||||||
tagHighlightIndex: -1,
|
|
||||||
// Active-filter chips (Batch B): Alpine renders them from this state;
|
// Active-filter chips (Batch B): Alpine renders them from this state;
|
||||||
// refreshed by refreshFilterChips after every grid refetch.
|
// refreshed by refreshFilterChips after every grid refetch.
|
||||||
activeFilters: [] as FilterChip[],
|
activeFilters: [] as FilterChip[],
|
||||||
@@ -107,9 +104,12 @@ Alpine.data("bookshelf", () => ({
|
|||||||
"filter-form",
|
"filter-form",
|
||||||
) as HTMLFormElement;
|
) as HTMLFormElement;
|
||||||
if (!filterForm) return;
|
if (!filterForm) return;
|
||||||
if (param === "has_cover") {
|
// Tree chips (qf mode): "rule:<path>" — remove that rule from the
|
||||||
filterForm.querySelector('[name="has_cover"]')?.remove();
|
// builder tree, resync the wire inputs, re-apply.
|
||||||
this.hasCoverState = null;
|
if (param.startsWith("rule:")) {
|
||||||
|
const builder = builderData();
|
||||||
|
const path = param.slice(5).split("-").map((n) => parseInt(n, 10));
|
||||||
|
builder?.removeNode(path);
|
||||||
window.htmx.trigger(filterForm, "submit");
|
window.htmx.trigger(filterForm, "submit");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,18 +172,6 @@ Alpine.data("bookshelf", () => ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// Cycle through has_cover states: null -> true -> false -> null
|
|
||||||
cycleHasCover(): void {
|
|
||||||
if (this.hasCoverState === null) {
|
|
||||||
this.hasCoverState = true;
|
|
||||||
} else if (this.hasCoverState === true) {
|
|
||||||
this.hasCoverState = false;
|
|
||||||
} else {
|
|
||||||
this.hasCoverState = null;
|
|
||||||
}
|
|
||||||
// NOTE: We don't trigger form submission here
|
|
||||||
// The user must click "Search" or press Enter to apply the filter
|
|
||||||
},
|
|
||||||
// Handle post-HTMX swap cleanup for saved filters
|
// Handle post-HTMX swap cleanup for saved filters
|
||||||
afterFilterSave(): void {
|
afterFilterSave(): void {
|
||||||
// Hide empty state if it exists
|
// Hide empty state if it exists
|
||||||
@@ -257,32 +245,11 @@ Alpine.data("bookshelf", () => ({
|
|||||||
clearFormWithoutSubmit();
|
clearFormWithoutSubmit();
|
||||||
|
|
||||||
// Don't clear form - just update values
|
// Don't clear form - just update values
|
||||||
// Populate form fields from filter data
|
// Populate form fields from filter data. Every saved param lands
|
||||||
|
// as a (hidden) form input — the builder's hydration reads them
|
||||||
|
// (qf wins over flat, the pinned precedence).
|
||||||
Object.entries(filterData).forEach(([key, value]) => {
|
Object.entries(filterData).forEach(([key, value]) => {
|
||||||
if (key === "has_cover") {
|
|
||||||
const bookshelfComponent = document.querySelector(
|
|
||||||
'[x-data*="bookshelf"]',
|
|
||||||
);
|
|
||||||
if (bookshelfComponent && (bookshelfComponent as any)._x_dataStack) {
|
|
||||||
const data = (bookshelfComponent as any)._x_dataStack[0];
|
|
||||||
if (data && typeof data.hasCoverState !== "undefined") {
|
|
||||||
// Convert string value to boolean|null
|
|
||||||
if (value === "true") {
|
|
||||||
data.hasCoverState = true;
|
|
||||||
} else if (value === "false") {
|
|
||||||
data.hasCoverState = false;
|
|
||||||
} else {
|
|
||||||
data.hasCoverState = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return; // Don't process has_cover like other fields
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value) return; // Skip empty values
|
if (!value) return; // Skip empty values
|
||||||
// Special handling for has_cover - update Alpine state directly
|
|
||||||
|
|
||||||
// Handle all other fields normally
|
|
||||||
const visibleField = filterForm.querySelector(
|
const visibleField = filterForm.querySelector(
|
||||||
`[name="${key}"]`,
|
`[name="${key}"]`,
|
||||||
) as HTMLInputElement;
|
) as HTMLInputElement;
|
||||||
@@ -302,6 +269,9 @@ Alpine.data("bookshelf", () => ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rehydrate the rule-builder tree from the refilled form
|
||||||
|
builderData()?.hydrateFromForm();
|
||||||
|
|
||||||
// Trigger HTMX to apply the filter by submitting the form
|
// Trigger HTMX to apply the filter by submitting the form
|
||||||
window.htmx.trigger(filterForm, "submit");
|
window.htmx.trigger(filterForm, "submit");
|
||||||
|
|
||||||
@@ -349,139 +319,6 @@ Alpine.data("bookshelf", () => ({
|
|||||||
showToast("Error deleting filter", "error");
|
showToast("Error deleting filter", "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Autocomplete helper function
|
|
||||||
async fetchFieldValues(
|
|
||||||
field: string,
|
|
||||||
search: string,
|
|
||||||
datalistId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const token = localStorage.getItem("token");
|
|
||||||
if (!token) {
|
|
||||||
console.error("Not authenticated");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (search.length < 2) return;
|
|
||||||
|
|
||||||
const currentLibraryId = (
|
|
||||||
document.getElementById("library-select") as HTMLSelectElement
|
|
||||||
)?.value;
|
|
||||||
|
|
||||||
const libParam = currentLibraryId ? `&library_id=${currentLibraryId}` : "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/media-items/search?${field}=${encodeURIComponent(
|
|
||||||
search,
|
|
||||||
)}${libParam}&limit=50`,
|
|
||||||
{
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error("Failed to fetch field values");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Update datalist via DOM manipulation
|
|
||||||
const datalist = document.getElementById(datalistId);
|
|
||||||
if (!datalist) {
|
|
||||||
console.error(`Datalist ${datalistId} not found`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear existing options
|
|
||||||
datalist.innerHTML = "";
|
|
||||||
|
|
||||||
// Add new options
|
|
||||||
data.results.forEach((item: { value: string; count: number }) => {
|
|
||||||
const option = document.createElement("option");
|
|
||||||
option.value = item.value;
|
|
||||||
option.textContent = `${item.value} (${item.count})`;
|
|
||||||
datalist.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching field values:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fetch author values for autocomplete
|
|
||||||
async fetchAuthorValues(input: HTMLInputElement): Promise<void> {
|
|
||||||
await this.fetchFieldValues("author", input.value, "author-datalist");
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fetch tag values for autocomplete (custom dropdown)
|
|
||||||
async fetchTagValues(input: HTMLInputElement): Promise<void> {
|
|
||||||
const libraryId = (document.getElementById("library-select") as HTMLSelectElement)?.value;
|
|
||||||
if (!libraryId || input.value.length < 2) {
|
|
||||||
this.tagSuggestions = [];
|
|
||||||
this.showTagDropdown = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const results = await searchTagSuggestions(input.value, libraryId);
|
|
||||||
this.tagSuggestions = results;
|
|
||||||
this.showTagDropdown = results.length > 0;
|
|
||||||
this.tagHighlightIndex = -1;
|
|
||||||
},
|
|
||||||
|
|
||||||
selectTagSuggestion(tag: string) {
|
|
||||||
const input = document.querySelector('input[name="tags_filter"]') as HTMLInputElement;
|
|
||||||
if (input) input.value = tag;
|
|
||||||
this.showTagDropdown = false;
|
|
||||||
this.tagSuggestions = [];
|
|
||||||
this.tagHighlightIndex = -1;
|
|
||||||
},
|
|
||||||
|
|
||||||
hideTagDropdown() {
|
|
||||||
setTimeout(() => { this.showTagDropdown = false; this.tagHighlightIndex = -1; }, 200);
|
|
||||||
},
|
|
||||||
|
|
||||||
onTagFilterKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "ArrowDown") {
|
|
||||||
event.preventDefault();
|
|
||||||
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
|
||||||
this.tagHighlightIndex = (this.tagHighlightIndex + 1) % this.tagSuggestions.length;
|
|
||||||
}
|
|
||||||
} else if (event.key === "ArrowUp") {
|
|
||||||
event.preventDefault();
|
|
||||||
if (this.showTagDropdown && this.tagSuggestions.length > 0) {
|
|
||||||
this.tagHighlightIndex = this.tagHighlightIndex <= 0
|
|
||||||
? this.tagSuggestions.length - 1
|
|
||||||
: this.tagHighlightIndex - 1;
|
|
||||||
}
|
|
||||||
} else if (event.key === "Enter" && this.tagHighlightIndex >= 0 && this.showTagDropdown) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.selectTagSuggestion(this.tagSuggestions[this.tagHighlightIndex].value);
|
|
||||||
} else if (event.key === "Escape") {
|
|
||||||
this.showTagDropdown = false;
|
|
||||||
this.tagSuggestions = [];
|
|
||||||
this.tagHighlightIndex = -1;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// // Fetch genre values for autocomplete
|
|
||||||
// async fetchGenreValues(input: HTMLInputElement): Promise<void> {
|
|
||||||
// await this.fetchFieldValues("genre", input.value, "genre-datalist");
|
|
||||||
// },
|
|
||||||
|
|
||||||
// Fetch series values for autocomplete
|
|
||||||
async fetchSeriesValues(input: HTMLInputElement): Promise<void> {
|
|
||||||
await this.fetchFieldValues("series", input.value, "series-datalist");
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fetch narrator values for autocomplete (A2a)
|
|
||||||
async fetchNarratorValues(input: HTMLInputElement): Promise<void> {
|
|
||||||
await this.fetchFieldValues("narrator", input.value, "narrator-datalist");
|
|
||||||
},
|
|
||||||
|
|
||||||
// Fetch language values for autocomplete
|
|
||||||
async fetchLanguageValues(input: HTMLInputElement): Promise<void> {
|
|
||||||
await this.fetchFieldValues("language", input.value, "language-datalist");
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Standalone function - initialize the bookshelf (NOT async, like dashboard.ts)
|
// Standalone function - initialize the bookshelf (NOT async, like dashboard.ts)
|
||||||
@@ -494,6 +331,26 @@ Alpine.data("bookshelf", () => ({
|
|||||||
function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
||||||
if (!filterForm) return false;
|
if (!filterForm) return false;
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
// Rule-builder trees ride the qf param (faceted plan Phase 9).
|
||||||
|
// Precedence (pinned): when qf is present the flat params are
|
||||||
|
// ignored — the tree IS the query.
|
||||||
|
const qf = params.get("qf");
|
||||||
|
if (qf) {
|
||||||
|
const input = filterForm.querySelector(
|
||||||
|
'[name="qf"]',
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
if (input) {
|
||||||
|
input.value = qf;
|
||||||
|
} else {
|
||||||
|
const hidden = document.createElement("input");
|
||||||
|
hidden.type = "hidden";
|
||||||
|
hidden.name = "qf";
|
||||||
|
hidden.value = qf;
|
||||||
|
filterForm.appendChild(hidden);
|
||||||
|
}
|
||||||
|
builderData()?.hydrateFromForm();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const filterNames = [
|
const filterNames = [
|
||||||
"author_filter",
|
"author_filter",
|
||||||
"series_filter",
|
"series_filter",
|
||||||
@@ -513,6 +370,7 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
|||||||
"abridged_filter",
|
"abridged_filter",
|
||||||
"year_min",
|
"year_min",
|
||||||
"year_max",
|
"year_max",
|
||||||
|
"qf",
|
||||||
];
|
];
|
||||||
let applied = false;
|
let applied = false;
|
||||||
for (const name of filterNames) {
|
for (const name of filterNames) {
|
||||||
@@ -545,6 +403,8 @@ function applyURLFilters(filterForm: HTMLFormElement | null): boolean {
|
|||||||
interface FilterChip {
|
interface FilterChip {
|
||||||
param: string;
|
param: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
// Tree chips only: negated operators render red.
|
||||||
|
negated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// prettifyGenreLabel ports templates.prettifyGenre (Batch B transform):
|
// prettifyGenreLabel ports templates.prettifyGenre (Batch B transform):
|
||||||
@@ -610,23 +470,55 @@ function refreshFilterChips(): void {
|
|||||||
if (typeof data.activeFilters === "undefined") return;
|
if (typeof data.activeFilters === "undefined") return;
|
||||||
|
|
||||||
const chips: FilterChip[] = [];
|
const chips: FilterChip[] = [];
|
||||||
|
const qfInput = filterForm.querySelector(
|
||||||
|
'[name="qf"]',
|
||||||
|
) as HTMLInputElement | null;
|
||||||
|
|
||||||
|
if (qfInput && qfInput.value) {
|
||||||
|
// Tree mode: one chip per rule (red for negations — the chips
|
||||||
|
// template styles them via chip.negated). Values that are IDs
|
||||||
|
// (collection/library) label through the builder's id→name cache.
|
||||||
|
const builder = builderData();
|
||||||
|
try {
|
||||||
|
const tree = JSON.parse(qfInput.value) as FilterGroupState;
|
||||||
|
const walk = (group: FilterGroupState, path: number[]) => {
|
||||||
|
(group.children as (FilterRuleState | FilterGroupState)[]).forEach(
|
||||||
|
(child, idx) => {
|
||||||
|
const childPath = [...path, idx];
|
||||||
|
if ("children" in (child as FilterGroupState)) {
|
||||||
|
walk(child as FilterGroupState, childPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rule = child as FilterRuleState;
|
||||||
|
const label = builder
|
||||||
|
? builder.chipLabelFor(rule)
|
||||||
|
: `${rule.field} ${rule.op}`;
|
||||||
|
chips.push({
|
||||||
|
param: "rule:" + childPath.join("-"),
|
||||||
|
label,
|
||||||
|
negated: builder?.isNegated(rule) ?? false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
walk(tree, []);
|
||||||
|
} catch {
|
||||||
|
// malformed qf — no tree chips
|
||||||
|
}
|
||||||
|
} else {
|
||||||
filterForm
|
filterForm
|
||||||
.querySelectorAll<HTMLInputElement>(
|
.querySelectorAll<HTMLInputElement>(
|
||||||
'[name$="_filter"], [name="year_min"], [name="year_max"]',
|
'[name$="_filter"], [name="year_min"], [name="year_max"], [name="has_cover"]',
|
||||||
)
|
)
|
||||||
.forEach((input) => {
|
.forEach((input) => {
|
||||||
if (input.value) {
|
if (input.value) {
|
||||||
chips.push({
|
chips.push({
|
||||||
param: input.name,
|
param: input.name,
|
||||||
label: facetChipLabel(input.name, input.value),
|
label: facetChipLabel(input.name, input.value),
|
||||||
|
negated: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const hasCoverInput = filterForm.querySelector(
|
|
||||||
'[name="has_cover"]',
|
|
||||||
) as HTMLInputElement | null;
|
|
||||||
if (hasCoverInput && hasCoverInput.value) {
|
|
||||||
chips.push({ param: "has_cover", label: facetChipLabel("has_cover", hasCoverInput.value) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data.activeFilters = chips;
|
data.activeFilters = chips;
|
||||||
@@ -640,7 +532,7 @@ function refreshFilterChips(): void {
|
|||||||
"imprint_filter", "edition_filter", "age_rating_filter",
|
"imprint_filter", "edition_filter", "age_rating_filter",
|
||||||
"format_group_filter", "manga_type_filter", "reading_direction_filter",
|
"format_group_filter", "manga_type_filter", "reading_direction_filter",
|
||||||
"explicit_filter", "abridged_filter", "year_min", "year_max",
|
"explicit_filter", "abridged_filter", "year_min", "year_max",
|
||||||
"has_cover", "library_id",
|
"has_cover", "library_id", "qf",
|
||||||
]);
|
]);
|
||||||
const next = new URLSearchParams();
|
const next = new URLSearchParams();
|
||||||
url.searchParams.forEach((value, key) => {
|
url.searchParams.forEach((value, key) => {
|
||||||
@@ -674,8 +566,15 @@ function initBookshelf(): void {
|
|||||||
// pre-fills the form and applies it to the first grid load. Author
|
// pre-fills the form and applies it to the first grid load. Author
|
||||||
// links on the detail page (and anything else) can share filtered
|
// links on the detail page (and anything else) can share filtered
|
||||||
// views; a comma-joined value is a multi-select (server splits it).
|
// views; a comma-joined value is a multi-select (server splits it).
|
||||||
|
// qf deep links rehydrate as the rule tree (qf wins over flat — the
|
||||||
|
// pinned precedence).
|
||||||
const urlApplied = applyURLFilters(filterForm);
|
const urlApplied = applyURLFilters(filterForm);
|
||||||
|
|
||||||
|
// The builder's tree mirrors the applied wire state (URL or empty)
|
||||||
|
// so the drawer opens with the current rules; the wire inputs are
|
||||||
|
// then serializer-owned.
|
||||||
|
builderData()?.hydrateFromForm();
|
||||||
|
|
||||||
// Check if books were already rendered server-side
|
// Check if books were already rendered server-side
|
||||||
const booksGrid = document.getElementById("books-grid");
|
const booksGrid = document.getElementById("books-grid");
|
||||||
const hasServerBooks =
|
const hasServerBooks =
|
||||||
|
|||||||
@@ -0,0 +1,774 @@
|
|||||||
|
// Filter rule builder (faceted plan Phase 9) — the drawer's ONLY filter
|
||||||
|
// UI. Alpine owns the rule-tree state; templ owns the markup (the
|
||||||
|
// sanctioned island: same pattern as the chips bar, NO innerHTML
|
||||||
|
// templating). The server renders nothing per keystroke: value menus
|
||||||
|
// are server JSON (field-aware, computed under the OTHER rules),
|
||||||
|
// rendered by Alpine.
|
||||||
|
//
|
||||||
|
// The tree serializes to the FLAT facet params when it is simple (one
|
||||||
|
// rule per field, flat-expressible operators — readable deep links
|
||||||
|
// preserved), else to one compact `qf` JSON param. saved_filters store
|
||||||
|
// either (no schema change). On a hand-crafted URL carrying both, qf
|
||||||
|
// wins server-side.
|
||||||
|
|
||||||
|
import { Alpine } from "./alpine";
|
||||||
|
|
||||||
|
// ─── operator + field metadata (client mirror of
|
||||||
|
// internal/services/filter_registry.go — labels drive the menus) ────
|
||||||
|
|
||||||
|
export interface OpMeta {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
negated: boolean;
|
||||||
|
arity: "none" | "one" | "two" | "many";
|
||||||
|
}
|
||||||
|
|
||||||
|
const op = (
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
negated: boolean,
|
||||||
|
arity: OpMeta["arity"],
|
||||||
|
): OpMeta => ({ key, label, negated, arity });
|
||||||
|
|
||||||
|
export const OPS: Record<string, OpMeta> = {
|
||||||
|
contains: op("contains", "contains", false, "many"),
|
||||||
|
not_contains: op("doesn't contain", "doesn't contain", true, "many"),
|
||||||
|
is: op("is", "is", false, "many"),
|
||||||
|
is_not: op("isn't", "isn't", true, "many"),
|
||||||
|
empty: op("is empty", "is empty", false, "none"),
|
||||||
|
is_true: op("is true", "is true", false, "none"),
|
||||||
|
is_false: op("is false", "is false", false, "none"),
|
||||||
|
between: op("between", "between", false, "two"),
|
||||||
|
at_least: op("at least", "at least", false, "one"),
|
||||||
|
at_most: op("at most", "at most", false, "one"),
|
||||||
|
within_days: op("within the last", "within the last", false, "one"),
|
||||||
|
has: op("has", "has", false, "none"),
|
||||||
|
missing: op("missing", "missing", false, "none"),
|
||||||
|
unrated: op("unrated", "unrated", false, "none"),
|
||||||
|
in: op("in", "in", false, "many"),
|
||||||
|
not_in: op("isn't in", "isn't in", true, "many"),
|
||||||
|
in_any: op("in any", "in any", false, "none"),
|
||||||
|
in_none: op("in none", "in none", false, "none"),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FieldMeta {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
group: string;
|
||||||
|
// value kind for the cell: text (with server suggestions when
|
||||||
|
// suggest), number, days, id (server suggestions, id values), fixed
|
||||||
|
// (static option list), none.
|
||||||
|
valueKind: "text" | "number" | "days" | "id" | "fixed" | "none";
|
||||||
|
suggest: boolean;
|
||||||
|
options?: string[];
|
||||||
|
ops: string[];
|
||||||
|
defaultOp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const F = (
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
group: string,
|
||||||
|
valueKind: FieldMeta["valueKind"],
|
||||||
|
ops: string[],
|
||||||
|
defaultOp: string,
|
||||||
|
suggest = false,
|
||||||
|
options?: string[],
|
||||||
|
): FieldMeta => ({ key, label, group, valueKind, ops, defaultOp, suggest, options });
|
||||||
|
|
||||||
|
const TEXT_OPS = ["contains", "not_contains", "is", "is_not", "empty"];
|
||||||
|
const NUM_OPS = ["between", "at_least", "at_most", "empty"];
|
||||||
|
const DATE_OPS = ["within_days", "between", "empty"];
|
||||||
|
|
||||||
|
export const FIELD_GROUPS: { group: string; fields: FieldMeta[] }[] = [
|
||||||
|
{
|
||||||
|
group: "People",
|
||||||
|
fields: [
|
||||||
|
F("author", "Author", "People", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("narrator", "Narrator", "People", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("contributor", "Contributor", "People", "text", TEXT_OPS, "contains", true),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Publishing",
|
||||||
|
fields: [
|
||||||
|
F("publisher", "Publisher", "Publishing", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("imprint", "Imprint", "Publishing", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("edition", "Edition", "Publishing", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("published_year", "Published year", "Publishing", "number", NUM_OPS, "at_least"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Topics",
|
||||||
|
fields: [
|
||||||
|
F("genre", "Genre", "Topics", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("tags", "Tags", "Topics", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("story_arc", "Story arc", "Topics", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("age_rating", "Age rating", "Topics", "text", ["is", "is_not", "empty"], "is", true),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Book facts",
|
||||||
|
fields: [
|
||||||
|
F("title", "Title", "Book facts", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("subtitle", "Subtitle", "Book facts", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("description", "Description", "Book facts", "text", TEXT_OPS, "contains"),
|
||||||
|
F("summary", "Summary", "Book facts", "none", ["has", "missing"], "has"),
|
||||||
|
F("language", "Language", "Book facts", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("page_count", "Page count", "Book facts", "number", NUM_OPS, "at_least"),
|
||||||
|
F("chapter_count", "Chapter count", "Book facts", "number", NUM_OPS, "at_least"),
|
||||||
|
F("total_characters", "Total characters", "Book facts", "number", NUM_OPS, "at_least"),
|
||||||
|
F("community_rating", "Community rating", "Book facts", "number", NUM_OPS, "at_least"),
|
||||||
|
F("added_at", "Date added", "Book facts", "days", DATE_OPS, "within_days"),
|
||||||
|
F("imported_at", "Date imported", "Book facts", "days", DATE_OPS, "within_days"),
|
||||||
|
F("web_url", "Web URL", "Book facts", "none", ["has", "missing"], "has"),
|
||||||
|
F("metadata_notes", "Metadata notes", "Book facts", "none", ["has", "missing"], "has"),
|
||||||
|
F("isbn", "ISBN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
|
||||||
|
F("asin", "ASIN", "Book facts", "text", ["is", "is_not", "has", "missing"], "has"),
|
||||||
|
F("file_size", "File size", "Book facts", "number", NUM_OPS, "at_least"),
|
||||||
|
F("file_availability", "File availability", "Book facts", "fixed",
|
||||||
|
["is", "is_not"], "is", false, ["available", "missing"]),
|
||||||
|
F("cover", "Cover", "Book facts", "none", ["has", "missing"], "has"),
|
||||||
|
F("added_by", "Added by", "Book facts", "id", ["is", "has", "missing"], "has", true),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Comics & manga",
|
||||||
|
fields: [
|
||||||
|
F("series", "Series", "Comics & manga", "text", TEXT_OPS, "contains", true),
|
||||||
|
F("series_number", "Series number", "Comics & manga", "number", NUM_OPS, "at_least"),
|
||||||
|
F("series_count", "Series count", "Comics & manga", "number", NUM_OPS, "at_least"),
|
||||||
|
F("volume", "Volume", "Comics & manga", "number", NUM_OPS, "at_least"),
|
||||||
|
F("format_group", "Format group", "Comics & manga", "text", ["is", "is_not"], "is", true),
|
||||||
|
F("mime_type", "File type (mime)", "Comics & manga", "text", ["is", "is_not"], "is", true),
|
||||||
|
F("is_reflowable", "Reflowable", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
|
||||||
|
F("has_fixed_layout", "Fixed layout", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
|
||||||
|
F("is_black_and_white", "Black & white", "Comics & manga", "none", ["is_true", "is_false"], "is_true"),
|
||||||
|
F("manga_type", "Manga type", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true),
|
||||||
|
F("reading_direction", "Reading direction", "Comics & manga", "text", ["is", "is_not", "empty"], "is", true),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "My reading",
|
||||||
|
fields: [
|
||||||
|
F("status", "Reading status", "My reading", "fixed", ["is"], "is", false,
|
||||||
|
["unread", "reading", "finished"]),
|
||||||
|
F("progress_percent", "Progress percent", "My reading", "number", NUM_OPS, "at_least"),
|
||||||
|
F("last_read", "Last read", "My reading", "days", DATE_OPS, "within_days"),
|
||||||
|
F("my_rating", "My rating", "My reading", "fixed",
|
||||||
|
["is", "at_least", "at_most", "unrated"], "at_least", false,
|
||||||
|
["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"]),
|
||||||
|
F("review", "Has review", "My reading", "none", ["has", "missing"], "has"),
|
||||||
|
F("notes", "Notes", "My reading", "none", ["has", "missing", "within_days"], "has"),
|
||||||
|
F("highlights", "Highlights", "My reading", "none", ["has", "missing", "within_days"], "has"),
|
||||||
|
F("note_text", "Note text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true),
|
||||||
|
F("highlight_text", "Highlight text", "My reading", "text", ["contains", "not_contains", "empty"], "contains", true),
|
||||||
|
F("time_spent", "Time spent (minutes)", "My reading", "number", ["between", "at_least", "at_most"], "at_least"),
|
||||||
|
F("pages_read", "Pages read", "My reading", "number", ["between", "at_least", "at_most"], "at_least"),
|
||||||
|
F("collection", "In collection", "My reading", "id",
|
||||||
|
["in", "not_in", "in_any", "in_none"], "in", true),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Library",
|
||||||
|
fields: [
|
||||||
|
F("library", "Library", "Library", "id", ["is", "is_not"], "is", true),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// fieldByKey / fieldGroupsById — lookup helpers.
|
||||||
|
const FIELD_BY_KEY = new Map<string, FieldMeta>();
|
||||||
|
for (const g of FIELD_GROUPS) {
|
||||||
|
for (const f of g.fields) {
|
||||||
|
FIELD_BY_KEY.set(f.key, f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Grouped ops per field, precomputed in the field's op order.
|
||||||
|
const OPS_BY_FIELD = new Map<string, OpMeta[]>();
|
||||||
|
for (const [, f] of FIELD_BY_KEY) {
|
||||||
|
OPS_BY_FIELD.set(
|
||||||
|
f.key,
|
||||||
|
f.ops.map((k) => OPS[k]).filter(Boolean),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flat-expressible fields: key → the flat wire param a `contains`/`is`
|
||||||
|
// rule serializes to (the PINNED deep-link contract — semantics
|
||||||
|
// byte-identical to today's params).
|
||||||
|
const FLAT_PARAMS: Record<string, string> = {
|
||||||
|
author: "author_filter",
|
||||||
|
series: "series_filter",
|
||||||
|
genre: "genre_filter",
|
||||||
|
publisher: "publisher_filter",
|
||||||
|
tags: "tags_filter",
|
||||||
|
narrator: "narrator_filter",
|
||||||
|
language: "language_filter",
|
||||||
|
story_arc: "story_arc_filter",
|
||||||
|
imprint: "imprint_filter",
|
||||||
|
edition: "edition_filter",
|
||||||
|
age_rating: "age_rating_filter",
|
||||||
|
format_group: "format_group_filter",
|
||||||
|
manga_type: "manga_type_filter",
|
||||||
|
reading_direction: "reading_direction_filter",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── tree state types ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FilterRuleState {
|
||||||
|
field: string;
|
||||||
|
op: string;
|
||||||
|
values: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterGroupState {
|
||||||
|
mode: "all" | "any";
|
||||||
|
children: (FilterRuleState | FilterGroupState)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isRule = (n: FilterRuleState | FilterGroupState): n is FilterRuleState =>
|
||||||
|
"field" in n;
|
||||||
|
|
||||||
|
// flatParamNames are the wire params the flat deep-link contract pins
|
||||||
|
// (plus the two flat extras the builder owns).
|
||||||
|
export const FLAT_WIRE_NAMES = [
|
||||||
|
...Object.values(FLAT_PARAMS),
|
||||||
|
"explicit_filter",
|
||||||
|
"abridged_filter",
|
||||||
|
"year_min",
|
||||||
|
"year_max",
|
||||||
|
"has_cover",
|
||||||
|
];
|
||||||
|
|
||||||
|
// opIsFlatExhaustible — every op that maps 1:1 onto a flat param.
|
||||||
|
function ruleToFlatParam(rule: FilterRuleState): Record<string, string> | null {
|
||||||
|
const joined = rule.values.join(",");
|
||||||
|
if (!joined) return null;
|
||||||
|
const flat = FLAT_PARAMS[rule.field];
|
||||||
|
if (flat && rule.op === "contains") return { [flat]: joined };
|
||||||
|
if (flat && rule.op === "is" &&
|
||||||
|
["age_rating", "format_group", "manga_type", "reading_direction"].includes(rule.field)) {
|
||||||
|
return { [flat]: joined };
|
||||||
|
}
|
||||||
|
if (flat && rule.op === "is" &&
|
||||||
|
["story_arc", "imprint", "edition"].includes(rule.field)) {
|
||||||
|
return { [flat]: joined };
|
||||||
|
}
|
||||||
|
if (rule.field === "cover") {
|
||||||
|
if (rule.op === "has") return { has_cover: "true" };
|
||||||
|
if (rule.op === "missing") return { has_cover: "false" };
|
||||||
|
}
|
||||||
|
if (rule.field === "explicit" && rule.op === "is_true") return { explicit_filter: "true" };
|
||||||
|
if (rule.field === "explicit" && rule.op === "is_false") return { explicit_filter: "false" };
|
||||||
|
if (rule.field === "abridged" && rule.op === "is_true") return { abridged_filter: "true" };
|
||||||
|
if (rule.field === "abridged" && rule.op === "is_false") return { abridged_filter: "false" };
|
||||||
|
if (rule.field === "published_year" && rule.op === "at_least") return { year_min: joined };
|
||||||
|
if (rule.field === "published_year" && rule.op === "at_most") return { year_max: joined };
|
||||||
|
if (rule.field === "published_year" && rule.op === "between") {
|
||||||
|
return { year_min: rule.values[0], year_max: rule.values[1] };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// isFlatable — the smart-serialization rule: a single ALL group of
|
||||||
|
// rules, one rule per field, every rule flat-expressible.
|
||||||
|
function isFlatable(tree: FilterGroupState): boolean {
|
||||||
|
if (tree.mode !== "all") return false;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const child of tree.children) {
|
||||||
|
if (!isRule(child)) return false;
|
||||||
|
if (seen.has(child.field)) return false;
|
||||||
|
seen.add(child.field);
|
||||||
|
if (!ruleToFlatParam(child)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── row projection (flat rendering of the nested tree) ─────────────
|
||||||
|
|
||||||
|
export interface BuilderRow {
|
||||||
|
key: string;
|
||||||
|
path: number[];
|
||||||
|
type: "rule" | "groupOpen" | "groupClose";
|
||||||
|
depth: number;
|
||||||
|
connective: "AND" | "OR";
|
||||||
|
isFirst: boolean;
|
||||||
|
rule?: FilterRuleState;
|
||||||
|
group?: FilterGroupState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectRows(
|
||||||
|
group: FilterGroupState,
|
||||||
|
path: number[],
|
||||||
|
depth: number,
|
||||||
|
out: BuilderRow[],
|
||||||
|
): void {
|
||||||
|
group.children.forEach((child, idx) => {
|
||||||
|
const childPath = [...path, idx];
|
||||||
|
const key = childPath.join("-");
|
||||||
|
if (!isRule(child)) {
|
||||||
|
out.push({
|
||||||
|
key,
|
||||||
|
path: childPath,
|
||||||
|
type: "groupOpen",
|
||||||
|
depth,
|
||||||
|
connective: group.mode === "all" ? "AND" : "OR",
|
||||||
|
isFirst: idx === 0,
|
||||||
|
group: child,
|
||||||
|
});
|
||||||
|
projectRows(child, childPath, depth + 1, out);
|
||||||
|
out.push({
|
||||||
|
key: key + "-close",
|
||||||
|
path: childPath,
|
||||||
|
type: "groupClose",
|
||||||
|
depth,
|
||||||
|
connective: group.mode === "all" ? "AND" : "OR",
|
||||||
|
isFirst: idx === 0,
|
||||||
|
group: child,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
out.push({
|
||||||
|
key,
|
||||||
|
path: childPath,
|
||||||
|
type: "rule",
|
||||||
|
depth,
|
||||||
|
connective: group.mode === "all" ? "AND" : "OR",
|
||||||
|
isFirst: idx === 0,
|
||||||
|
rule: child,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── the Alpine component slice ──────────────────────────────────────
|
||||||
|
|
||||||
|
interface Suggestion {
|
||||||
|
value: string;
|
||||||
|
count: number;
|
||||||
|
display?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterBuilderData() {
|
||||||
|
return {
|
||||||
|
// Rule tree (source of truth; hidden wire inputs are synced after
|
||||||
|
// every mutation).
|
||||||
|
filterTree: { mode: "all", children: [] } as FilterGroupState,
|
||||||
|
matchCount: null as number | null,
|
||||||
|
matchCountLoading: false,
|
||||||
|
openMenu: "" as string, // "<rowKey>:field" | "<rowKey>:op" | "<rowKey>:value" | ""
|
||||||
|
suggestions: {} as Record<string, Suggestion[]>,
|
||||||
|
suggestionsOpen: "" as string, // rowKey whose value menu is open
|
||||||
|
idLabels: {} as Record<string, string>, // id → display, from suggestion fetches
|
||||||
|
_suggestTimer: null as number | null,
|
||||||
|
_countTimer: null as number | null,
|
||||||
|
|
||||||
|
get fieldGroups() {
|
||||||
|
return FIELD_GROUPS;
|
||||||
|
},
|
||||||
|
|
||||||
|
get builderRows(): BuilderRow[] {
|
||||||
|
const rows: BuilderRow[] = [];
|
||||||
|
projectRows(this.filterTree as FilterGroupState, [], 0, rows);
|
||||||
|
return rows;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── lookups ─────────────────────────────────────────────────────
|
||||||
|
fieldMeta(key: string): FieldMeta | undefined {
|
||||||
|
return FIELD_BY_KEY.get(key);
|
||||||
|
},
|
||||||
|
fieldLabelOf(key: string): string {
|
||||||
|
return FIELD_BY_KEY.get(key)?.label ?? "Field";
|
||||||
|
},
|
||||||
|
opsFor(fieldKey: string): OpMeta[] {
|
||||||
|
return OPS_BY_FIELD.get(fieldKey) ?? [];
|
||||||
|
},
|
||||||
|
opMeta(opKey: string): OpMeta | undefined {
|
||||||
|
return OPS[opKey];
|
||||||
|
},
|
||||||
|
opLabelOf(rule: FilterRuleState): string {
|
||||||
|
const m = OPS[rule.op];
|
||||||
|
return m ? m.label : rule.op;
|
||||||
|
},
|
||||||
|
isNegated(rule: FilterRuleState): boolean {
|
||||||
|
return OPS[rule.op]?.negated ?? false;
|
||||||
|
},
|
||||||
|
valueArity(rule: FilterRuleState): OpMeta["arity"] {
|
||||||
|
return OPS[rule.op]?.arity ?? "many";
|
||||||
|
},
|
||||||
|
hasValueCell(rule: FilterRuleState): boolean {
|
||||||
|
const field = FIELD_BY_KEY.get(rule.field);
|
||||||
|
if (!field) return false;
|
||||||
|
return field.valueKind !== "none" && this.valueArity(rule) !== "none";
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── mutations (each ends in form sync + live count refresh) ────
|
||||||
|
_touch(): void {
|
||||||
|
this.syncFormInputs();
|
||||||
|
this.scheduleMatchCount();
|
||||||
|
},
|
||||||
|
ensureReadyRule(): void {
|
||||||
|
if (this.filterTree.children.length === 0) {
|
||||||
|
this.filterTree.children.push({ field: "author", op: "contains", values: [] });
|
||||||
|
this._touch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nodeAt(path: number[]): FilterRuleState | FilterGroupState | null {
|
||||||
|
let node: FilterRuleState | FilterGroupState = this.filterTree;
|
||||||
|
for (const idx of path) {
|
||||||
|
if (!("children" in node)) return null;
|
||||||
|
const next = (node as FilterGroupState).children[idx];
|
||||||
|
if (!next) return null;
|
||||||
|
node = next;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
},
|
||||||
|
addRule(path: number[]): void {
|
||||||
|
const parent = this.nodeAt(path);
|
||||||
|
if (!parent || !("children" in parent)) return;
|
||||||
|
(parent as FilterGroupState).children.push({ field: "author", op: "contains", values: [] });
|
||||||
|
this._touch();
|
||||||
|
},
|
||||||
|
addGroup(path: number[]): void {
|
||||||
|
const parent = this.nodeAt(path);
|
||||||
|
if (!parent || !("children" in parent)) return;
|
||||||
|
(parent as FilterGroupState).children.push({
|
||||||
|
mode: "any",
|
||||||
|
children: [{ field: "author", op: "contains", values: [] }],
|
||||||
|
});
|
||||||
|
this._touch();
|
||||||
|
},
|
||||||
|
removeNode(path: number[]): void {
|
||||||
|
if (path.length === 0) return;
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
const idx = path[path.length - 1];
|
||||||
|
const parent = this.nodeAt(parentPath);
|
||||||
|
if (!parent || !("children" in parent)) return;
|
||||||
|
(parent as FilterGroupState).children.splice(idx, 1);
|
||||||
|
if (this.openMenu.startsWith(path.join("-") + ":")) this.openMenu = "";
|
||||||
|
this._touch();
|
||||||
|
},
|
||||||
|
setGroupMode(path: number[], mode: "all" | "any"): void {
|
||||||
|
const node = this.nodeAt(path);
|
||||||
|
if (!node || !("children" in node)) return;
|
||||||
|
(node as FilterGroupState).mode = mode;
|
||||||
|
this._touch();
|
||||||
|
},
|
||||||
|
setRuleField(path: number[], key: string): void {
|
||||||
|
const node = this.nodeAt(path);
|
||||||
|
if (!node || !isRule(node)) return;
|
||||||
|
if (node.field === key) {
|
||||||
|
this.openMenu = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Field switch clears the value + resets the operator to the
|
||||||
|
// kind's default (approved behavior).
|
||||||
|
node.field = key;
|
||||||
|
const meta = FIELD_BY_KEY.get(key);
|
||||||
|
node.op = meta?.defaultOp ?? "contains";
|
||||||
|
node.values = [];
|
||||||
|
this.openMenu = "";
|
||||||
|
delete this.suggestions[path.join("-")];
|
||||||
|
this._touch();
|
||||||
|
},
|
||||||
|
setRuleOp(path: number[], opKey: string): void {
|
||||||
|
const node = this.nodeAt(path);
|
||||||
|
if (!node || !isRule(node)) return;
|
||||||
|
node.op = opKey;
|
||||||
|
if (this.valueArity(node) === "none") {
|
||||||
|
node.values = [];
|
||||||
|
} else if (this.valueArity(node) === "two") {
|
||||||
|
while (node.values.length < 2) node.values.push("");
|
||||||
|
} else if (node.values.length > 1) {
|
||||||
|
// keep the typed values; multi stays valid for many-arity
|
||||||
|
} else if (node.values.length === 0) {
|
||||||
|
node.values = [""];
|
||||||
|
}
|
||||||
|
this.openMenu = "";
|
||||||
|
this._touch();
|
||||||
|
},
|
||||||
|
setRuleValue(path: number[], idx: number, value: string): void {
|
||||||
|
const node = this.nodeAt(path);
|
||||||
|
if (!node || !isRule(node)) return;
|
||||||
|
node.values[idx] = value;
|
||||||
|
this._touch();
|
||||||
|
if (idx === 0 && FIELD_BY_KEY.get(node.field)?.suggest) {
|
||||||
|
this.scheduleSuggestions(path.join("-"), path, node);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pickSuggestion(_rowKey: string, path: number[], s: Suggestion): void {
|
||||||
|
const node = this.nodeAt(path);
|
||||||
|
if (!node || !isRule(node)) return;
|
||||||
|
const meta = FIELD_BY_KEY.get(node.field);
|
||||||
|
this.idLabels[s.value] = s.display ?? s.value;
|
||||||
|
if (meta?.valueKind === "id") {
|
||||||
|
// id-valued: toggle the id in the comma list.
|
||||||
|
const ids = node.values.join(",").split(",").filter(Boolean);
|
||||||
|
const at = ids.indexOf(s.value);
|
||||||
|
if (at >= 0) {
|
||||||
|
ids.splice(at, 1);
|
||||||
|
} else {
|
||||||
|
ids.push(s.value);
|
||||||
|
}
|
||||||
|
node.values = [ids.join(",")];
|
||||||
|
} else if (this.valueArity(node) === "two") {
|
||||||
|
node.values[0] = s.value;
|
||||||
|
} else {
|
||||||
|
node.values = [s.value];
|
||||||
|
}
|
||||||
|
this.suggestionsOpen = "";
|
||||||
|
this._touch();
|
||||||
|
},
|
||||||
|
clearBuilder(): void {
|
||||||
|
this.filterTree = { mode: "all", children: [] };
|
||||||
|
this.matchCount = null;
|
||||||
|
this.suggestions = {};
|
||||||
|
this.suggestionsOpen = "";
|
||||||
|
this.openMenu = "";
|
||||||
|
this._touch();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── menus ───────────────────────────────────────────────────────
|
||||||
|
toggleMenu(menuKey: string): void {
|
||||||
|
this.openMenu = this.openMenu === menuKey ? "" : menuKey;
|
||||||
|
if (this.openMenu.endsWith(":value")) {
|
||||||
|
this.suggestionsOpen = this.openMenu.split(":")[0];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── suggestions (field-aware, under the OTHER rules) ────────────
|
||||||
|
scheduleSuggestions(rowKey: string, path: number[], rule: FilterRuleState): void {
|
||||||
|
if (this._suggestTimer !== null) window.clearTimeout(this._suggestTimer);
|
||||||
|
this._suggestTimer = window.setTimeout(() => {
|
||||||
|
void this.fetchSuggestions(rowKey, path, rule);
|
||||||
|
}, 250);
|
||||||
|
},
|
||||||
|
async fetchSuggestions(rowKey: string, path: number[], rule: FilterRuleState): Promise<void> {
|
||||||
|
const meta = FIELD_BY_KEY.get(rule.field);
|
||||||
|
if (!meta || !meta.suggest) return;
|
||||||
|
// Fixed-option fields never hit the server.
|
||||||
|
if (meta.valueKind === "fixed") return;
|
||||||
|
const typed = rule.values[0] ?? "";
|
||||||
|
if (typed.trim().length < 2 && meta.valueKind !== "id") return;
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return;
|
||||||
|
// Symmetry: the tree sent EXCLUDES the rule being edited — the
|
||||||
|
// menu is computed under every OTHER completed rule.
|
||||||
|
const others = this.treeWithout(path);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set(meta.valueKind === "id" ? rule.field : rule.field, typed.trim() || " ");
|
||||||
|
if (others) params.set("qf", JSON.stringify(others));
|
||||||
|
const currentLibraryId = (
|
||||||
|
document.getElementById("library-select") as HTMLSelectElement
|
||||||
|
)?.value;
|
||||||
|
if (currentLibraryId) params.set("library_id", currentLibraryId);
|
||||||
|
params.set("limit", "8");
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/media-items/search?${params.toString()}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`suggestion fetch failed (${response.status})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { results?: Suggestion[] };
|
||||||
|
for (const s of data.results ?? []) {
|
||||||
|
if (s.display) this.idLabels[s.value] = s.display;
|
||||||
|
}
|
||||||
|
this.suggestions[rowKey] = data.results ?? [];
|
||||||
|
this.suggestionsOpen = rowKey;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("suggestion fetch failed", error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openValueMenu(rowKey: string, path: number[], rule: FilterRuleState): void {
|
||||||
|
const meta = FIELD_BY_KEY.get(rule.field);
|
||||||
|
if (meta?.valueKind === "fixed") {
|
||||||
|
// Static options: no fetch.
|
||||||
|
this.suggestions[rowKey] = (meta.options ?? []).map((v) => ({
|
||||||
|
value: v,
|
||||||
|
count: 0,
|
||||||
|
}));
|
||||||
|
this.suggestionsOpen = rowKey;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (meta?.suggest) {
|
||||||
|
void this.fetchSuggestions(rowKey, path, rule);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.suggestionsOpen = rowKey;
|
||||||
|
},
|
||||||
|
suggestionShown(rowKey: string): boolean {
|
||||||
|
return this.suggestionsOpen === rowKey && (this.suggestions[rowKey]?.length ?? 0) > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── live count ──────────────────────────────────────────────────
|
||||||
|
scheduleMatchCount(): void {
|
||||||
|
if (this._countTimer !== null) window.clearTimeout(this._countTimer);
|
||||||
|
this._countTimer = window.setTimeout(() => {
|
||||||
|
void this.fetchMatchCount();
|
||||||
|
}, 350);
|
||||||
|
},
|
||||||
|
async fetchMatchCount(): Promise<void> {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return;
|
||||||
|
if (this.filterTree.children.length === 0) {
|
||||||
|
this.matchCount = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("qf", JSON.stringify(this.filterTree));
|
||||||
|
const currentLibraryId = (
|
||||||
|
document.getElementById("library-select") as HTMLSelectElement
|
||||||
|
)?.value;
|
||||||
|
if (currentLibraryId) params.set("library_id", currentLibraryId);
|
||||||
|
this.matchCountLoading = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/media-items/filter-count?${params.toString()}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
this.matchCount = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as { count?: number };
|
||||||
|
this.matchCount = data.count ?? null;
|
||||||
|
} catch {
|
||||||
|
this.matchCount = null;
|
||||||
|
} finally {
|
||||||
|
this.matchCountLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── serialization + form sync ───────────────────────────────────
|
||||||
|
// treeWithout returns the tree with the node at `path` removed (the
|
||||||
|
// "other rules" for a rule's own value menu).
|
||||||
|
treeWithout(path: number[]): FilterGroupState | null {
|
||||||
|
const clone: FilterGroupState = JSON.parse(JSON.stringify(this.filterTree));
|
||||||
|
if (path.length === 0) return clone;
|
||||||
|
const parentPath = path.slice(0, -1);
|
||||||
|
let node: FilterGroupState = clone;
|
||||||
|
for (const idx of parentPath) {
|
||||||
|
const next = node.children[idx];
|
||||||
|
if (!next || isRule(next)) return clone;
|
||||||
|
node = next;
|
||||||
|
}
|
||||||
|
node.children.splice(path[path.length - 1], 1);
|
||||||
|
return clone;
|
||||||
|
},
|
||||||
|
serializeFilters():
|
||||||
|
| { mode: "flat"; params: Record<string, string> }
|
||||||
|
| { mode: "qf"; qf: string } {
|
||||||
|
if (this.filterTree.children.length === 0) {
|
||||||
|
return { mode: "flat", params: {} };
|
||||||
|
}
|
||||||
|
if (isFlatable(this.filterTree as FilterGroupState)) {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (const child of this.filterTree.children) {
|
||||||
|
if (!isRule(child)) continue;
|
||||||
|
Object.assign(params, ruleToFlatParam(child));
|
||||||
|
}
|
||||||
|
return { mode: "flat", params };
|
||||||
|
}
|
||||||
|
return { mode: "qf", qf: JSON.stringify(this.filterTree) };
|
||||||
|
},
|
||||||
|
syncFormInputs(): void {
|
||||||
|
const form = document.getElementById("filter-form") as HTMLFormElement | null;
|
||||||
|
if (!form) return;
|
||||||
|
// Drop every previously managed wire input, then write the
|
||||||
|
// current serialization.
|
||||||
|
for (const name of [...FLAT_WIRE_NAMES, "qf"]) {
|
||||||
|
form.querySelectorAll(`input[name="${name}"]`).forEach((el) => el.remove());
|
||||||
|
}
|
||||||
|
const s = this.serializeFilters();
|
||||||
|
const add = (name: string, value: string) => {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "hidden";
|
||||||
|
input.name = name;
|
||||||
|
input.value = value;
|
||||||
|
form.appendChild(input);
|
||||||
|
};
|
||||||
|
if (s.mode === "qf") {
|
||||||
|
add("qf", s.qf);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const flatParams = s.params as Record<string, string>;
|
||||||
|
for (const [name, value] of Object.entries(flatParams)) {
|
||||||
|
add(name, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── hydration (URL deep links + saved filters) ──────────────────
|
||||||
|
// hydrateFromForm reads the form's wire inputs into the tree: a qf
|
||||||
|
// input wins (pinned precedence), else the flat params become one
|
||||||
|
// rule each. Returns true when anything was applied.
|
||||||
|
hydrateFromForm(): boolean {
|
||||||
|
const form = document.getElementById("filter-form") as HTMLFormElement | null;
|
||||||
|
if (!form) return false;
|
||||||
|
const qfInput = form.querySelector('input[name="qf"]') as HTMLInputElement | null;
|
||||||
|
if (qfInput && qfInput.value) {
|
||||||
|
try {
|
||||||
|
const tree = JSON.parse(qfInput.value) as FilterGroupState;
|
||||||
|
if (tree && Array.isArray(tree.children)) {
|
||||||
|
this.filterTree = tree;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// malformed qf: fall through to flat mapping
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rules: FilterRuleState[] = [];
|
||||||
|
for (const [fieldKey, wire] of Object.entries(FLAT_PARAMS)) {
|
||||||
|
const input = form.querySelector(`input[name="${wire}"]`) as HTMLInputElement | null;
|
||||||
|
if (!input || !input.value) continue;
|
||||||
|
const meta = FIELD_BY_KEY.get(fieldKey);
|
||||||
|
const op = meta && meta.ops.includes("contains") ? "contains" : "is";
|
||||||
|
rules.push({ field: fieldKey, op, values: [input.value] });
|
||||||
|
}
|
||||||
|
const cover = form.querySelector('input[name="has_cover"]') as HTMLInputElement | null;
|
||||||
|
if (cover && cover.value) {
|
||||||
|
rules.push({ field: "cover", op: cover.value === "true" ? "has" : "missing", values: [] });
|
||||||
|
}
|
||||||
|
const explicitIn = form.querySelector('input[name="explicit_filter"]') as HTMLInputElement | null;
|
||||||
|
if (explicitIn && explicitIn.value) {
|
||||||
|
rules.push({ field: "explicit", op: explicitIn.value === "true" ? "is_true" : "is_false", values: [] });
|
||||||
|
}
|
||||||
|
const abridgedIn = form.querySelector('input[name="abridged_filter"]') as HTMLInputElement | null;
|
||||||
|
if (abridgedIn && abridgedIn.value) {
|
||||||
|
rules.push({ field: "abridged", op: abridgedIn.value === "true" ? "is_true" : "is_false", values: [] });
|
||||||
|
}
|
||||||
|
const yearMin = form.querySelector('input[name="year_min"]') as HTMLInputElement | null;
|
||||||
|
if (yearMin && yearMin.value) {
|
||||||
|
rules.push({ field: "published_year", op: "at_least", values: [yearMin.value] });
|
||||||
|
}
|
||||||
|
const yearMax = form.querySelector('input[name="year_max"]') as HTMLInputElement | null;
|
||||||
|
if (yearMax && yearMax.value) {
|
||||||
|
rules.push({ field: "published_year", op: "at_most", values: [yearMax.value] });
|
||||||
|
}
|
||||||
|
if (rules.length === 0) return false;
|
||||||
|
this.filterTree = { mode: "all", children: rules };
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// chipLabelFor — one rule's chip text.
|
||||||
|
chipLabelFor(rule: FilterRuleState): string {
|
||||||
|
const fieldLabel = this.fieldLabelOf(rule.field);
|
||||||
|
const opLabel = this.opLabelOf(rule);
|
||||||
|
const meta = FIELD_BY_KEY.get(rule.field);
|
||||||
|
let value = "";
|
||||||
|
if (this.valueArity(rule) !== "none" && meta?.valueKind !== "none") {
|
||||||
|
const names = rule.values
|
||||||
|
.join(",")
|
||||||
|
.split(",")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((v) => this.idLabels[v] ?? v);
|
||||||
|
value = names.length > 0 ? ` ${names.join(", ")}` : "";
|
||||||
|
if (meta?.valueKind === "days") value = ` ${rule.values[0]} days`;
|
||||||
|
}
|
||||||
|
return `${fieldLabel} ${opLabel}${value}`.trim();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component registration (main.ts imports this module for the side
|
||||||
|
// effect, same pattern as bookshelf.ts).
|
||||||
|
Alpine.data("filterBuilderExtra", filterBuilderData);
|
||||||
@@ -8,6 +8,7 @@ import "./api-explorer";
|
|||||||
import "./api-explorer-docs";
|
import "./api-explorer-docs";
|
||||||
import "./bookPicker";
|
import "./bookPicker";
|
||||||
import "./bookshelf";
|
import "./bookshelf";
|
||||||
|
import "./filter_builder";
|
||||||
import "./book-detail";
|
import "./book-detail";
|
||||||
import "./collection-rules";
|
import "./collection-rules";
|
||||||
import "./collections";
|
import "./collections";
|
||||||
|
|||||||
+8
-6
@@ -176,12 +176,14 @@ const setupHTMXListeners = (): void => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Suggestion fetches — field-value autocomplete (author=, genre=, …)
|
// Suggestion fetches — field-value autocomplete on the search endpoint
|
||||||
// on the search endpoint — are background hints for typed inputs. A
|
// (author=, genre=, note_text=, collection=, … any filter-registry
|
||||||
// failed hint must never interrupt the user with a toast: it logs to
|
// field key) — are background hints for typed inputs. A failed hint
|
||||||
// the console only. The grid's real search (q=/sort= params) is NOT a
|
// must never interrupt the user with a toast: it logs to the console
|
||||||
// suggestion fetch and keeps its toasts.
|
// only. The grid's real search (q=/sort= params) is NOT a suggestion
|
||||||
const suggestionFieldRe = /[?&](author|genre|tags|narrator|series|language)=/;
|
// fetch and keeps its toasts.
|
||||||
|
const suggestionFieldRe =
|
||||||
|
/[?&](author|narrator|contributor|publisher|imprint|edition|genre|tags|story_arc|age_rating|title|subtitle|language|format_group|mime_type|manga_type|reading_direction|series|note_text|highlight_text|collection|library|added_by)=/;
|
||||||
function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
|
function isSuggestionFetch(input: Parameters<typeof fetch>[0]): boolean {
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||||
if (!url.includes("/api/media-items/search?")) return false;
|
if (!url.includes("/api/media-items/search?")) return false;
|
||||||
|
|||||||
@@ -1909,3 +1909,126 @@
|
|||||||
background: inherit;
|
background: inherit;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Filter rule builder (filter drawer, Phase 9) ----------
|
||||||
|
Templ owns the markup; Alpine owns the rule-tree state. Look only:
|
||||||
|
rows indent by group depth, negated operators/chips run red. */
|
||||||
|
.fb-connective {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
min-width: 1.6rem;
|
||||||
|
}
|
||||||
|
.fb-picker {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fb-picker:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.fb-picker-active {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||||
|
}
|
||||||
|
.fb-picker-op {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.fb-negated-text {
|
||||||
|
color: var(--status-danger);
|
||||||
|
}
|
||||||
|
.fb-value {
|
||||||
|
width: 9rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
}
|
||||||
|
.fb-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 60;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
width: 15rem;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.fb-menu-value {
|
||||||
|
width: 16rem;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.fb-menu-group {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.5rem 0.75rem 0.15rem;
|
||||||
|
}
|
||||||
|
.fb-menu-item {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.35rem 0.75rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fb-menu-item:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
.fb-menu-item-active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.fb-mode-btn {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fb-mode-btn.fb-mode-active {
|
||||||
|
background-color: var(--accent-muted);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.fb-mini-btn {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
background-color: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fb-mini-btn:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.fb-mini-btn.fb-danger {
|
||||||
|
border-style: solid;
|
||||||
|
color: var(--status-danger);
|
||||||
|
}
|
||||||
|
.fb-group-end {
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
height: 0.5rem;
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
}
|
||||||
|
.fb-nested {
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding-left: 0.6rem;
|
||||||
|
}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }
|
||||||
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Noto Sans SC,Noto Sans TC,Noto Sans JP,Noto Sans KR,PingFang SC,Microsoft YaHei,Hiragino Sans,Apple SD Gothic Neo,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}input:where(:not([type])),input:where([type=date]),input:where([type=datetime-local]),input:where([type=email]),input:where([type=month]),input:where([type=number]),input:where([type=password]),input:where([type=search]),input:where([type=tel]),input:where([type=text]),input:where([type=time]),input:where([type=url]),input:where([type=week]),select,select:where([multiple]),textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:#fff;border-color:#6b7280;border-width:1px;border-radius:0;padding:.5rem .75rem;font-size:1rem;line-height:1.5rem;--tw-shadow:0 0 #0000}input:where(:not([type])):focus,input:where([type=date]):focus,input:where([type=datetime-local]):focus,input:where([type=email]):focus,input:where([type=month]):focus,input:where([type=number]):focus,input:where([type=password]):focus,input:where([type=search]):focus,input:where([type=tel]):focus,input:where([type=text]):focus,input:where([type=time]):focus,input:where([type=url]):focus,input:where([type=week]):focus,select:focus,select:where([multiple]):focus,textarea:focus{outline:2px solid transparent;outline-offset:2px;--tw-ring-inset:var(--tw-empty,/*!*/ /*!*/);--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#2563eb;--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow);border-color:#2563eb}input::-moz-placeholder,textarea::-moz-placeholder{color:#6b7280;opacity:1}input::placeholder,textarea::placeholder{color:#6b7280;opacity:1}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-date-and-time-value{min-height:1.5em;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-meridiem-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-year-field{padding-top:0;padding-bottom:0}select{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath Line truncated
|
||||||
Reference in new issue
Block a user