feat(filters): the rule builder — composed engines over the field registry (faceted plan Phase 9)

- Field registry (services/filter_registry.go): ~55 typed fields —
  every book column, file/lifecycle facts, the three HOLDING
  STRUCTURES (library, collections with excluded-aware membership,
  series with FULL primary+alternate_info membership), and the whole
  user-relative class (status, progress, last read, my rating, review,
  notes/highlights presence, note/highlight text, time spent, pages
  read, collections, added by) reading the VIEWER's rows. Operators
  are typed constants; negation is an operator.
- Composed engines (services/filter_query.go): one Go clause builder
  over raw pgx feeds the search (identical SearchMediaItemsUnifiedRow
  shape), the field-aware suggestions, and a true-COUNT live count.
  Mandatory WHERE head everywhere (family lens + integrity facts).
  Values are always bound parameters — no user string reaches a
  field/operator position, ever.
- Wire: the qf JSON param (validated strictly — unknown
  fields/operators/malformed values are 400); qf wins over flat params
  on hand-crafted URLs; flat deep links keep working unchanged.
- Endpoints: GET /api/media-items/filter-count (live count);
  field-values autocomplete upgraded in place — any registry field
  key, qf binds the OTHER rules (symmetric under-filter suggestions,
  zero-match values never offered); classic six without a tree keep
  their static pinned contract.
- UI: the drawer becomes the rule builder — the ONLY filter UI (templ
  markup, Alpine tree state, sanctioned island; grouped field menus,
  operator menus with red negations, field-aware value menus, live
  count, chips per rule with red negations, URL rehydration, smart
  serialization flat-when-simple else qf).
- Tests: composed semantics (AND/OR/negation/nesting/enums/bools/
  numbers/dates/empty), every user-relative field class with the
  my-data weld (two readers, different worlds), note-text substring
  pin (catharsis ≠ Carthage), family lens (hidden libraries nowhere,
  403s), suggestion-under-rules exactness, qf validation + precedence,
  flat-vs-tree equivalence; registry completeness unit test.
This commit is contained in:
John O'Keefe committed 2026-10-20 14:49:50 -04:00
1 parent ebb7f4a5b0
commit 4ee24e7152
18 files changed
+3788 -376

No files matched your search

+123
View File
@@ -1909,3 +1909,126 @@
background: inherit;
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;
}