Compare commits

..
57 Commits
Author SHA1 Message Date
John O'Keefe 9d0ae2bcc4 feat(web): sync-conflict banner at top of media item page + end-time help row
Release / build-and-push (push) Successful in 3m9s
- A progress sync conflict used to be flagged only inside the progress
  card, far down a long page. An unresolved conflict now also renders
  a full-width warning strip at the very top (under the app bar) that
  opens the same resolution modal on click. Rendered from the same
  ActiveConflict the progress section uses — absent when clean.
- Shortcuts dialog Mouse section gains an 'End time' row: click to
  cycle total → book left → chapter left (§10's toggle was
  undiscoverable).
2026-10-23 12:30:12 -04:00
John O'Keefe f8de17723a feat(web): sleep timer upgrades — undo toast, chain mode, clock stop, wind-down dim (plan §17)
- Undo: a genuine sleep fire now toasts with a Resume action (toast.ts
  grows an optional action button; acting dismisses).
- Chain: 'End of chapter, then 15 min' — leaving the chapter starts a
  15-min countdown instead of fading (SleepState.followUpMinutes).
- Clock: the popover gains a time input + Arm — armClockTime resolves
  HH:MM to its next occurrence and reuses the minutes machinery.
- Dim: the ticker eases a pointer-events-none overlay toward 35% black
  across a countdown's final 30s; stop/finish resets it.
(Fade-out and countdown labels already existed — §3 built them.)
2026-10-23 11:06:42 -04:00
John O'Keefe 4769e832c4 fix(web): favicon missing on the audiobook listening page
audio_reader.templ's <head> was the only page head without the
favicon link — the tab icon vanished the moment you opened a
listener. Standard <link rel=icon> added, matching every sibling
page.
2026-10-23 10:43:10 -04:00
John O'Keefe 03c3e151dc feat(audio-player): gear-menu default speed + split shortcuts into Mouse/Touch (plan §12)
Release / build-and-push (push) Successful in 3m13s
The Player settings popover gains a 'Default playback speed' row
beside autoplay — a compact rate select, always visible (the Speed ▾
footer hides when current == default, which made the setting
undiscoverable at 1x/1×); same setDefaultSpeed PUT. The shortcuts
dialog splits its pointer section into MOUSE (click/drag/shift/wheel)
and TOUCH (drag/pull-away) — the bar has two scrub systems and the
sections now say so.
2026-10-22 15:39:14 -04:00
John O'Keefe 3576a4415a feat(audio-player): per-book playback speed + user default (plan §8)
Speed becomes a per-USER-per-BOOK preference that syncs across
devices, with a user-level default for books not yet played:
- new audio_playback_speed table (mirrors reading_speed's shape; a
  preference, NOT a measurement and NOT a reading_progress column) +
  users.default_audio_speed, schema in place
- chapters endpoint resolves per-book row → user default → 1.0 and
  ships playback_speed + default_playback_speed (zero extra round
  trips; default powers the dropdown marker)
- PUT /api/media-items/:id/audio-speed upserts (range-checked; the
  client SPEEDS list stays the real gate; library visibility gates
  writes)
- the user default rides the profile path like theme/timezone:
  profile form gains a 'Default Playback Speed' select (server-side
  SpeedOptions mirrors the web SPEEDS list), GET /profile carries it
- web: restorePosition applies the resolved server speed (localStorage
  audio_speed demotes to a first-paint cache); setSpeed PUTs
  fire-and-forget with a failure toast; the Speed dropdown gains a
  '(default)' row marker and a 'Make N× my default' action with a
  success toast

ALSO fixes a pre-existing profile-page bug surfaced by the §8 form
verification: the password section's current_password input was
required INSIDE the shared form, so HTML5 validation blocked every
Save Changes submission (dead since the shell migration). The
password flow keeps its own gates (disabled-button checkers + server
400).

Integration test TestAudioPlaybackSpeed pins the full contract (unset
→ 1x, upsert resolution, range rejection, default fallback for
row-less books, default updates re-satisfying unset books). Verified
live: 9 Playwright checks.
2026-10-22 15:01:41 -04:00
John O'Keefe 3325b64463 fix(audio-player): precision rail rendered transparent
Alpine :style string bindings REPLACE the element's style attribute —
the rail's static background/border/z-index were wiped the moment the
binding evaluated, leaving bare text over the transports. Fold all
declarations into the single :style binding.
2026-10-22 14:10:42 -04:00
John O'Keefe a9430f3c06 feat(audio-player): end-time label toggles total / book-left / chapter-left (plan §10 web)
Clicking the end time cycles the three display modes, persisted per
device in localStorage (audio_time_display). Pure helpers (mode list,
normalize, cycle, endTimeLabel with 0-clamp and no-chapters fallback
to book remaining) + vitest; aria-label announces the current mode and
the next. Remaining values track the scrub cursor mid-drag. 140 unit
tests; 15 Playwright checks on KJV + Gardens.
2026-10-22 13:11:16 -04:00
John O'Keefe 96fd2aad42 feat(audio-player): scrub interaction rework — free-flow drag, pixel magnetism, touch pull-away precision (plan §9)
Mouse: drag is free-flow 1:1 (rail only on Shift+drag, now with
window from/to edge labels); clicks land with pixel magnetism
(MAGNET_RADIUS_PX=10) — the old 5-second threshold was sub-pixel on
long books and never fired, which was the 'chapter scrub isn't
accurate' bug. Hover inside the radius locks the tooltip to the line
with a highlight. Shift+click jumps to the nearest line from anywhere.

Touch: drag is an incremental cursor (applyDrag) whose scale tightens
continuously as the finger pulls off the bar (dragScale, exponential
10^(-|dy|/80px)) — the rail is the feedback: window shrinks with the
zoom (touchRailSpan) and lifts toward the finger (railLiftPx).

Dialog retitled 'Shortcuts & interactions' with a Mouse & touch
section driven by POINTER_GUIDE (audio-keys.ts). Old seconds-threshold
snapToChapter deleted. 133 unit tests; 19 Playwright checks on the
KJV (1189ch) and Gardens (34ch) books.
2026-10-22 13:05:10 -04:00
John O'Keefe 1f30602c23 feat(audio-player): progress bar overhaul — ticks, buffer, wheel, tooltip, precision rail (plan §1)
The scrubber becomes a layered bar: the native <input type=range> stays
on top for a11y (focus, arrow keys, aria — its track goes transparent
via static page CSS) while the layers beneath render the state:

- Chapter ticks: density-decimated (denseTicks keeps only marks >= 4px
  apart) so an 1189-chapter 86-hour book gets ~100 readable marks
  instead of a solid mass; the current chapter's tick is tinted.
- Buffered span: the ACTIVE track's buffered-ahead end mapped into the
  global bar (buffers are per-track and never span the whole bar).
  bufferedAheadEnd reads the range containing the playhead — a huge
  m4b's moov index at the file tail is its own buffered range and must
  not read as 'the whole book is buffered'. Rides progress + the
  events that demonstrably fire for Range-served files that go
  network-idle while paused (timeupdate/canplay/loadedmetadata/playing).
- Wheel over the bar: ±5s nudge, Shift = ±60s.
- Hover tooltip: time + chapter title under the pointer.
- Precision rail: while dragging, a ±5-minute window floats below the
  bar (fixed anchor; release and re-grab to travel far) and the drag
  maps at minutes-per-pixel instead of hours-per-pixel — the whole
  point for 80h+ books. Click snaps to a chapter line within ~5s
  (snapToChapter).

Pointer handling is ours end-to-end: pointerdown maps hours-per-pixel
+ snaps, captured moves remap through the rail, release seeks. The
native drag default is prevented so the two mappings never fight;
keyboard arrows keep their native step and seek via change.

Time labels get reserved width — the bar no longer resizes when the
elapsed label grows from 0:00 to 43:07 mid-drag (caught by the rail
drag check measuring a 25% mapping drift).

Pure math in audio-scrubber.ts: pointer mapping, chapter lookup, snap,
tick density, per-track buffer→global span, rail window/mapping/ticks.
Unit: 16 new tests including both book shapes (86h/1189ch and
26h/34ch). Live: 23 Playwright checks on KJV (folder) and Gardens of
the Moon (single-file m4b); zip books share the folder manifest shape
(no zip fixture exists in the dev library). Probe user deleted.
2026-10-22 09:49:02 -04:00
John O'Keefe 18bed79d3a feat(audio-player): settings popover + autoplay-on-open (plan §4)
Header gear (beside the keyboard icon) opens a 'Player settings'
popover — one option row today, structured for more: 'Autoplay on
open', a role=switch toggle persisted to localStorage audio_autoplay
('1'/'0', default off) via the normalizeAutoplay one-source helper.

maybeAutoplay() runs strictly AFTER restorePosition resolves: the
?loc=audio:<seconds> deep link keeps winning on position; the setting
only decides whether playback starts. A browser autoplay-policy
rejection (or any play() failure) is swallowed silently — the user
just taps play; the unhandled-rejection trap in init would otherwise
shout 'REJ:' into the title. settingsOpen joins closeTopSurface, so
Esc closes the panel with the other surfaces.

Unit: normalizeAutoplay tests. Live: 13 Playwright checks — default
off stays paused, toggle + persistence, reload autoplays with no
gesture, deep link lands at 600s global while autoplay starts, off
stays paused, and a stubbed NotAllowedError proves the silent
swallow; probe user created and deleted.
2026-10-22 09:04:03 -04:00
John O'Keefe fe7eab91e8 feat(audio-player): sleep timer — moon pill, countdown, 5s fade then pause (plan §3)
Off / 5 / 10 / 15 / 30 / 45 / 60 minutes / end-of-chapter. The moon
pill sits in the settings strip between Bookmark and Volume and shows
the live mm:ss countdown while armed (or 'End of chapter'); the
dropdown's armed row highlights like Skip/Speed.

audio-sleep.ts is the pure, DOM-free state machine (arm/due/fired/
fade-ramp/label + the SLEEP_CHOICES vocabulary the templ dropdown
iterates). The component owns the timers: a 1s countdown ticker in
minutes mode, and a ~5s element-volume fade on expiry that ends in
audio.pause() — the SAME pause listener a manual pause fires, so the
progress push and media-session state land. The user's configured
volume is restored right after the pause, and the fade operates on
the element's volume the user controls, not a parallel path.

End-of-chapter fires when the armed chapter is left — natural
crossing (tickChapter) or a manual jump (jumpToChapter) alike. Manual
play during a fade aborts the expiry (you're awake, you asked);
stopSleep restores any partial fade. Per-session ONLY: nothing
touches localStorage — the reload check proves it starts Off.

sleepOpen joins closeTopSurface's dropdown branch, so Esc closes it
with the other settings-strip surfaces. Muted (or zero-volume) expiry
skips the pantomime and pauses immediately.

Unit: audio-sleep.test.ts (deadline mechanics, chapter-fired
semantics both directions, fade ramp clamps, label formatting,
SLEEP_CHOICES integrity). Live: 24 Playwright checks on the KJV book
— dropdown, countdown ticking, armed-row highlight, end-of-chapter
fire + reset, internal 3s deadline fade→pause→restore, play-abort,
reload-starts-Off; probe user created and deleted.
2026-10-22 08:40:54 -04:00
John O'Keefe 295c4b0664 feat(audio-player): keyboard shortcuts — declarative keymap, dialog, media keys (plan §2)
Space/Enter (body-focus only), H/L + arrows skip, J/K + PgDn/PgUp
chapter steps, B bookmark capture (note box auto-focuses, Enter saves
even when blank, Shift+Enter inserts a newline, Esc cancels),
Alt+arrows / Alt+H/L speed steps, up/down volume with a brief OSD,
M mute, [ ] skip-duration cycle, ? shortcut dialog, Esc closes the
top-most surface (help → capture → note edit → drawers → dropdowns).

audio-keys.ts is the ONE declarative table: resolveShortcut() drives
the document keydown dispatcher and visibleKeymap() renders the
templ dialog (x-for over the exposed keymap — templ cannot import
TS). Hardware media keys ride the existing MediaSession handlers;
the Media* keydown codes resolve through the same table to the SAME
methods, so both layers stay one behavior.

Guards mirror the ebook reader: typing in any
INPUT/TEXTAREA/contentEditable never triggers shortcuts (bookmark
notes included), a focused button keeps Space/Enter for itself, and
the focused scrubber keeps its arrows (its own @change seeks — a
native arrow nudge moves 0.08s, not a 10s skip).

SKIP_SECONDS consolidates to one const beside SPEEDS (the templ skip
dropdown now iterates skipChoices; [ ] cycles via cycleSkip()) — the
same one-source pattern as the §5 speed-list consolidation.

Unit: audio-keys.test.ts (resolution, Alt disambiguation, media
keys, keymap integrity and conflict-freedom, dialog visibility) and
cycleSkip tests. Live: 39 Playwright checks on the KJV folder book
covering every binding, the guards, dialog and OSD; probe user
created and deleted.
2026-10-22 08:15:06 -04:00
John O'Keefe db96ea91cd refactor(audio-player): one source for the speed list (plan §5)
The rate list was duplicated — hardcoded in the templ x-for AND
implicitly in audio.ts — and phase 2's keyboard stepping would have
needed a third copy. SPEEDS is now the single exported const (the
agreed nine values, 0.5 through 3); the component exposes it as
'speeds' for the dropdown's x-for (templ cannot import TS), and
stepSpeed() walks the same array for the future audio-keys consumer.

normalizeSpeed() maps persisted values onto the list: garbage or
out-of-list localStorage entries fall back to 1x, and init() writes
the normalized value back so storage heals instead of staying
poisoned.

Pure logic unit-tested in audio.test.ts; live-verified with Playwright
on the KJV folder book: nine options render, pick/reload/persist and
the stale-value fallback all behave, button label and
audio.playbackRate agree in every case.
2026-10-21 21:08:27 -04:00
John O'Keefe 408a972665 fix(audio-player): playback speed silently reverted to 1x on every reload
restorePosition() swaps the <audio> element's src (manifest track for
multi-file books, whole file for single-file, plain URL on offline
start), and loading new media resets playbackRate to the element
default. init() had applied the persisted speed before any src
existed, so after the swap every load actually played at 1x while the
Speed pill still showed the stored value — pick 1.5x, reload, and
audio was back at 1x.

The seekTo() src-swap path already re-applied the rate; the initial
load paths now do the same (manifest branch + offline-catch branch).
Surfaced by the phase-1 speed-list browser check: button label and
audio.playbackRate disagreed after reload.
2026-10-21 21:08:06 -04:00
John O'Keefe 6508abc00f docs(agents): the doctrine — tests never run against a real server
Codify the standing rule at the top of the Tests section: the dev DB is
the test environment, and the invariant every test must uphold is the
testuser experience — its settings, scans, and reading data must be
exactly as the suite found them. The helpers exist to enforce this;
when a test needs protection the helpers don't provide, expand the
helpers rather than hand-rolling.

New known-gotchas from the 2026-10-21 incident cluster:
7. scanner re-extraction is write-after-success — audio_metadata is
   guarded on both writers; new scan-owned column writes need the same
   guard.
8. templ double-escapes style attribute values (sanitizer + EscapeString)
   — emit quote-free CSS via unquoted url() tokens through SafeCSS.
9. vp test runs the vite config — build-hook plugins fire in the test
   context too; outDir-mutating plugins need an "am I a build?" guard.
2026-10-21 20:25:26 -04:00
John O'Keefe 3ed852515b test(helpers): setSystemSettingRestored — leave shared settings as found
The testuser-experience rule: the suite must leave shared settings
exactly as it found them. setSystemSettingRestored snapshots the prior
system_settings row, writes through the sanctioned API path (the only
one that reloads the settings registry — raw SQL leaves the cache
stale), and registers a cleanup restoring what WAS there, never a
hardcoded value.

setExtractorVersion now rides it, so the ev-pin tests no longer
restore scan_extractor_version to a hardcoded 1: a deliberate dev bump
of the extractor version survives a suite run instead of being
silently cancelled (the live dev server obeys that setting on its next
poll and would stop re-extracting parser fixes). The now-redundant
explicit t.Cleanup restores are removed from
TestExtractorVersionReextract and pinExtractorVersionOne.
2026-10-21 20:24:44 -04:00
John O'Keefe f62b46fb15 test(integration): route regression tests for the audio_metadata guards
Three tests in scanner_audio_guard_test.go pin the routes the wipes
traveled; each drives a real scan/rescan/edit over ffmpeg-rendered
fixtures and asserts the payload survives with duration and manifest
intact:

- TestRescanMediaItemPreservesAudioMetadata: folder book through the
  focused payload writer.
- TestRescanMediaItemPreservesAudioMetadataSingleFileM4B: the incident
  class verbatim — single-file m4b (no layout key) routes through the
  generic updater; fixture rendered with ffmpeg AAC/ipod
  (writeTestTrackM4B).
- TestMetadataEditorSavePreservesAudioMetadata: the metadata editor's
  full-row save through PUT /api/media-items/:id.

Verified to bite: with the handler fix stashed, the editor test fails
with "metadata edit must never NULL audio_metadata"; with it, green.
2026-10-21 20:24:09 -04:00
John O'Keefe 35d8ba32e5 fix(media): metadata editor save no longer wipes audio_metadata
PUT /api/media-items/:id (the metadata editor) writes the FULL row via
UpdateMediaItem, whose query sets audio_metadata unconditionally — and
audio_metadata has no editor field, so the params literal left it nil
and every save NULLed the payload. A plain metadata edit (genre tweak,
title rename) dropped any audiobook back to "1 second long" with no
track manifest: the same incident class as the scanner wipe, sitting
on the most-used admin path in the app.

Carry existing.AudioMetadata through the write. Write-after-success:
only the audio chain (probe-backed extraction /
UpdateMediaItemAudioBookPayload) ever replaces that column.
2026-10-21 20:23:42 -04:00
John O'Keefe 80bfc1a77b fix(scanner): re-extraction is write-after-success — no NULL audio_metadata
The 2026-10-21 wipe: a re-extraction pass NULLed audio_metadata on the
m4b audiobooks — every affected book dropped its chapters, track
manifest and duration and displayed as 1 second long (the reader also
lost its manifest, since single-file books synthesize it from the
payload).

Guards on both writers of that column:
- updateMediaItemWithMetadata (generic full-row pass): when the new
  extraction carries no audio payload, keep existing.AudioMetadata.
  Single-file m4b books route here, and a failed/absent extraction
  leaves AudioMeta nil.
- updateAudioBookItem (focused payload write): an empty payload (no
  tracks, no chapters, no duration) over a populated row is a failed
  extraction — refuse the write with a loud warning and let the next
  scan retry.

audioPayloadIsEmpty() pins the empty-payload predicate (unit-tested):
the column is only ever replaced by fresh data from a real audio
extraction, never by absence of one.
2026-10-21 20:22:57 -04:00
John O'Keefe 970beee4b8 fix(pwa): add the manifest icons the manifest always referenced
manifest.json has referenced /static/icons/icon-{192,512}.png since
April, but the directory was never committed — ~100 404s per browsing
session and a broken PWA install. Rendered both sizes from the
existing favicon book glyph on the Tokyo Night palette
(#1a1b26 background, #7aa2f7 stroke).
2026-10-21 20:22:28 -04:00
John O'Keefe fa5b952c54 fix(audio-reader): valid doctype — page loaded in quirks mode
audio_reader.templ was the only template emitting <!DOCTYPE html/>
(trailing slash is a parse error), which put the audio player page in
quirks mode — document.compatMode was BackCompat while every other
page is CSS1Compat. Quirks layout rules are a standing hazard for the
player's h-screen/flex page even where it happens to render. Drop the
slash.
2026-10-21 20:21:58 -04:00
John O'Keefe ff17d14694 fix(book-detail): hero backdrop — templ double-escapes style values
templ's codegen wraps style attribute expressions in a sanitizer AND
templ.EscapeString, so any quote character — even inside templ.SafeCSS
— reached the DOM as literal &#39; text. The hero backdrop's url('...')
never parsed: no blurred cover on any book detail page, and Firefox
resolved the mangled token as a relative URL, fetching /media/& (500)
once per page load.

Fix: emit quote-free CSS — an unquoted url(...) token built through a
new cssURL() helper that percent-encodes the characters that would
break it (space, quotes, parens, backslash). Stored cover paths are
already percent-encoded URL paths, so the escaping is defensive only.
2026-10-21 20:21:33 -04:00
John O'Keefe 807899b137 fix(reader): arrows stay page turns while the progress slider holds focus
Clicking the bottom progress slider left it focused, and the reader's
keydown guard treats INPUT focus as typing — so arrow keys stepped the
range input instead of turning pages. With step="any" each press moved
~1% of the book and key repeat crossed the whole book in seconds; the
debounced progress saves then persisted every wild landing (positions
scattered from page 5 to 285 to 100%, "progress no longer saves
correctly after page turns").

Two layers:
- reader.templ: all three progress sliders blur on pointerup, so a
  mouse scrub no longer leaves focus behind.
- reader.ts handleKeydown: ArrowLeft/ArrowRight on a focused progress
  slider preventDefault, blur, and fall through to the page-turn
  handlers; every other key still belongs to the control, so a
  keyboard user who Tabbed to the slider keeps arrows for it.
2026-10-21 20:21:07 -04:00
John O'Keefe 6791d611f0 fix(vite): guard cleanStaleChunks — vp test deleted every built chunk
The cleanStaleChunks plugin lives in the shared vite config, so its
closeBundle hook also fired under `vp test` — where generateBundle never
runs, produced stayed empty, and the sweep deleted every hashed chunk
in web/static (28 files → 3). The entries still referenced their old
imports, so every page 404'd and Alpine never started: the whole reader
and media system appeared dead after merely running the test suite.

Guard: skip the sweep when produced.size === 0 — not a build run, no
build output to clean. Verified: build → 28 files, test → 28 files;
back-to-back builds stay byte-identical.
2026-10-21 20:20:41 -04:00
John O'Keefe e5315f1b94 test(web): vitest suites for markdown, filters, password rules, api, storage, toast
Test infrastructure uses the vite-plus stack: vp test (bundled Vitest 5),
jsdom environment configured in vite.config.ts, colocated web/src/*.test.ts
files that tsc type-checks, vp fmt formats, and the bundler never imports.

- markdown.test.ts pins the notes XSS defenses: parser-level html escaping,
  DOMPurify FORBID_TAGS, scheme checks, footnote rendering, html->markdown
  round-trips incl. the headingless-table rule.
- filter_builder.test.ts pins the deep-link wire contract (ruleToFlatParam),
  smart-serialization rules (isFlatable), and pruneIncomplete semantics.
- password_validation.test.ts covers the rules fetch + fallback and the
  full submit-gating matrix through the real DOM flow (debounce included).
- api.test.ts pins response/error contracts incl. the HTTP-status and
  Unknown-error fallbacks and the 404-as-null progress contract.
- storage.test.ts covers token/theme/library round-trips, the __all__
  sentinel, and the SSR cookie.
- toast.test.ts pins config completeness for every ToastType (the missing
  'warning' entry regression) and render behavior.

Found + fixed while writing these: the no-useless-escape fix in
d7d14957 had de-escaped the ']' in hasSpecialChar's character class,
closing it early — special-character validation silently stopped
matching most specials. The new password suite caught it immediately.

jsdom chosen over happy-dom after empirical comparison: DOMPurify's
sanitizer output is mangled under happy-dom (forbidden tags survive),
which would have made the security suite test nothing.

Note: AGENTS.md gains a 'Web (TypeScript) tests' section documenting the
workflow and the happy-dom gotcha.
2026-10-21 15:34:29 -04:00
John O'Keefe 86c4940426 style(toast): warning toast uses the /90 opacity variant
Release / build-and-push (push) Successful in 3m6s
Matches the error/success/info entries visually. bg-yellow-500/90 is
generated by the Tailwind scanner from this file itself (content globs
include web/src/**/*.ts), same as the sibling bg-*-500/90 classes — no
safelist entry needed.
2026-10-21 14:46:26 -04:00
John O'Keefe d7d1495753 fix(types): clear web/src type and lint debt with real types
tsc --noEmit and oxlint web/src are now both clean; vp check passes.

- htmx: one canonical Htmx interface in shims.d.ts (ajax/process/trigger
  per the htmx 2.x API). alpine.ts had a second, conflicting Window.htmx
  declaration (TS2717) — removed; bookPicker/collections lose their
  (window as any) escapes now that Window.htmx is properly typed.
- tsconfig lib ES2020 -> ES2021 + DOM.Iterable: replaceAll exists
  (escapeHtml) and for..of over querySelectorAll results is legal.
  Type-checking only — vite emits at esnext regardless.
- custom-section-builder: selectedBooks holds identity fields only;
  typed as Pick<BookInfo, ...> instead of forcing partial API shapes.
  The API-critical BookInfo contract is untouched.
- toast: real bug found by the type system — setup.ts passes 'warning'
  but getToastConfig had no entry, so the warning toast threw TypeError
  at runtime instead of displaying. Added the type + config. Uses
  bg-yellow-500 (verified present in scanned templates => generated by
  Tailwind); the one intentional behavior change in this commit.
- book-detail: instanceof narrowing replaces an as-cast; unused
  AnnotationSource import removed.
- password_validation: explicit HTMLElement | null for the checklist
  list (querySelector('ul') infers HTMLUListElement, conflicting with
  the container-as-list branch).
- stats: tooltip parsed value typed via annotation (bar=scalar,
  line={x,y}); dead getActiveRange() removed (was already tree-shaken
  out of every bundle).
- library-switcher: onSwitch honestly typed void | Promise<void>
  (series.ts passes a sync navigator).
- 20 unused catch bindings converted to optional catch binding.

Verification: fresh build is byte-reproducible; every chunk is
identical to the pre-fix build except main.js (instanceof narrowing,
regex fix, stats refactor), reader.js (destructure), and the shared
storage chunk (renamed — carries toast.ts). reader/main diffs audited
hunk-by-hunk against the intended edits.
2026-10-21 14:17:02 -04:00
John O'Keefe 77cd268f4e style: format web/src with oxfmt defaults (via vp fmt)
One-time mechanical sweep, isolated from any functional change.
- oxfmt defaults: double quotes, semicolons, 2-space indent, LF,
  printWidth 100, trailing commas, bracket spacing, arrow parens.
- vite.config.ts gains an fmt.ignorePatterns block for web/static:
  vendored/minified assets (htmx.min.js, compiled css) must never
  be reformatted.
- Verified: rebuild after the sweep produces byte-identical JS/CSS
  bundles; only sourcemaps changed (they embed sourcesContent).
2026-10-21 13:18:24 -04:00
John O'Keefe 8aeecaa0ef Experiment: migrate web build to vite-plus 1.3.0
- vp migrate rewrote scripts (vite build -> vp build), aliased vite to
  @voidzero-dev/vite-plus-core 1.3.0 with an overrides pin, and switched
  the config import to vite-plus.
- Manual one-line fix migrate missed: minify "esbuild" -> true (Vite 8
  dropped bundled esbuild; oxc minifier is the replacement).
- Widened cleanStaleChunks sweep from .js/.js.map to .css/.mjs too:
  Rolldown renames emitted css chunks, and two stale vite-7-era css
  files (annotation_layer_builder, text_layer_builder) survived the
  old js-only regex.
- Verified: both custom plugins survive; prod/dev/watch builds OK;
  two consecutive builds idempotent at 289 files; vendor/pdfjs assets
  byte-identical; entry names (main.js/reader.js) unchanged.
2026-10-21 09:27:59 -04:00
John O'Keefe dd4cf1235c test: pin scan_extractor_version at the start of ev-sensitive tests
Release / build-and-push (push) Successful in 3m20s
The extractor/force-rescan tests assert manifest stamps against the
default version (1), but the setting lives in the SHARED dev DB — any
prior run (or a manual admin-UI bump) can leave it at 2, failing every
stamp assertion through no fault of the code. Each ev-sensitive test
now pins the setting to 1 at start and restores 1 on cleanup; proven
against a deliberately bumped setting (tests pass, row restored).
2026-10-20 22:19:29 -04:00
John O'Keefe a42c6d4f75 feat(scanner): force rescan = original scan; feat(filters): the manually-edited filter (faceted plan Phase 13)
Release / build-and-push (push) Successful in 3m7s
Force rescan contract change (owner-directed): the editorial-keep skip
is retired. Force rescan now re-derives EVERY field on EVERY book from
all sources — multi-file books included (the folder/zip flows ran the
full chain on create and on manifest-flagged edits, but skipped the
sidecar apply under force). metadata_overrides is the only protection:
ApplyMetadataOverrides re-applies manual values inside the writer, so
manual edits survive; everything else re-derives.

- A force pass also STAMPS each re-derived book's manifest with the
  current parser version (success-only — same truthfulness rule as
  Phase 10) and closes its open processing issues (Phase 11 self-heal).
  Force rescan and the ev-bump trickle now converge: either leaves the
  library fully current; the deploy order is bump-first, then Rescan
  All (documented in the plan and contract_pins).
- Single-file force rescan already ran the full chain — unchanged,
  now pinned.

NEW filter field manually_edited ("Manually edited", boolean):
cardinality(mi.metadata_overrides) > 0 / = 0. Composed-engine (qf)
only — the builder's Book facts group gains it with is true/false;
no value menu, no suggestion surface; family lens inherited.

Tests: multifile force applies sidecar metadata + stamps + override
protection; single-file pin; the filter both directions scoped to the
test library; adjacent suites (Audiobook, Calibre, ExtractorVersion,
CrossLibraryMove, ProcessingIssues, FilterBuilder, SchemaIdempotency)
green. The extractor mechanism itself is untouched — the owner bumps
scan_extractor_version manually via the admin UI (plan Phase 13 §3).
2026-10-20 22:05:42 -04:00
John O'Keefe 9b5defcb31 feat(issues): the honest processing-issues ledger — row-less import failures, self-healing resolution, family lens (faceted plan Phase 11)
processing_issues only recorded cross-library move rejections; files
that failed to import vanished into an error counter and stdout. The
ledger now records every failure class and heals itself:

Schema (in place, pre-release boundary):
- media_item_id NULLABLE + file_path TEXT (deliberately unbounded —
  an over-long path is itself a failure mode; a VARCHAR(500) ledger
  column died on the exact 22001 it existed to report — caught by the
  new test). Row-less failures key on (library, file_path); item-backed
  on (item, type). Widened dedup = COALESCE-expression unique index;
  the old table UNIQUE stays (NULLs pairwise-distinct for row-less).
- Conflict-target upsert: repeat failures UPDATE the same row and
  REOPEN resolved ones — never duplicates.

Wirings:
- CreateMediaItem failure (single-file + multifile createAudioBookItem)
  → row-less import_failed (error).
- Extraction hard-fail with fallback import → item-backed
  metadata_extraction_failed (warning).
- Change-path re-extraction failure (the ev/sidecar guard) → item-backed
  metadata_extraction_failed; LogProcessingIssue keeps its signature, a
  row-less helper joins it.

Auto-resolution:
- Successful import → ResolveOpenProcessingIssuesForPath (library,
  file_path).
- Successful (re)extraction → ResolveOpenProcessingIssuesForItem
  (single-file change path, multifile editorial apply).
- Unchanged-skip scans do NOT resolve: extraction never re-ran, so the
  degraded metadata is still degraded — the page heals exactly when the
  book does.

Family lens on ALL issues surfaces (list, stats, resolve, delete, SSR
page): explicit out-of-set library id → 403, never silent. This
tightens one pre-lens behavior — a nonexistent library id returned
200-empty, now 403 (pinned, changelog entry in docs/contract_pins.md).
List is LEFT-JOINed so row-less rows appear (media_item_id "",
title falls back to the path).

Tests: full lifecycle state machine through real scans (fail → appears,
repeat → no dups, success-at-path → resolved, LEFT-JOIN list shape);
family lens 403s with a fresh non-shared admin; all 7 pre-existing
issue suites green (two expectations widened to the lens, changelogged);
adjacent suites (CrossLibraryMove, Audiobook, Calibre,
ExtractorVersion, SchemaIdempotency) green; unit suites green.
2026-10-20 19:38:02 -04:00
John O'Keefe 19b9c7c9b8 fix(tests): wire settings registry into the test auth handler — unstick TestAdminCreateUser
setupTestServer built the settings registry (and wired it into
systemSettingsHandler) but never called authHandler.SetSettings, unlike
main.go:112. The auth handler therefore ran with a nil registry and
defaultTimezone() took its nil-fallback branch, returning "UTC" for every
Register/AdminCreateUser in tests regardless of the system default_timezone
setting — so the 740213f0 inheritance pin in TestAdminCreateUser could never
pass and failed every full-suite run.

Also replace the TestAdminCreateUser "stable-ish" uniquifier (MaxConns is
pinned to 1 by the harness, so it produced the same user every run and
depended on the next run's cleanup sweep) with a real uuid suffix.
2026-10-20 19:02:26 -04:00
John O'Keefe 91b0c45dd4 feat(scanner): extractor-version (ev) mechanism — parser fixes reach old rows without a force rescan (faceted plan Phase 10)
Parser fixes (7e4c97f6, genre model v2) never reached rows ingested
under an older parser: the sidecar never changed, so every fingerprint
said 'unchanged'. The manifest JSONB now carries the reserved 'ev'
stamp — the parser version that produced the item's row.

Semantics (all pinned by tests):
- ABSENT ev decodes as 1 ('v1-or-unknown'); stamping is truthful-only:
  create paths + post-extraction stores stamp the setting's current
  value; deploy-day backfill and the extract-failure guard stay UNSET
  (a row never claims a version its data can't prove — a mid-backfill
  bump self-heals next poll); repoint reconciliation preserves the
  stored ev (stat-only refresh re-extracted nothing).
- NEW setting scan_extractor_version (seeded + registry + getter,
  int default 1, category scanner): a manifest whose ev != the setting
  re-extracts through the SAME change path (full extraction →
  updateMediaItemWithMetadata → fresh stamped manifest). Sidecar-less
  books get an entries-less manifest as their ev home (create: stamped;
  first-sighting: unset).
- Catch-up bounded: evReextractBudgetPerScan=100, atomic (watcher pass
  can overlap a poll scan), reset per ScanFolders — amortized +
  resumable across polls; no force rescan, no flood.
- Multifile books included: the ev gate rides multifileSidecarChanged's
  shared ladder; ev-driven manifestChanged drives the editorial apply
  + stamped re-store.

Settings API: the unified GET/PUT /api/system/settings now lists the
key (registry-driven, auto-surfaces in the admin UI). Test harness:
the registry is now Loaded + wired into the SystemSettingsHandler
(main.go parity) — settings writes through the API were 503s before.

Verified: unit (ev normalization, budget CAS, GetExtractorVersion
cache paths, registry clamping); integration state machine —
create-stamp ev=1 → equality skips (corruption probe persists) →
wiped scan-state backfills UNSET without re-extract → bump re-extracts
ONCE (sidecar value restored, ev=2) → stable (second corruption
persists); multifile twin green; adjacent suites (Audiobook, Calibre,
CrossLibraryMove, SchemaIdempotency) green; live app restart re-scans
1198 files with 0 changed (deploy-day rule), setting listed at
/api/system/settings.
2026-10-20 18:24:08 -04:00
John O'Keefe 43987262a6 fix(filters): operator table sent labels as wire values — every non-self-named operator was broken (user report)
The client's operator map passed the display string as BOTH the map
key and the OpMeta.key, so the menu clicks transmitted 'doesn't
contain' / 'isn't' / 'is empty' / 'at least' / … literally. The server
rightly rejected them (unknown operator → 400), the count fetch's
catch hid the counter, and flipping away from such an operator never
came back. Only self-named ops (contains, is, has, between…) worked.

- Map keys now DERIVE from meta.key (Object.fromEntries over the op
  list) — wire value and label structurally cannot diverge again.
- New scripts/filter_builder_harness.js: a playwright harness that
  drives the real drawer in headless Chromium and sweeps every author
  operator, capturing filter-count responses and console errors — the
  first UI-level regression net for the builder (the server tests
  could never see a client-side mis-key).
- Verified live: contains 8 → doesn't contain 60 → is 8 → isn't 60 →
  is empty 2 → refill 8; zero console errors across the sweep.
2026-10-20 17:14:26 -04:00
John O'Keefe 68701d6b94 fix(filters): live count follows the top library picker (user report)
The count fetch reads the library select at fetch time, but nothing
re-triggered it on a scope change — the number stayed stale (8 across
all books) after switching to a library where the rules match nothing.
The picker's change event now reschedules the count, same as rule
edits do.
2026-10-20 16:52:08 -04:00
John O'Keefe ab713e2feb fix(filters): blank-typed browse 500 on id-valued fields (library/collection/added_by)
The browse-on-click blank sentinel was trimmed to empty (correct), but
the typed query had already been bound unconditionally — the id-source
suggestion query has no score CASE to reference it, leaving an unused
parameter Postgres rejects (42P18). Every click of an empty library /
collection / added-by box answered 'No matching values' via the error
path.

- ComposedFieldValues binds the typed query ONLY in branches that
  reference it and only when non-blank; the score expression is
  '0 AS score' for browse (no param) and the similarity CASE when
  typing. composedIDSourceValues takes the raw typed string and binds
  it only when the name filter is built. userBind already followed
  this discipline; audited every bind site in the file (fuzzy helpers,
  composed search, count) — all interpolate immediately.
- Tests: library browse under rules (exactly the surviving library,
  count pinned), series browse blank, added_by browse blank (no
  error), collection browse blank (offered with count) and under a
  zero-match rule (never offered). Verified end-to-end against the
  live dev data: author+Sapkowski AND narrator+Kenny → Audiobooks (8).
2026-10-20 16:44:50 -04:00
John O'Keefe 9c006a9a2f fix(filters): browse with no rules works, popup under the input, no sticky headers (user report)
- Blank typed queries rode the STATIC autocomplete path for the classic
  six fields, where a single space similarity-matches into nothing —
  clicking an empty value box with no other filters said 'No matching
  values' in a library full of values. Blank = browse now always rides
  the composed engine (which trims and answers the top values); static
  keeps its pinned contract for real typed queries. Regression-pinned:
  browse with no rules lists the field's values.
- The value popup now drops BELOW the input (top: 100%), not over it.
- Field-menu group headers lost position:sticky — the search box alone
  stays pinned.
2026-10-20 16:01:10 -04:00
John O'Keefe 4c03116ec7 fix(filters): value menu flash-on-click, empty state, sticky field filter (user report)
- The flash was a race: on localhost the suggestion fetch resolved
  between the focus (mousedown) and the click event, so the just-opened
  menu was closed by its own click-outside handler — the input sits
  outside the menu div. Clicks anywhere inside the value cell now
  never close the menu; outside clicks do.
- The menu opens immediately in a loading state; an empty browse
  renders 'No matching values' instead of nothing. Plain free-input
  fields (page_count etc.) open no menu at all.
- Field menu: the filter input moved outside the scroll region — it
  sticks while the grouped list scrolls beneath it.
2026-10-20 15:36:31 -04:00
John O'Keefe c747f0a76c feat(filters): per-row AND/OR (Excel-style chips) + browse-on-click value menus (user report)
- Per-rule connectives: the chip before each rule flips that rule
  between AND and OR — OR-joined neighbors merge into an invisible
  OR-block, AND pulls them back out. The tree stays the single source
  of truth (implicit blocks are a client marker the server ignores),
  so deep links, saved filters, and the composed engine are untouched.
  Group-mode pills are gone; explicit groups remain as pure structure
  (+ group). Compile limit, same as Excel: flat chips express
  AND-of-OR-groups; a top-level OR of AND-blocks still needs nesting.
- Browse-on-click: clicking an empty value box now fetches the field's
  top values UNDER the other rules (blank typed = browse, not a
  similarity filter) — 'assign an author, click Publisher, see their
  publishers' without typing. Server: the composed suggestion engine
  trims the typed query; client: the >=2-char guard no longer blocks
  the empty query, browse limit 15 vs 8 while typing.
- Tests: the browse case pinned (blank sentinel + rules → the field's
  values under those rules, zero-match values absent).
2026-10-20 15:25:57 -04:00
John O'Keefe 6f98ca4c22 fix(filters): group mode reads AND/OR, connective labels toggle it (user report)
The ALL/ANY pills required mapping ALL→AND, ANY→OR that nobody should
have to learn. The pills now say AND / OR directly (wire values stay
all|any — server contract untouched), and the AND/OR text between
sibling rules became a clickable toggle for its parent group, so the
switch lives exactly where you're looking.
2026-10-20 15:09:44 -04:00
John O'Keefe 04b36f0563 fix(filters): builder UX round one — incomplete rules, group removal, field menu (user report)
- Root bug: incomplete rules (the ready rule, every new group's first
  rule) were sent to the server — the live-count fetch toasted a 400
  and every suggestion fetch failed silently (why author typing showed
  no dropdown). Fix: completedTree() prunes incomplete rules and empty
  groups; the count fetch skips when nothing is complete, suggestion
  fetches send only the other COMPLETED rules, and serialization wires
  only completed rules (an unfinished rule was never a filter).
- Group remove button was x-show'd away on depth-0 groups — exactly
  where user groups live. Always visible now.
- Field menu: filter input at the top (Enter prevented), no-match
  state; group headers restyled as flush-left tinted sticky bands with
  options tabbed in beneath (user's layout pick).
- toast.ts: filter-count fetches join the suggestion-fetch exemption
  (a failed background hint never interrupts).
2026-10-20 15:04:19 -04:00
John O'Keefe 4ee24e7152 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.
2026-10-20 14:49:50 -04:00
John O'Keefe ebb7f4a5b0 feat(filters): panel removal + All-Books field-values fix (faceted plan Phase 8)
- Remove the Phase 7 facet-panel UI: SSR route, facet_panel.templ,
  FacetCountsForView, drawer panel block, panel TS + .facet-* CSS.
  The ENGINE stays (FacetCountsUnderFilters, all-facets endpoint,
  drift guard) — it becomes the Phase 9 builder's machinery.
- Fix the All-Books bug: field-values autocomplete (author/genre/
  tags/narrator/series/language) treats library_id as OPTIONAL —
  absent aggregates every VISIBLE library (family lens in-query);
  explicit out-of-set library_id is now 403, never a silent pass.
  The four scalar Search*Values queries get the nullable shape.
- toast.ts: suggestion fetches are exempt from error toasts
  (console-only) — a failed background hint never interrupts.
- TestFacetPanelPartial deleted with the behavior; TestFieldValuesAll-
  Libraries pins the fix (200 + cross-library aggregation, family
  lens, 403). Contract-pins changelog entry added.
2026-10-20 12:20:51 -04:00
John O'Keefe f52bb6f073 feat(facets): all-facets panel with under-filter counts (faceted plan Phase 7)
The 9 Batch A facets could not be SET from the web UI, the facets
endpoint had no in-app consumer, and the registry's libraryTypes hints
rendered nowhere. This phase closes all three.

- ONE UNION-ALL sqlc query (FacetCountsUnderFilters) computes all 14
  listable facets in a single round trip: each arm omits its OWN
  facet's filter block and carries every other registered facet's
  block (copied mechanically from SearchMediaItemsUnified) — classic
  faceted semantics: with filters active each facet counts under the
  others; with none, blocks are neutral and the counts are the v1
  global-per-library ones. One query serves both cases. The 14
  per-column Batch C queries are deleted.
- Endpoint: no facet param → the new all-facets shape
  ({facets: {param: [{value, display?, count}]}}), filters bound via
  the shared ParseMediaSearchParams, library_id optional (the
  All-Libraries view, family lens still enforced); facet=X keeps the
  pinned single-facet shape (now routed through the same query with
  neutral filters).
- SSR partial GET /media-items/facet-panel: templ renders the drawer's
  value lists — registry order, Batch D library-type scoping
  (FacetsForLibraryType: manga_type on manga, reading_direction on
  manga+comics, narrator on audiobooks; All view = union), prettified
  genre labels + language display names, active values marked. Family
  lens: out-of-set explicit library_id is 403.
- Drawer wiring: the panel fetches on drawer open and after every
  apply with the CURRENT filter query string; a value click toggles
  that facet's comma-joined filter (hidden input where the drawer has
  no field — same as deep links) and re-applies the form; chips and
  URL stay in sync. Values are server-rendered (rendering doctrine);
  bookshelf.ts owns only the click/fetch orchestration.
- prettifyGenre moves to internal/utils as the canonical
  implementation (services' display enrichment shares it; templ wraps
  it; the TS port in bookshelf.ts is unchanged).
- Drift guard extended: FacetCountsUnderFiltersParams must carry every
  registry facet's param, the bools as pgtype.Bool.

Tests: under-filter exactness (own-block omission, OR-within,
AND-across), family lens (hidden values absent; 403 on both shapes),
all-shape + pinned-shape coexistence, partial rendering with Batch D
scoping + active marking + 403. Full container suite green except the
documented pre-existing TestAdminCreateUser red.
2026-10-20 08:24:36 -04:00
John O'Keefe 6152137156 fix(tests): repair stale pins surfaced by the Phase 6 full-suite run
Two suite failures, neither caused by scanner code (the Phase 6 diff
touches no handler):

- search_test invalid_library_id expected the pre-family-lens 404;
  7e586df4 shipped the AGENTS-mandated 403 for every out-of-set id
  (existence must not be enumerable) and docs/contract_pins.md:195
  documented it — this one subtest's expectation was missed. Pin
  widened to the shipped 403.
- TestWebSocketPingPong deleted: it asserted a text-level ping→pong
  exchange no server code has ever implemented and no client has ever
  sent; it passed vacuously through its read-timeout path until
  interleaved scan_progress broadcasts from the live server's scans
  reached the socket first. Keepalive is the standard protocol-level
  ping the hub already sends. Never-built behavior is not a pinnable
  contract.

Both recorded in docs/contract_pins.md. Full container suite is green
except the documented pre-existing TestAdminCreateUser red.
2026-10-19 22:33:08 -04:00
John O'Keefe 2c5ad014c0 feat(scanner): multifile folder/zip sidecar detection (faceted plan Phase 6)
Reverses the Phase 4 scope cut: for EXISTING folder/zip audiobooks a
sidecar edit was invisible to everything, including force rescan —
updateAudioBookItem writes only scan-derived fields, and these formats
often have no useful embedded tags, so the sidecar is the only source.

- Change fingerprint gains the manifest compare in both processors:
  changed := size differs || track payload differs || manifest drifted.
  The ladder runs against the bulk-loaded map entry (stat pairs only,
  zero extra queries); NULL stored manifest backfills without
  re-extraction (deploy-day rule, same as single-file).
- Manifest-changed applies the once-extracted book metadata to the
  editorial columns (updateMediaItemWithMetadata, overrides protected)
  and re-runs the playlist hook. The apply runs BEFORE the focused
  payload write: the generic update's format-group pass derives mime
  from the sentinel (none) and chapters from metadata.ChapterCount
  (unset) — the payload write must win (rescanMultiFileAudioBook's
  ordering). Track-only changes keep the existing editorial-keep
  update; force rescan keeps it too.
- Manifests seeded at multifile create (folder names derive from the
  sentinel — the exact representative the extractor reads — via
  detectSidecarChangeWithRepresentative, so manifest and extractor can
  never disagree; a directory-basename cover is ignored by the
  extractor and therefore not tracked).
- Zip parity fixes: the fast path now lifts archive state like the
  folder path always did (a returned zip book stayed archived
  forever), and the change branch handles the unchanged-archived case.
- Single-file sidecar change path: extract-failure guard — the old
  non-nil empty &MediaMetadata{} fallback bypassed the update path's
  filename-title guard (preExtracted == nil only) and would blank the
  title on a failed extraction; the row is now left untouched and the
  manifest refreshed. Empty-success extraction (sidecar removed from a
  tag-less book) now passes nil so the create-equivalent chain (tags →
  filename) re-derives instead of blanking.
- Multifile changes now count in the scan summary (changedItems);
  they were previously uncounted.

Tests: sentinel-name derivation unit pin; folder sidecar edit
(title + collection sync + fresh manifest + steady-state skip),
track-only change keeps editorial, zip sidecar edit + archive
restore, sidecar removal falls back to filename (the wipe-guard's
testable sibling). Full container suite: green except the documented
pre-existing TestAdminCreateUser red.
2026-10-19 22:33:01 -04:00
John O'Keefe 5245f292ca feat(scanner): quiet scans, enqueue coalescing, watcher probe, sibling cache
Part 2 of the faceted plan, Phase 5 (steps 5-6): the scan that used to
print ~4 lines per unchanged file now prints ONE summary line, and a
poll tick landing mid-scan no longer stacks a second full scan.

- Quiet scans (§4): the walk loop is silent — inserts, changes, and
  errors log inside processMediaFile; the duplicate
  'Successfully processed' branch (both branches printed it) and the
  per-file 'processing / already exists / same size, skipping' chatter
  are gone. Summary carries the trigger and a changed counter:
  'Scan of <folders> (trigger: poll): N files, N changed, N new, Xs,
  N errors'. Triggers threaded through every enqueue site: poll
  (startBackupScan), watcher (dirty-dirs flush), admin (scan API),
  initial (startup scan).
- Enqueue coalescing (§5): Worker.HasActiveScanForLibrary (JobResult
  gains additive job_type so only SCAN jobs coalesce — an import
  sharing the library id never blocks one). enqueueLibraryScan skips
  silently; the admin scan API answers 409 with the active job id.
  Enforces the one-scan-per-library invariant the bulk scan-state map
  leans on.
- Watcher capability probe (§4): ONE Add on a root folder; failure
  disables the watcher with a single startup line (replaces the
  per-directory warning spam on inotify-broken deployments). The
  watcher stays an optional latency reducer over scheduled scans.
- Per-directory sibling cache (§6): hasSiblingBookFileCached caches
  the ReadDir per directory for one scan — loose-image manga re-stats
  thousands of images sharing a handful of directories.

Tests: TestHasActiveScanForLibrary (unit — running/pending coalesce,
terminal jobs don't, non-scan jobs don't, empty library never, nil
worker safe). Full scanner suite green in the container.
2026-10-19 20:35:07 -04:00
John O'Keefe 6750e9e0dc feat(scanner): manifest-gated sidecar change detection + playlist sync on change path
Part 2 of the faceted plan, Phase 4 (steps 3-4). Size-only detection
was blind to metadata-sidecar edits; a size-stable book is changed by
EDITING its metadata.opf/metadata.json/cover — previously invisible
until a force rescan.

- Sidecar manifest fingerprints (media_scanner_manifest.go): the
  manifest stats exactly the names the extractors consume (metadata.opf,
  metadata.json, findSidecarCover patterns incl. <basename>.<ext>), so
  manifest and extractors can never disagree. Shape v1:
  {v, entries:[{n, s, m (unix nanos — second granularity would hide
  edits landing in the backfill scan's own second), h (sha256 — covers
  included: the repoint ladder needs a baseline hash for any stat-
  drifted sidecar; a hashless cover turns harmless mtime drift into a
  re-extraction, the exact flood this design kills)]}.
- Evaluation ladder in cost order: stat pairs identical → skip with
  ZERO bytes read; stat drift → hash that sidecar (equal = host repoint
  reconciliation, stat pair refreshed, no re-extraction; differing =
  changed); NULL stored manifest → backfill only, NO re-extraction
  (deploy-day rule: the first scan after release must not look like an
  mtime flood).
- Change path: re-read full row, extractMetadata once and pass it
  through updateMediaItemWithMetadata (preExtracted — metadata_overrides
  stay protected), run syncPlaylistCollectionsAfterCreate (Correction 2:
  a reading-order entry added to an EXISTING book now creates its
  collection; add-if-missing is idempotent), store a fresh full
  manifest. Creates seed their manifest at insert time.
- Multifile folder/zip books intentionally keep Phase 3 behavior: their
  change path does not consume sidecar extractors, so gating them would
  flip books 'changed' without picking anything up (plan correction —
  the plan's §3 multifile note assumed otherwise).
- UpsertMediaScanState + GetMediaScanState queries.

Tests: media_scanner_manifest_test.go (ladder unit tests — fast path,
repoint, same-size drift, appear/vanish, unhashable legacy entries);
TestScanManifestLifecycle (seeding, unchanged-skip stability, repoint
reconciliation with stat-pair refresh assertion, same-size edit
consumed, playlist sync on the change path + idempotency, no-leak
guard). Container runner.
2026-10-19 20:17:57 -04:00
John O'Keefe 693053a9f5 perf(scanner): bulk scan-state load — one query per library scan
Part 2 of the faceted plan, Phase 3 (steps 1-2). The periodic scan
issued one GetMediaItemByFilePath per scannable file (~3,094 queries
per scan in prod, nearly all answering 'unchanged, skip') plus one
GetLibraryByFolder per file for folder resolution.

- NEW media_scan_state table: the scanner's per-item fingerprint
  cache. Deliberately NOT a media_items column — the library-scoped
  list emits raw sqlc rows as JSON (7537063c), so scanner bookkeeping
  must never leak into API payloads nor churn the model or pins.
  Manifest JSONB shape v1 documented in the schema (n/s/m/h; ev
  reserved for the Phase 4+ extractor-version mechanism).
- GetScanStateByLibrary: lean bulk projection (id, file_path,
  file_size, archived_at, missing_scan_count, file_sha256, manifest).
  archived/hash columns ride along so the skip path's archive-restore
  and hash self-heal checks stay DB-free too (refines the plan's
  4-column sketch; same goal, narrower invariant).
- ScanFolders: per-run reset, per-folder library cache, one bulk load
  per distinct library. processMediaFile: map hit answers existence +
  size + flags with zero DB reads; force-rescan and size-change
  branches re-read the full row (the change path may be expensive);
  write-through on create/size-change keeps later passes honest.
- Multifile folder/zip books keep their full-row lookup: it is one
  query per BOOK ROOT (not per track) and needs audio_metadata for
  the duration-probe check. They join the map in Phase 4, when the
  manifest compare replaces their fingerprint check.
- nil map (watcher's scanDirectory path, bulk-load failure) falls
  back to per-file lookups — behavior-preserving; the failure case
  logged once per scan.

Test: TestScanStateBulkLoad (unchanged rescan leaves updated_at
stable; API-created rows are seen by the bulk load; size changes
still update in place). Container runner.
2026-10-19 19:52:43 -04:00
John O'Keefe bbedbfb13f feat(facets+ui): clickable facet values everywhere, genre prettify, active-filter chips
Frontend half of the faceted-navigation Phase 2. 'Everything filterable
should be clickable': every displayed facet value links to the bookshelf
pre-filtered by its raw stored value; display forms are prettified at
render, never in the DB.

- templates/facet_links.templ: FacetLink (inline links) + FacetBadge
  (badge links) to /bookshelf?<facet>_filter=<value>; the raw value
  rides the param while the shown text is prettified, so matching and
  display can never disagree. Series/tags keep their dedicated pages
  by design; the card media-type icon stays unlinked (a derived kind,
  not a column value — deep-linking it would send wrong values).
- book_detail.templ: publisher, language, edition, genre, imprint
  cells + narrators line + age-rating/story-arc badges are now links.
- prettifyGenre (templates/utils.go + unit tests): title-cases only
  entirely-lowercase values; small words stay down except first
  position; & preserved. TS port for chip labels documents the twin.
- bookshelf.templ + bookshelf.ts: active-filter chips bar — Alpine
  renders chips from client-local form state (sanctioned island);
  one bubbled htmx:afterRequest hook keeps chips + shareable URL in
  sync; removeFilterChip clears the facet and re-applies. URL deep
  links now accept all 18 facet params (hidden inputs where the
  drawer has no field).

Pins changelog: Batch B/C entry in docs/contract_pins.md. Rendering
ownership guard (make lint-rendering) green.
2026-10-19 18:59:36 -04:00
John O'Keefe 7e586df48a feat(facets): facet registry, Batch A server params, genre model v2, facets endpoint
Phase 1 + backend of Phase 2 of the faceted-navigation plan. One
declarative facet registry (services/facet_registry.go) drives the
bookshelf filter system; a drift-guard unit test reflects over the
generated SearchMediaItemsUnifiedParams so a registry entry and its
sqlc.narg block can never disagree silently.

- Nine new filter params on SearchMediaItemsUnified: story_arc,
  imprint, edition (scalar-fuzzy), age_rating, format_group,
  manga_type, reading_direction (exact), explicit, abridged
  (presence-bound bools). Comma = OR-within multi-select on every
  text facet; AND-across-facets unchanged.
- Genre model v2 (user rule: a subgenre is not a genre, it's a tag):
  genre_lexicon.go rebuilt as shelf set + subgenre->shelf map;
  pickSmartGenre picks direct shelf > mapped subgenre > Fiction floor
  > empty (unknown-only stays tags-only). Type-words (Manga, Comics,
  Graphic Novel) join the demote list. Both 'adventure' and 'action &
  adventure' are shelves — provider data uses both and the verified
  traces pin them.
- Language filter UX: comma elements normalize through the ingest
  language map, so display names ('English') match rows stored as ISO
  codes ('en'). The language autocomplete now serves display names.
- NEW GET /api/media-items/facets?facet=X&library_id=... — registry-
  driven value+count listing (14 static per-column queries, family-lens
  join); language values carry display names; bool/range facets 400.
- Family-lens gates: an explicit hidden-library library_id is now a
  403 on the search endpoints (was a silent empty set) — JSON branch
  and HTMX grid partial both.
- handlers.ParseMediaSearchParams is the SINGLE param parser for the
  search JSON endpoint and the HTMX grid partial; the HTML branch
  previously dropped publisher_filter and narrator_filter silently.

Pins: docs/contract_pins.md changelog entries (additive request
params; new facets endpoint). Tests: facet_registry_test.go (shape +
SQL drift guard + library-type union), series_parsing_test.go genre-v2
cases, facets_integration_test.go (param filtering e2e, counts,
language display, 403s) — container runner.
2026-10-19 18:59:23 -04:00
John O'Keefe 7537063cb7 feat(filters+nav): author navigation end-to-end — arrays on every media response, deep-linkable bookshelf filters, clickable authors
Completes the A2c acceptance scenario: click an author on a book
detail page -> land on the bookshelf filtered to everything that
author wrote (including their share of co-authored books).

- Media detail + unscoped list resolved maps expose subtitle,
  explicit, authors, publishers (the library-scoped raw branch and
  the search endpoint already did — raw rows follow the SELECT).
- SearchMediaItemsUnified SELECT carries the new columns; the search
  endpoint serializes the row directly, so clients get them there too.
- book_detail.templ renders each author as a link to
  /bookshelf?author_filter=<name>.
- Bookshelf honors filter params in the URL on load (author_filter,
  series_filter, genre_filter, publisher_filter, tags_filter,
  narrator_filter, language_filter): pre-fills the form, applies it to
  the first grid load. Comma-joined values are multi-select via the
  server-side string_to_array split. Filtered views are shareable.

Pins widened: search rows (+7 keys), resolved keys (+4); changelog
entries in docs/contract_pins.md. Android follow-up plan written to
~/.opencode/plan/bookhoard-app-v2-compat.md.
2026-10-19 16:16:58 -04:00
John O'Keefe f94e7472b4 feat(filters+ui): per-element author/publisher filters, playlist toggles, Up Next API, reorder UI, dashboard badges
Phases 4 + 5 of the metadata overhaul (plan §4, §5) plus §3 handler
wiring.

Filters (§4.1): author_filter/publisher_filter now match per-element
against the authors[]/publishers[] arrays — a co-authored book matches
any one of its authors — with comma-joined params acting as
OR-within-facet multi-select (string_to_array + cross unnest). Legacy
single-value params unchanged on the wire; series/genre/language
filters stay on their varchar columns; narrator filter already
per-element.

Collections API (§3.3-3.5): collection detail carries viewer +
hide_finished into the listing query and exposes up_next (visible-
library-enforced reading-queue head); PUT
/api/collections/:id/items/order (owner-only, full-order rewrite);
UpdateCollectionRequest gains hide_finished/show_up_next ([]
string last-value-wins for the hidden-false-sibling checkbox pattern)
merged server-side OVER stored view_settings so imported_from_series
provenance survives edits; EditCollectionModal renders the toggles
from view_settings.

Dashboard (§5.5): sections JSON carries is_playlist/complete
(dashboard.ts is the AGENTS-documented JSON-render exception; first
paint stays templ). Fully-read playlists render '✓ Playlist complete'
instead of an empty rail; imported playlists get a 📖 Playlist badge.

Frontend: book detail shows subtitle + full authors + Explicit badge;
edit modal gains subtitle, comma-separated authors/publishers (client-
split to arrays exactly like narrators), step=any series number,
explicit checkbox; collection book cards get playlist move ↑/↓ buttons
persisting the full order via the reorder endpoint (templ partial
re-render on failure — DOM order is client-local, API order is truth).

AGENTS.md gotcha #3 amended per plan §3.7: user collections are
dashboard sections by default; the show_on_dashboard premise is
retired.

Verified: full affected integration sweep green (pins widened for
up_next/is_playlist/complete — changelog entries in
docs/contract_pins.md); unit suite green; go build + templ + vite
green.
2026-10-19 15:43:59 -04:00
John O'Keefe 1dbb0ce406 feat(collections): playlist auto-creation, position ordering, hide_finished, Up Next, dashboard eligibility
Phase 2b + 3 of the metadata overhaul (plan §2.4, §3.1-3.7).

Auto-playlist collections (§2.4): the scanner turns obviously-playlist
sidecar series (explicit '(Reading Order)'/chronological/publication/
in-order/timeline markers only — precision over recall) into curated
static collections: upsert by view_settings.imported_from_series then
name (rename-safe), position set ONLY on insert (clean integers; 1.5/1a
stay unpositioned rather than guessing), membership add-if-missing,
never removed or re-ordered by rescans, provenance in description +
view_settings, show_on_dashboard true (dashboard-eligible by design),
'scanner.auto_playlist_collections' setting (default on, Settings
Registry + schema seed).

Ordering (§3.1-3.2): collection listings order position ASC NULLS LAST
then added_at ASC (was added_at DESC — walked playlists backwards);
PUT /api/collections/:id/items/order applies full-order rewrites
(owner-only, service-gated).

hide_finished + Up Next (§3.3-3.4): GetCollectionItemsForDashboard
gains viewer_user_id + hide_finished (LEFT JOIN reading_progress,
COALESCE percentage < 0.999) and viewer_percentage rides the row for
card graying; GetCollectionUpNext serves the collection's reading-queue
head with the family-lens visible-library array enforced.

Dashboard (§3.5): user collections are dashboard-ELIGIBLE BY DEFAULT —
the show_on_dashboard gate (never writable for user rows since the
column shipped) is gone from GetUserCollectionsForDashboard; hiding
stays dashboard-prefs (hidden_collections) territory; hide_finished
filtered-everything now emits a Complete section instead of silently
vanishing; sections JSON carries is_playlist/complete.

Series page ordering hardening (§3.6): GetSeriesBooks tiebreaks by
title ASC — NULL/tied numbers previously returned heap (scan) order,
which is what the user saw.

Frontend custom-section-builder note: the builder has sent
show_on_dashboard: true since its creation; the API never bound it.
User-row visibility is now intentionally prefs-governed, so the field
stays unbound by design (documented in code).

Pins widened (docs/contract_pins.md): collection detail + up_next;
dashboard sections + is_playlist/complete; new reorder endpoint
recorded.

Verified: TestPlaylistCollectionsEndToEnd (new, full e2e: sidecar
series split, collection auto-creation with position 7, rescan
idempotency, owner-only reorder, finished-filter + completion marker +
Up Next drain) plus the full affected integration sweep green.
2026-10-19 15:25:40 -04:00
John O'Keefe 7e4c97f639 feat(scanner): fix ABS sidecar series parsing; three-way series mapping; smart genre; language codes; title-derived series
Phase 2a of the metadata overhaul (plan §2.1-2.3, §2.5-2.9).

The headline bug: ABS writes sidecar series as an array of STRINGS
(["The Witcher #3", ...]); the parser only accepted {series, sequence}
OBJECT arrays, so every ABS-matched book silently lost its series (found
live: 9 sidecars, zero series ingested; only folder-structure Malazan
survived). parseSeriesCandidates now normalizes string arrays, object
arrays and Libation plain strings.

Three-way series assignment (plan §2.2):
- primary bibliographic series -> series/series_number columns
- additional real series (Drizzt: Icewind Dale #1 = Legend of Drizzt #4)
  -> alternate_info JSONB, comic-established shape
- ordering constructs ('The Witcher (Reading Order)') -> PlaylistSeries
  candidates; NEVER the series column. Detection is explicit-marker-only
  ((reading order|chronological|publication order|in order|timeline) as
  parenthetical/suffix) — precision over recall; a series merely
  containing 'timeline' mid-name stays a series.

Sequence policy: leading-number extraction ('Book 2' -> 2, '2,
Dramatized Adaptation' -> 2, '1a' -> 1); lossy extractions preserve the
raw text in alternate_info.series_sequence_raw.

Smart genre (deterministic lexicon): compound Audible blobs split
('Epic, Action & Adventure, Military, Sword & Sorcery' -> 4 candidates),
alias-normalized, format noise demoted ('Audiobook' never wins),
most-specific canonical genre takes the genre column, all candidates
stay in tags.

Language: display names normalize to ISO 639-1 on ingest; ROOT-CAUSED
the update-path drop — CreateMediaItem call site never set Language
(explicit NULL over the column default), the update path did; create
now carries it.

Title-derived series fallback (§2.7): 'Baptism of Fire: The Witcher,
Book 3' 2-level folders split conservatively (only when the post-colon
tail matches ', Book N'), subtitle parsed for series+number, blanks-only
precedence, matching-name number fill, zero display mutation. Works
with no sidecar and no network.

Ingest fixes: subtitle/explicit land in their columns (audio tag
subtitle stops joining into title); full authors[] array (was [0]-
only); sidecar descriptions sanitized via bluemonday UGC policy
(vendored).

metadata_overrides: verified scan-update path already applies
ApplyMetadataOverrides; subtitle/explicit/authors covered by the Phase 1
override adaptation.

Tests: new series_parsing_test.go (string-array sidecar e2e, Drizzt
alternate_info, lossy raw preservation, playlist classification,
title-derived fallback incl. no-mutation + blanks-only, genre lexicon,
language map); audio-tag subtitle pin updated to column behavior;
calibre/ABS 2.5-series_index pins widened per float change (changelog'd
in phase 1).

Deferred to 2b: auto-created playlist collections (§2.4) — settings
toggle, collection upsert, ownership.
2026-10-19 14:34:42 -04:00
John O'Keefe 646477afad feat(schema): subtitle/explicit columns, authors+publishers arrays, float series numbers
Phase 1 of the series/collections/metadata overhaul
(~/.opencode/plan/series-collections-metadata-overhaul.md).

Schema (in place, idempotent):
- media_items.series_number INTEGER -> DOUBLE PRECISION: real-world
  ordinals are decimals (0.5, 1.5 novellas); INTEGER silently truncated
  them (calibre 2.5 pinned as 2 in tests).
- + subtitle TEXT, explicit BOOLEAN (sidecar ingest; ABS explicit /
  Audible isAdult).
- + authors/authors_search/publishers/publishers_search TEXT[] NOT NULL
  (narrators A2a precedent). Arrays are write truth; author/publisher
  varchars become trigger-regenerated display strings
  (sync_author_publisher_display) so every varchar consumer (trgm
  search, facets, API, OPDS/Kobo) keeps working unchanged.
- Row-level NULL normalization for the new arrays in
  set_library_type_name (A2 lesson: Go nil -> explicit NULL -> 23502).
- Backfills ARRAY[author]/ARRAY[publisher] — deliberately no
  comma-splitting ('Tolkien, J.R.R.' would corrupt; scanner kept only
  authors[0] historically).
- collection_items.position INTEGER + (collection_id, position) index
  for playlist ordering.

Go: sqlc regen; MediaMetadata/handler params gain Subtitle/Explicit/
Authors/Publishers (legacy singles still accepted, arrays win);
SeriesNumber int32 -> float64 end to end; metadata_overrides adapted
with legacy override keys ('author'/'publisher') preserved so existing
user overrides survive; bulk author edit writes the array.

Pins widened (docs/contract_pins.md): raw sqlc row key set + the six
new columns; changelog entry added.

Verified: unit suite green; integration sweep (bulk/smart collections/
scanner/stats/sync/queue/calibre/pins/audiobooks) green against the
shared dev DB after in-place migration. Pre-existing unrelated red:
TestOwnershipAndAdminTestSuite/TestAdminCreateUser timezone inheritance
fails identically at the pre-change commit (live shared state).
2026-10-19 14:17:50 -04:00
181 changed files with 21992 additions and 4809 deletions

No files matched your search

+62 -8
View File
@@ -71,6 +71,16 @@ were name-only across ALL libraries. Fixed; do not regress.
## Tests
- **THE DOCTRINE — tests are never run against a real/production
server.** The dev DB is the test environment, and the invariant every
test must uphold is: **leave the testuser experience alone** — its
settings, scans, and reading data must be exactly as the suite found
them. The helpers exist to enforce this; if a test needs protection
the helpers don't provide, EXPAND THE HELPERS rather than hand-rolling
(2026-10-21: `setSystemSettingRestored` — snapshot-and-restore for
system_settings — exists because a hardcoded ev restore could clobber
a deliberate dev bump, and the live dev server obeys that setting on
its next poll).
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
(see the Conventions section below for the pool/cleanup rules).
- Integration tests run against the SHARED dev database: never write
@@ -113,6 +123,23 @@ were name-only across ALL libraries. Fixed; do not regress.
tested with synthetic ffprobeReport values. Recipes:
docs/contributing/development.md → Media fixtures.
## Web (TypeScript) tests
- `npm run test` (vp test) runs the vitest suite: colocated `web/src/*.test.ts`
files, jsdom environment (configured in vite.config.ts `test`). Do NOT switch
to happy-dom — DOMPurify's sanitizer output is mangled under it, which makes
the markdown security suite test nothing (verified empirically; jsdom is the
documented fallback for exactly this reason).
- Static gates: `npx tsc --noEmit`, `npx vp check web/src` (format + lint),
`npx vp fmt web/src` to apply formatting.
- Tests are never bundled (build entries are main.ts/reader.ts only). When
touching build config, re-prove it: back-to-back `npm run build:ts` runs must
produce byte-identical hashes, and no test-only strings may appear in
web/static.
- markdown.test.ts pins the notes XSS defenses (FORBID_TAGS, scheme checks,
parser-level html escaping). Loosening any assertion there is a security
decision, not cleanup.
## Timezones
- The server owns display conversion: clients send instants (RFC3339),
@@ -161,14 +188,19 @@ of a server bug: stop and flag the user.
the cache stale until the next API-path write — never "preview"
rule changes via psql and expect them applied (2026-10-14: a live
curl test flip looked ignored until the API path was used).
3. **User collections are not dashboard sections**: nothing sets
collection show_on_dashboard for user rows, and GetDashboardSections
skips empty user sections. Collection detail (JSON + SSR) must NOT
route user rows through the dashboard-section path — a 7ce7321
regression did exactly that (static detail without library_id
returned zero books until 0a7fbd6). Only real system query types
(continue-reading etc.) route there; `query_type='filter'` is the
schema DEFAULT on every user row, not a query type.
3. **User collections are not dashboard query types** (amended 2026-10-19,
A2c): user collections ARE dashboard sections by default now —
GetUserCollectionsForDashboard no longer gates on show_on_dashboard
(which remains system-section provisioning state; hiding is
dashboard-prefs `hidden_collections` territory). GetDashboardSections
still skips genuinely empty user sections, but hide_finished
collections emit a Complete section instead of vanishing. Collection
detail (JSON + SSR) must still NOT route user rows through the
dashboard-section path — a 7ce7321 regression did exactly that
(static detail without library_id returned zero books until 0a7fbd6).
Only real system query types (continue-reading etc.) route there;
`query_type='filter'` is the schema DEFAULT on every user row, not a
query type.
4. **End-of-day arithmetic**: anchor the END bound to the START of the
day, never to NOW(). `now.AddDate(0,0,1) − 1ns` carries this
instant's clock time into tomorrow — a range "ending today"
@@ -201,3 +233,25 @@ of a server bug: stop and flag the user.
test pools use `max_conns=1`; never create a `pgxpool` per subtest
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
subtests over duplicate functions.
7. **Scanner re-extraction is write-after-success** — a failed/empty
extraction must NEVER clear scan-derived columns. `updateMediaItem`
(generic full-row path) preserves `existing.AudioMetadata` when the
new extraction carries no audio payload, and `updateAudioBookItem`
refuses empty payloads over populated rows (both guarded 2026-10-21
after an ev-gated re-extract NULLed every multi-file audiobook's
audio_metadata — books displayed as 1 second long and lost their
track manifests). New scan-owned column writes need the same guard.
8. **templ double-escapes style attribute values** — the codegen wraps
style exprs in a sanitizer AND `templ.EscapeString`, so ANY quote
character (even inside `templ.SafeCSS`) reaches the DOM as literal
`&#39;` text and the CSS never parses (2026-10-21: every book-detail
hero backdrop broken + Firefox fetched `/media/&` → 500 spam).
Emit quote-free CSS: unquoted `url(...)` tokens via `SafeCSS` +
`cssURL()` in book_detail.templ.
9. **`vp test` runs the vite config** — plugins with build hooks fire
in the test context too. `cleanStaleChunks` guards on
`produced.size === 0` precisely because a bare sweep deleted every
hashed chunk in `web/static` when run under vitest (2026-10-21:
`npm run test` gutted the build; every page 404'd its imports).
Any future outDir-mutating plugin needs the same "am I a build?"
guard.
+1 -1
View File
@@ -165,7 +165,7 @@ func main() {
dashboardHandler := handlers.NewDashboardHandler(queries)
seriesHandler := handlers.NewSeriesHandler(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)
jobsHandler := handlers.NewJobsHandler(queries, worker)
+172
View File
@@ -0,0 +1,172 @@
package main
// Playback speed preferences (player plan §8): a per-user PER-BOOK
// speed plus a user-level default, resolved at the chapters endpoint
// (per-book row → users.default_audio_speed → 1.0). All speed writes
// ride the fresh per-test regular user — the shared admin account is
// never touched (AGENTS doctrine). Fixture books live in test-named
// libraries that cascade away with the library delete.
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// writeTestTrackTone renders a short sine mp3 at a GIVEN frequency so
// multiple fixture books never collide on the scanner's content dedup.
// Falls back to dummy bytes (ingest is ffprobe-optional) like
// writeTestTrack.
func writeTestTrackTone(t *testing.T, path string, hz int) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
"-i", fmt.Sprintf("sine=frequency=%d:duration=0.3", hz), "-codec:a", "libmp3lame", "-b:a", "32k", path)
if out, err := cmd.CombinedOutput(); err == nil {
return
} else {
t.Logf("ffmpeg track render failed (%s), falling back to dummy bytes: %s", err, out)
}
}
require.NoError(t, os.WriteFile(path, []byte(fmt.Sprintf("ID3 dummy audio %d", hz)), 0o644))
}
func TestAudioPlaybackSpeed(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Speed %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Speed Book", "01 one.mp3"))
// 552 Hz (not the shared helper's 440) so the scanner's sha-256
// dedup doesn't skip the second book as a byte-duplicate.
writeTestTrackTone(t, filepath.Join(folder, "Speed Book Two", "01 one.mp3"), 552)
scanTestFolder(t, setup, admin, folder)
// Grouping depends on track count (single-track folders ingest as
// single-file items whose path IS the file; multi-track folders get
// the folder path) — find by the library-relative path CONTAINING
// the book folder so the test holds either way.
var bookPath, book2Path string
for _, item := range listLibraryAudioItems(t, setup, libID) {
p := filepath.ToSlash(item.FilePath)
t.Logf("ingested item path: %s", p)
if strings.Contains(p, "Speed Book Two") && book2Path == "" {
book2Path = p
} else if strings.Contains(p, "Speed Book/") && bookPath == "" {
bookPath = p
}
}
require.NotEmpty(t, bookPath, "Speed Book item must exist")
require.NotEmpty(t, book2Path, "Speed Book Two item must exist")
book, found := findAudioItemByPath(t, setup, libID, bookPath)
require.True(t, found)
bookID := uuid.UUID(book.ID.Bytes)
book2, found := findAudioItemByPath(t, setup, libID, book2Path)
require.True(t, found)
book2ID := uuid.UUID(book2.ID.Bytes)
client := &http.Client{}
token := setup.RegularToken
authed := func(method, url, body string) (*http.Response, func()) {
var req *http.Request
if body == "" {
req, _ = http.NewRequest(method, url, nil)
} else {
req, _ = http.NewRequest(method, url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
return resp, func() { _ = resp.Body.Close() }
}
getSpeeds := func(itemID uuid.UUID) (playback, def float64) {
resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "")
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var data struct {
PlaybackSpeed float64 `json:"playback_speed"`
DefaultPlaybackSpeed float64 `json:"default_playback_speed"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
return data.PlaybackSpeed, data.DefaultPlaybackSpeed
}
t.Run("unset resolves to 1x", func(t *testing.T) {
playback, def := getSpeeds(bookID)
require.Equal(t, 1.0, playback)
require.Equal(t, 1.0, def)
})
t.Run("put per-book speed upserts and resolves", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", `{"speed":1.5}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
var saved struct {
PlaybackSpeed float64 `json:"playback_speed"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&saved))
require.Equal(t, 1.5, saved.PlaybackSpeed)
playback, def := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
require.Equal(t, 1.0, def) // user default untouched
})
t.Run("out of range speed is rejected", func(t *testing.T) {
for _, body := range []string{`{"speed":99}`, `{"speed":0}`, `{"speed":0.1}`} {
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", body)
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
closer()
}
// The stored speed is untouched by the rejects.
playback, _ := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
})
t.Run("profile default applies to books without their own speed", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":1.25}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
// Per-book row wins over the default on book one...
playback, def := getSpeeds(bookID)
require.Equal(t, 1.5, playback)
require.Equal(t, 1.25, def)
// ...book two has no row and picks the default up.
playback2, _ := getSpeeds(book2ID)
require.Equal(t, 1.25, playback2)
// GET /profile carries it for the profile form.
resp2, closer2 := authed("GET", setup.Server.URL+"/api/auth/profile", "")
defer closer2()
var profile struct {
DefaultAudioSpeed float64 `json:"default_audio_speed"`
}
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&profile))
require.Equal(t, 1.25, profile.DefaultAudioSpeed)
})
t.Run("updating the default re-satisfies unset books", func(t *testing.T) {
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":0.75}`)
defer closer()
require.Equal(t, http.StatusOK, resp.StatusCode)
playback2, _ := getSpeeds(book2ID)
require.Equal(t, 0.75, playback2)
playback1, _ := getSpeeds(bookID)
require.Equal(t, 1.5, playback1) // per-book pin survives
})
}
@@ -47,6 +47,24 @@ func writeTestTrack(t *testing.T, path string) {
require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644))
}
// writeTestTrackM4B is writeTestTrack for the m4b (AAC/ipod muxer)
// container — the audiobook format whose rescan route is under guard.
// ffmpeg needs -f ipod for the .m4b extension; falls back to dummy bytes.
func writeTestTrackM4B(t *testing.T, path string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
"-i", "sine=frequency=440:duration=0.3", "-codec:a", "aac", "-b:a", "32k", "-f", "ipod", path)
if out, err := cmd.CombinedOutput(); err == nil {
return
} else {
t.Logf("ffmpeg m4b render failed (%s), falling back to dummy bytes: %s", err, out)
}
}
require.NoError(t, os.WriteFile(path, []byte("dummy m4b audio"), 0o644))
}
// audioTestLibrary builds an audiobooks-type library with a fresh temp
// folder, returning its UUID and the folder path.
func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) {
@@ -569,3 +587,213 @@ func TestRescanKeepsFilenameTitle(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "Plain Book", rescanned.Title, "rescan must not blank the filename-derived title")
}
// TestAudiobookFolderSidecarEditDetected is the Phase 6 headline: a
// size-stable folder book is changed by EDITING its metadata.json —
// the track-count/size fingerprint never saw it, not even on force
// rescan. Pins the ladder end to end: seeding at create, pure-skip
// steady state, same-size sidecar edit consumed (title + collection
// sync), and the refreshed manifest.
func TestAudiobookFolderSidecarEditDetected(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Sidecar %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
bookDir := filepath.Join(folder, "Sidecar Author", "Sidecar Dir Name")
writeTestTrack(t, filepath.Join(bookDir, "01 first.mp3"))
writeTestTrack(t, filepath.Join(bookDir, "02 second.mp3"))
sidecar := filepath.Join(bookDir, "metadata.json")
// Sidecar title deliberately differs from the directory name so the
// assertion proves the SIDECAR won.
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Title"}`), 0o644))
scanTestFolder(t, setup, admin, folder)
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Sidecar Author/Sidecar Dir Name"))
require.True(t, found)
require.Equal(t, "Sidecar Real Title", book.Title, "sidecar title wins at create")
require.NotEqual(t, book.UpdatedAt.Time, time.Time{}, "sanity")
// Manifest seeded at create (the multifile create tail).
state, err := setup.DB.GetMediaScanState(context.Background(), book.ID)
require.NoError(t, err)
require.NotEmpty(t, state.Manifest, "folder create must seed the sidecar manifest")
seededManifest := state.Manifest
// Steady state: the manifest ladder runs and skips — pure skip.
scanTestFolder(t, setup, admin, folder)
afterSkip := findItemUpdated(t, setup, libID, book.FilePath)
require.Equal(t, book.UpdatedAt.Time, afterSkip, "unchanged rescan with seeded manifest is a pure skip")
// Same-size sidecar edit — the size-only blind spot.
sameSize := []byte(`{"title":"Sidecar Real Titlz"}`)
require.Len(t, sameSize, len(`{"title":"Sidecar Real Title"}`), "fixture must be a same-size edit")
require.NoError(t, os.WriteFile(sidecar, sameSize, 0o644))
scanTestFolder(t, setup, admin, folder)
edited := findAudioItemByPathRow(t, setup, libID, book.FilePath)
require.Equal(t, "Sidecar Real Titlz", edited.Title, "same-size sidecar edit is detected and consumed")
state, err = setup.DB.GetMediaScanState(context.Background(), edited.ID)
require.NoError(t, err)
require.NotEmpty(t, state.Manifest)
require.NotEqual(t, string(seededManifest), string(state.Manifest), "change path stores a fresh full manifest")
// Playlist sync on the change path (plan Correction 2): a reading-
// order entry added to an EXISTING book creates its collection, and
// a further rescan does not duplicate it.
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Titlz","series":["Sidecar Saga (Reading Order) #1"]}`), 0o644))
scanTestFolder(t, setup, admin, folder)
var collCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
require.Equal(t, 1, collCount, "reading-order sidecar edit creates the collection for an existing book")
scanTestFolder(t, setup, admin, folder)
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
require.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
}
// TestAudiobookTrackOnlyChangeKeepsEditorial pins the other half of the
// contract: a TRACK change (sidecars untouched) keeps the existing
// scan-derived-only update — editorial columns stay as the sidecar
// wrote them, and no editorial apply fires.
func TestAudiobookTrackOnlyChangeKeepsEditorial(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio TrackOnly %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
bookDir := filepath.Join(folder, "Track Only Author", "Track Only Book")
writeTestTrack(t, filepath.Join(bookDir, "01 first.mp3"))
writeTestTrack(t, filepath.Join(bookDir, "02 second.mp3"))
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "metadata.json"), []byte(`{"title":"Editorial Anchor"}`), 0o644))
scanTestFolder(t, setup, admin, folder)
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Track Only Author/Track Only Book"))
require.True(t, found)
require.Equal(t, "Editorial Anchor", book.Title)
// A third track arrives; sidecars untouched → track-count change →
// payload update only.
writeTestTrack(t, filepath.Join(bookDir, "03 third.mp3"))
scanTestFolder(t, setup, admin, folder)
updated := findAudioItemByPathRow(t, setup, libID, book.FilePath)
layout, tracks, _, _ := decodeAudioPayload(t, updated)
require.Equal(t, "folder", layout)
require.Equal(t, 3, tracks, "track change takes the payload update")
require.Equal(t, "Editorial Anchor", updated.Title, "track-only change must not touch editorial columns")
}
// TestAudiobookZipSidecarEditAndRestore covers the zip twin: sibling
// metadata.json edits are detected, and the fast path lifts archive
// state like the folder path always did (Phase 6 parity fix).
func TestAudiobookZipSidecarEditAndRestore(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio ZipSidecar %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
dir := t.TempDir()
writeTestTrack(t, filepath.Join(dir, "a.mp3"))
writeTestTrack(t, filepath.Join(dir, "b.mp3"))
zipPath := filepath.Join(folder, "Zip Sidecar Book.zip")
zipFiles(t, zipPath, map[string]string{
"01 first.mp3": filepath.Join(dir, "a.mp3"),
"02 second.mp3": filepath.Join(dir, "b.mp3"),
})
sidecar := filepath.Join(folder, "metadata.json")
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Zip Sidecar Title"}`), 0o644))
scanTestFolder(t, setup, admin, folder)
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Zip Sidecar Book.zip"))
require.True(t, found)
require.Equal(t, "Zip Sidecar Title", book.Title, "sibling sidecar feeds the zip book at create")
state, err := setup.DB.GetMediaScanState(context.Background(), book.ID)
require.NoError(t, err)
require.NotEmpty(t, state.Manifest, "zip create must seed the sidecar manifest")
scanTestFolder(t, setup, admin, folder)
require.Equal(t, book.UpdatedAt.Time, findItemUpdated(t, setup, libID, book.FilePath), "unchanged zip rescan is a pure skip")
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Zip Sidecar Titlz"}`), 0o644))
scanTestFolder(t, setup, admin, folder)
edited := findAudioItemByPathRow(t, setup, libID, book.FilePath)
require.Equal(t, "Zip Sidecar Titlz", edited.Title, "zip sidecar edit is detected and consumed")
// Archive the row the way the missing-scan does; the file is back,
// so the next scan must lift the archive (parity with folders).
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`UPDATE media_items SET archived_at = NOW() WHERE id = $1 RETURNING id`, edited.ID,
).Scan(new(pgtype.UUID)))
scanTestFolder(t, setup, admin, folder)
restored := findAudioItemByPathRow(t, setup, libID, book.FilePath)
require.False(t, restored.ArchivedAt.Valid, "zip fast path must lift archive state when the file is back")
require.Equal(t, "Zip Sidecar Titlz", restored.Title)
}
// TestAudiobookSidecarRemovalFallsBackToFilename is the wipe-guard's
// testable sibling (faceted plan Phase 6 audit find): a single-file
// book whose sidecar is REMOVED must re-derive metadata through the
// create-equivalent chain (tags → filename), never blank the title.
// The pre-fix change path passed a non-nil empty metadata, which
// skipped the filename guard and the tag re-derivation.
func TestAudiobookSidecarRemovalFallsBackToFilename(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Removal %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
trackPath := filepath.Join(folder, "Fallback Book.mp3")
writeTestTrack(t, trackPath)
sidecar := filepath.Join(folder, "metadata.json")
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Soon Gone Title"}`), 0o644))
scanTestFolder(t, setup, admin, folder)
book, found := findAudioItemByPath(t, setup, libID, "Fallback Book.mp3")
require.True(t, found)
require.Equal(t, "Soon Gone Title", book.Title, "sidecar title wins at create")
// The sidecar vanishes: every sidecar gone → change → re-extract
// finds nothing → title falls back to the filename, not blank.
require.NoError(t, os.Remove(sidecar))
scanTestFolder(t, setup, admin, folder)
after := findAudioItemByPathRow(t, setup, libID, book.FilePath)
require.Equal(t, "Fallback Book", after.Title, "sidecar removal re-derives through the create-equivalent chain")
}
// findItemUpdated reads a media item's updated_at by file_path.
func findItemUpdated(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) time.Time {
t.Helper()
for _, item := range listLibraryAudioItems(t, setup, libID) {
if item.FilePath == filePath {
return item.UpdatedAt.Time
}
}
t.Fatalf("item %q not found in library", filePath)
return time.Time{}
}
// findAudioItemByPathRow is findAudioItemByPath with a hard failure.
func findAudioItemByPathRow(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) database.ListMediaItemsByLibraryIncludingArchivedRow {
t.Helper()
item, found := findAudioItemByPath(t, setup, libID, filePath)
require.True(t, found, "item %q must exist", filePath)
return item
}
+1 -1
View File
@@ -108,7 +108,7 @@ func TestCalibreLibraryScan(t *testing.T) {
require.Equal(t, "Test Book", book.Title, "Title should match sidecar")
require.Equal(t, "Test Author", book.Author.String, "Author should match sidecar")
require.Equal(t, "Test Series", book.Series.String, "Series should match sidecar")
require.Equal(t, int32(1), book.SeriesNumber.Int32, "Series number should match sidecar")
require.Equal(t, float64(1), book.SeriesNumber.Float64, "Series number should match sidecar")
require.Equal(t, "Test Publisher", book.Publisher.String, "Publisher should match sidecar")
require.Len(t, book.Tags, 2, "Should have 2 tags from sidecar")
require.Contains(t, book.Tags, "Fantasy", "Should have Fantasy tag")
+1 -1
View File
@@ -382,7 +382,7 @@ func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID s
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: "Preview Test Book " + string(rune('A'+i)),
Author: pgtype.Text{String: "Test Author " + string(rune('A'+i)), Valid: true},
Authors: []string{"Test Author " + string(rune('A'+i))},
Genre: pgtype.Text{String: genre, Valid: true},
FilePath: "/tmp/test" + string(rune('A'+i)) + ".epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
+1 -1
View File
@@ -111,7 +111,7 @@ func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uui
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: title,
Author: pgtype.Text{String: "Smart Rule Author", Valid: true},
Authors: []string{"Smart Rule Author"},
Genre: pgtype.Text{String: genre, Valid: true},
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
+18 -5
View File
@@ -123,7 +123,7 @@ func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
Title: itemTitle,
Author: pgtype.Text{String: "Test Contract Author", Valid: true},
Authors: []string{"Test Contract Author"},
Series: pgtype.Text{String: seriesName, Valid: true},
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
FilePath: "/tmp/contract-test-item.epub",
@@ -490,10 +490,12 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
fx := createContractFixture(t, setup)
// The envelope every detail branch shares.
// A2c (2026-10-19): + up_next (the collection's reading-queue strip
// payload; docs/contract_pins.md changelog).
envelopeKeys := []string{
"id", "user_id", "name", "description", "color", "icon",
"auto_assign_rules", "view_settings", "books", "book_count",
"is_smart", "created_at",
"is_smart", "created_at", "up_next",
}
t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
@@ -660,6 +662,11 @@ func TestContractPin_SearchJSON(t *testing.T) {
// EXPLAIN plan-identical) and the search endpoint serializes the
// row directly, so they emit here.
"abridged", "audio_metadata",
// A2b/A2c (2026-10-19): + subtitle, explicit, authors,
// authors_search, publishers, publishers_search, narrators_search
// (docs/contract_pins.md changelog).
"subtitle", "explicit", "authors", "authors_search",
"publishers", "publishers_search", "narrators_search",
}
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
@@ -693,6 +700,8 @@ func TestContractPin_DashboardSections(t *testing.T) {
sectionKeys := []string{
"id", "collection_id", "is_system", "title", "description",
"icon", "items", "view_all_url", "priority",
// A2c (2026-10-19): + is_playlist, complete (docs/contract_pins.md).
"is_playlist", "complete",
}
sections := getArray(t, body, "sections")
require.NotEmpty(t, sections)
@@ -711,10 +720,14 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
fx := createContractFixture(t, setup)
// The sqlc row key sets behind BOTH branches.
// A2b (2026-10-19): + subtitle, explicit, authors, authors_search,
// publishers, publishers_search (docs/contract_pins.md changelog).
rawRowKeys := []string{
"id", "library_id", "title", "author", "isbn", "description",
"id", "library_id", "title", "subtitle", "author", "isbn", "description",
"file_path", "file_size", "mime_type", "cover_image_path",
"series", "series_number", "tags", "narrators", "abridged",
"explicit", "authors", "authors_search", "publishers",
"publishers_search",
"asin", "date_published", "publisher", "contributors", "language",
"edition", "page_count", "genre", "copyright_year", "goodreads_id",
"openlibrary_id", "google_books_id", "added_by_admin_id", "created_at",
@@ -731,10 +744,10 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
"library_type_name_2",
}
resolvedKeys := []string{
"id", "library_id", "title", "author", "isbn", "description",
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
"file_path", "file_size", "mime_type", "cover_image_path",
"series", "series_number", "tags", "asin", "date_published",
"publisher", "contributors", "language", "edition", "page_count",
"publisher", "publishers", "explicit", "contributors", "language", "edition", "page_count",
"genre", "created_at", "updated_at", "format_group",
"manga_type", "reading_direction",
"series_count", "volume", "imprint", "age_rating", "web_url",
+181
View File
@@ -0,0 +1,181 @@
package main
// Extractor-version (ev) mechanism, faceted plan Phase 10: parser fixes
// (7e4c97f6, genre model v2) must reach rows ingested under an older
// parser without a force rescan. The manifest's ev stamp is the
// per-item provenance; scan_extractor_version is the current parser's
// claim; a mismatch re-extracts on a later scan, bounded per scan.
//
// State machine pinned here, driven through REAL scans over a temp
// library (the corruption probe makes re-extraction observable: the
// only writer that restores the sidecar values is a re-extraction):
//
// create → stamped ev=1 (current parser, truthful)
// equal → no re-extract (corruption persists)
// wiped → backfill stores ev UNSET, never stamps, never re-extracts
// bump → re-extract ONCE (corruption healed, ev=2)
// equal → stable (second corruption persists)
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/require"
)
// storedManifestEv reads the "ev" stamp from an item's stored manifest
// (0 = absent — the backfill's v1-or-unknown marker).
func storedManifestEv(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID) int {
t.Helper()
row, err := setup.DB.GetMediaScanState(context.Background(), itemID)
require.NoError(t, err, "scan-state row must exist — every book needs an ev home")
var m map[string]any
require.NoError(t, json.Unmarshal(row.Manifest, &m))
ev, _ := m["ev"].(float64)
return int(ev)
}
// corruptItemColumn scribbles a scan-owned column on a TEST-CREATED item
// so a later re-extraction is observable (the sidecar rewrites it back).
// The row is test data cascade-deleted with the library — never real rows.
func corruptItemColumn(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID, column, value string) {
t.Helper()
tag, err := setup.DBPool.Exec(context.Background(),
fmt.Sprintf("UPDATE media_items SET %s = $1 WHERE id = $2", column), value, itemID)
require.NoError(t, err)
require.Equal(t, int64(1), tag.RowsAffected(), "corruption must hit exactly the test item")
}
func setExtractorVersion(t *testing.T, setup *TestServerSetup, v int) {
t.Helper()
// Restore-to-prior (not a hardcoded value): the shared dev DB's
// setting must survive the suite exactly as the test found it — if a
// parser-fix bump was in flight, a hardcoded 1 would silently cancel
// it and the live server would stop re-extracting.
setSystemSettingRestored(t, setup, "scan_extractor_version", strconv.Itoa(v))
}
func TestExtractorVersionReextract(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
pinExtractorVersionOne(t, setup)
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test EV Library %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// metadata.opf sidecar: the re-extraction's provenance source.
bookDir := filepath.Join(folder, "EV Probe Author", "EV Probe Book")
require.NoError(t, os.MkdirAll(bookDir, 0o755))
opf := `<?xml version='1.0' encoding='utf-8'?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>EV Probe Book</dc:title>
<dc:creator>EV Probe Author</dc:creator>
</metadata>
</package>`
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "metadata.opf"), []byte(opf), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "EV Probe Book.epub"), []byte("dummy epub content"), 0o644))
scanTestFolder(t, setup, admin, folder)
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.True(t, found, "sidecar book must import")
// Create stamps the current parser version (truthful).
require.Equal(t, 1, storedManifestEv(t, setup, item.ID), "create must stamp the default ev=1")
// Equality → skip: corruption persists (nothing re-extracts at ev=1).
// NOTE: `author` is trigger-owned (sync_author_publisher_display
// rewrites it from the authors array on every UPDATE) — corrupt a
// trigger-free column the re-extraction rewrites: title.
corruptItemColumn(t, setup, item.ID, "title", "ZZZ corrupted")
scanTestFolder(t, setup, admin, folder)
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.True(t, found)
require.Equal(t, "ZZZ corrupted", item.Title, "equal ev must NOT re-extract (corruption persists)")
require.Equal(t, 1, storedManifestEv(t, setup, item.ID))
// Wipe the scan state (test-owned row) to simulate a pre-release
// book: backfill re-fingerprints WITHOUT re-extraction and WITHOUT
// stamping — the row never claims a version it can't prove.
_, err := setup.DBPool.Exec(ctx, "DELETE FROM media_scan_state WHERE media_item_id = $1", item.ID)
require.NoError(t, err)
scanTestFolder(t, setup, admin, folder)
require.Equal(t, 0, storedManifestEv(t, setup, item.ID), "backfill must store ev UNSET (v1-or-unknown)")
item, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.Equal(t, "ZZZ corrupted", item.Title, "backfill must NOT re-extract")
// Bump via the SETTINGS API; the absent ev rides v1 → mismatch →
// re-extract once: the corruption heals and the fresh manifest
// carries ev=2.
setExtractorVersion(t, setup, 2)
// Prior-value restore rides inside setExtractorVersion — never a
// hardcoded 1, so a deliberate dev bump survives the suite.
scanTestFolder(t, setup, admin, folder)
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.True(t, found)
require.Equal(t, "EV Probe Book", item.Title, "ev mismatch must re-extract (sidecar value restored)")
require.Equal(t, 2, storedManifestEv(t, setup, item.ID), "post-extraction store stamps the new ev")
// Then stable: the second corruption persists at ev=2 == setting 2.
corruptItemColumn(t, setup, item.ID, "title", "ZZZ corrupted again")
scanTestFolder(t, setup, admin, folder)
item, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
require.Equal(t, "ZZZ corrupted again", item.Title, "equal ev must NOT re-extract again (stable)")
require.Equal(t, 2, storedManifestEv(t, setup, item.ID))
}
func TestExtractorVersionMultifile(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
pinExtractorVersionOne(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test EV Audio %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// A canonical folder book, sidecar-less: the Phase 6 seed gives it
// an entries-less manifest — now also its ev home.
writeTestTrack(t, filepath.Join(folder, "EV Audio Author", "EV Audio Title", "01 First.mp3"))
writeTestTrack(t, filepath.Join(folder, "EV Audio Author", "EV Audio Title", "02 Second.mp3"))
scanTestFolder(t, setup, admin, folder)
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
require.True(t, found, "folder book must import")
require.Equal(t, "EV Audio Title", book.Title)
require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "multifile create must stamp the default ev=1")
// Equality → skip: the multifile ladder's unchanged gate stays shut.
corruptItemColumn(t, setup, book.ID, "title", "ZZZ corrupted")
scanTestFolder(t, setup, admin, folder)
book, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
require.Equal(t, "ZZZ corrupted", book.Title, "equal ev must NOT re-apply (corruption persists)")
require.Equal(t, 1, storedManifestEv(t, setup, book.ID))
// Bump → the ev gate fires through multifileSidecarChanged → the
// editorial apply re-derives the title from the folder basename.
setExtractorVersion(t, setup, 2)
// Prior-value restore rides inside setExtractorVersion — never a
// hardcoded 1, so a deliberate dev bump survives the suite.
scanTestFolder(t, setup, admin, folder)
book, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
require.True(t, found)
require.Equal(t, "EV Audio Title", book.Title, "ev mismatch must re-extract the folder book")
require.Equal(t, 2, storedManifestEv(t, setup, book.ID), "change-path store stamps the new ev")
// Then stable.
corruptItemColumn(t, setup, book.ID, "title", "ZZZ corrupted again")
scanTestFolder(t, setup, admin, folder)
book, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
require.Equal(t, "ZZZ corrupted again", book.Title, "equal ev must NOT re-apply again (stable)")
require.Equal(t, 2, storedManifestEv(t, setup, book.ID))
}
+641
View File
@@ -0,0 +1,641 @@
package main
// Facet registry Batch A integration tests (faceted-navigation plan,
// Part 1 Phase 1): the nine new SearchMediaItemsUnified narg blocks —
// scalar-fuzzy (story_arc, imprint, edition), exact enums (age_rating,
// format_group, manga_type, reading_direction), and presence-bound
// bools (explicit, abridged) — filter correctly against a real DB.
// The registry↔SQL drift guard (unit level) lives in
// internal/services/facet_registry_test.go.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"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"
)
// facetSearch runs the unified search endpoint with arbitrary query
// params and returns (status, decoded array-or-object).
func facetSearch(t *testing.T, setup *TestServerSetup, 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 "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
// The endpoint's no-results contract: 404 + error envelope.
return resp.StatusCode, nil
}
require.Equal(t, http.StatusOK, resp.StatusCode)
var rows []map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
return resp.StatusCode, rows
}
func facetTitles(t *testing.T, rows []map[string]interface{}) []string {
t.Helper()
var out []string
for _, r := range rows {
title, _ := r["title"].(string)
out = append(out, title)
}
return out
}
func TestFacetRegistryParamsFilter(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)
libName := fmt.Sprintf("Test Facet Params Library %d", time.Now().UnixNano())
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: libName,
Description: pgtype.Text{String: "facet params 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) })
createItem := func(title string, mutate func(*database.CreateMediaItemParams)) {
t.Helper()
p := database.CreateMediaItemParams{
LibraryID: library.ID,
Title: title,
Authors: []string{"Facet Param Author"},
FilePath: fmt.Sprintf("/tmp/facet-params/%s-%d.epub", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
}
if mutate != nil {
mutate(&p)
}
_, err := setup.DB.CreateMediaItem(ctx, p)
require.NoError(t, err, "create %s", title)
}
createItem("Facet Alpha", func(p *database.CreateMediaItemParams) {
p.StoryArc = pgtype.Text{String: "civil war", Valid: true}
p.Imprint = pgtype.Text{String: "Vertigo", Valid: true}
p.Edition = pgtype.Text{String: "First Edition", Valid: true}
p.AgeRating = pgtype.Text{String: "Teen", Valid: true}
p.MangaType = pgtype.Text{String: "yes", Valid: true}
p.Explicit = true
})
createItem("Facet Beta", func(p *database.CreateMediaItemParams) {
p.StoryArc = pgtype.Text{String: "infinity gauntlet", Valid: true}
p.AgeRating = pgtype.Text{String: "Mature", Valid: true}
p.ReadingDirection = pgtype.Text{String: "rtl", Valid: true}
p.Abridged = true
})
base := url.Values{"library_id": []string{libUUID.String()}}
titles := func(params url.Values) []string {
status, rows := facetSearch(t, setup, mergeParams(base, params))
require.Equal(t, http.StatusOK, status)
return facetTitles(t, rows)
}
// Unfiltered: both items, the additive params are default-neutral.
got := titles(url.Values{})
assert.Len(t, got, 2, "unfiltered search returns both items")
// Scalar-fuzzy: single value...
got = titles(url.Values{"story_arc_filter": {"civil war"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "story_arc scalar-fuzzy")
got = titles(url.Values{"imprint_filter": {"Vertigo"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "imprint scalar-fuzzy")
got = titles(url.Values{"edition_filter": {"First Edition"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "edition scalar-fuzzy")
// ...and comma = OR-within (multi-select).
got = titles(url.Values{"story_arc_filter": {"civil war,infinity gauntlet"}})
assert.Len(t, got, 2, "comma is OR-within the facet")
// Exact enums: single + multi.
got = titles(url.Values{"age_rating_filter": {"Teen"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "age_rating exact")
got = titles(url.Values{"age_rating_filter": {"Teen,Mature"}})
assert.Len(t, got, 2, "age_rating multi-select")
got = titles(url.Values{"format_group_filter": {"reflowable"}})
assert.Len(t, got, 2, "format_group exact (create default)")
status, _ := facetSearch(t, setup, mergeParams(base, url.Values{"format_group_filter": {"audiobook"}}))
assert.Equal(t, http.StatusNotFound, status, "wrong format_group matches nothing")
got = titles(url.Values{"reading_direction_filter": {"rtl"}})
assert.Equal(t, []string{"Facet Beta"}, got, "reading_direction exact")
got = titles(url.Values{"manga_type_filter": {"yes"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "manga_type exact (works cross-type by data)")
// Presence-bound bools: absent = no filter, value = exact.
got = titles(url.Values{"explicit_filter": {"true"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "explicit=true")
got = titles(url.Values{"explicit_filter": {"false"}})
assert.Equal(t, []string{"Facet Beta"}, got, "explicit=false")
got = titles(url.Values{"abridged_filter": {"true"}})
assert.Equal(t, []string{"Facet Beta"}, got, "abridged=true")
// AND-across-facets: story_arc + age_rating intersect to Alpha.
got = titles(url.Values{"story_arc_filter": {"civil war"}, "age_rating_filter": {"Teen"}})
assert.Equal(t, []string{"Facet Alpha"}, got, "AND-across-facets")
// Language UX (Batch B): a display name normalizes through the
// ingest map — "English" matches the row stored as "en".
createItem("Facet Gamma", func(p *database.CreateMediaItemParams) {
p.Language = pgtype.Text{String: "en", Valid: true}
})
got = titles(url.Values{"language_filter": {"English"}})
assert.Equal(t, []string{"Facet Gamma"}, got, "display name matches ISO-stored rows")
got = titles(url.Values{"language_filter": {"en"}})
assert.Equal(t, []string{"Facet Gamma"}, got, "raw code still matches")
}
// TestFacetsEndpoint covers GET /api/media-items/facets (Batch C): the
// registry-driven value+count listing, language display names, and the
// family-lens gates (hidden library → 403 on BOTH the facets and search
// endpoints; bool/range facets → 400 because they render as toggles).
func TestFacetsEndpoint(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)
libName := fmt.Sprintf("Test Facet Listing Library %d", time.Now().UnixNano())
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: libName,
Description: pgtype.Text{String: "facet listing 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) })
// Two items share the genre; one carries the language + narrator.
for _, title := range []string{"Facet List One", "Facet List Two"} {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: title,
Authors: []string{"Facet Listing Author"},
Genre: pgtype.Text{String: "fantasy", Valid: true},
Language: pgtype.Text{String: "en", Valid: true},
FilePath: fmt.Sprintf("/tmp/facet-listing/%s-%d.epub", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
}
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Facet List Three",
Authors: []string{"Facet Listing Author"},
Genre: pgtype.Text{String: "horror", Valid: true},
Narrators: []string{"Facet Listing Narrator"},
FilePath: fmt.Sprintf("/tmp/facet-listing/three-%d.epub", time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
getFacets := func(params url.Values) (int, map[string]interface{}) {
t.Helper()
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?"+params.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 >= 400 {
return resp.StatusCode, nil
}
var body map[string]interface{}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
return resp.StatusCode, body
}
// Counts correct + ordered by count desc.
status, body := getFacets(url.Values{
"facet": {"genre"},
"library_id": {libUUID.String()},
})
require.Equal(t, http.StatusOK, status)
assert.Equal(t, "genre", body["facet"])
values, _ := body["values"].([]interface{})
require.Len(t, values, 2)
first, _ := values[0].(map[string]interface{})
second, _ := values[1].(map[string]interface{})
assert.Equal(t, "fantasy", first["value"])
assert.Equal(t, float64(2), first["count"], "two items share the genre")
assert.Equal(t, "horror", second["value"])
assert.Equal(t, float64(1), second["count"])
// Language values carry display names (server owns display).
status, body = getFacets(url.Values{
"facet": {"language"},
"library_id": {libUUID.String()},
})
require.Equal(t, http.StatusOK, status)
values, _ = body["values"].([]interface{})
require.Len(t, values, 1)
lang, _ := values[0].(map[string]interface{})
assert.Equal(t, "en", lang["value"], "the raw code rides the param")
assert.Equal(t, "English", lang["display"], "the name is for display")
// Bool facets render as toggles, not lists → 400.
status, _ = getFacets(url.Values{
"facet": {"explicit"},
"library_id": {libUUID.String()},
})
assert.Equal(t, http.StatusBadRequest, status)
// Unknown facet → 400.
status, _ = getFacets(url.Values{
"facet": {"woodcut"},
"library_id": {libUUID.String()},
})
assert.Equal(t, http.StatusBadRequest, status)
// Family-lens: a library explicitly hidden from the regular user is
// a 403 on the facets endpoint AND on the search endpoint (an
// explicit library_id outside the visible set is never silently
// empty — AGENTS.md → Library visibility).
_, err = setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
LibraryID: library.ID,
IsVisible: false,
})
require.NoError(t, err)
status, _ = getFacets(url.Values{
"facet": {"genre"},
"library_id": {libUUID.String()},
})
assert.Equal(t, http.StatusForbidden, status, "hidden library → 403")
searchReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?library_id="+libUUID.String(), nil)
searchReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
searchResp, err := http.DefaultClient.Do(searchReq)
require.NoError(t, err)
_, _ = io.Copy(io.Discard, searchResp.Body)
_ = searchResp.Body.Close()
assert.Equal(t, http.StatusForbidden, searchResp.StatusCode, "search with hidden library_id → 403")
}
func mergeParams(base, extra url.Values) url.Values {
out := url.Values{}
for k, v := range base {
out[k] = v
}
for k, v := range extra {
out[k] = v
}
return out
}
// facetFixtureBooks is the under-filter counts fixture: two fantasy
// books (Pinecrest / Quartzmill) and one history book (Pinecrest).
// Publisher names are trigram-disjoint on purpose — the scalar-fuzzy
// filter is word_similarity, and near-identical names ("Ace Books" vs
// "Baen Books") cross-match at 0.3+ (session gotcha).
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
t.Helper()
ctx := context.Background()
books := []struct {
title string
genre string
publisher string
}{
{prefix + " Alpha", "fantasy", "Pinecrest"},
{prefix + " Beta", "fantasy", "Quartzmill"},
{prefix + " Gamma", "history", "Pinecrest"},
}
for _, b := range books {
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libraryID,
Title: b.title,
Authors: []string{prefix + " Author"},
Genre: pgtype.Text{String: b.genre, Valid: true},
Publishers: []string{b.publisher},
Language: pgtype.Text{String: "en", Valid: true},
FilePath: fmt.Sprintf("/tmp/facet-counts/%s-%d.epub", b.title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
}
}
// facetCountValue is one decoded facet value+count.
type facetCountValue struct {
Value string `json:"value"`
Count float64 `json:"count"`
}
// facetCountsGet runs the all-facets endpoint (no facet param) and
// decodes {"facets": {param: [{value, count}, …]}}.
func facetCountsGet(t *testing.T, setup *TestServerSetup, params url.Values) (int, map[string][]facetCountValue) {
t.Helper()
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?"+params.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 >= 400 {
return resp.StatusCode, nil
}
var body struct {
Facets map[string][]facetCountValue `json:"facets"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
return resp.StatusCode, body.Facets
}
// requireFacetCount asserts one value's count within a facet.
func requireFacetCount(t *testing.T, facet []facetCountValue, value string, want float64) {
t.Helper()
for _, v := range facet {
if v.Value == value {
require.Equal(t, want, v.Count, "facet value %q count", value)
return
}
}
require.Failf(t, "facet value missing", "%q not in facet", value)
}
// TestFacetCountsUnderFilters (faceted plan Phase 7): the all-facets
// shape computes each facet's counts UNDER the other active filters —
// a facet's own block is omitted from its arm (classic faceted
// semantics: the genre list stays complete while genre filters the
// rest), multi-select is OR-within, and the family lens holds on the
// new shape too.
func TestFacetCountsUnderFilters(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)
libName := fmt.Sprintf("Test Facet Counts %d", time.Now().UnixNano())
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: libName,
Description: pgtype.Text{String: "facet under-filter counts 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) })
hiddenLib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Test Facet Counts Hidden %d", time.Now().UnixNano()),
Description: pgtype.Text{String: "hidden for the regular user", Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
})
require.NoError(t, err)
hiddenUUID, err := uuid.FromBytes(hiddenLib.ID.Bytes[0:16])
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, hiddenLib.ID) })
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
hiddenLib.ID, userID)
require.NoError(t, err)
facetFixtureBooks(t, setup, library.ID, "Counts")
// A book in the HIDDEN library: distinctive values that must never
// surface for the regular user.
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: hiddenLib.ID,
Title: "Counts Hidden Book",
Authors: []string{"Counts Secret Author"},
Genre: pgtype.Text{String: "cryptobotany", Valid: true},
Publishers: []string{"Shadowpress"},
FilePath: fmt.Sprintf("/tmp/facet-counts/hidden-%d.epub", time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
// No filters: the v1 global-per-library counts.
status, counts := facetCountsGet(t, setup, url.Values{"library_id": {libUUID.String()}})
require.Equal(t, http.StatusOK, status)
requireFacetCount(t, counts["genre"], "fantasy", 2)
requireFacetCount(t, counts["genre"], "history", 1)
requireFacetCount(t, counts["publisher"], "Pinecrest", 2)
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
// Under genre=fantasy: the genre facet STILL lists everything with
// unfiltered counts (its own block is omitted from its arm), while
// the publisher facet counts only within fantasy.
status, counts = facetCountsGet(t, setup, url.Values{
"library_id": {libUUID.String()},
"genre_filter": {"fantasy"},
})
require.Equal(t, http.StatusOK, status)
requireFacetCount(t, counts["genre"], "fantasy", 2)
requireFacetCount(t, counts["genre"], "history", 1)
requireFacetCount(t, counts["publisher"], "Pinecrest", 1)
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
// OR-within: both genres → Pinecrest regains Alpha (fantasy) and
// Gamma (history).
status, counts = facetCountsGet(t, setup, url.Values{
"library_id": {libUUID.String()},
"genre_filter": {"fantasy,history"},
})
require.Equal(t, http.StatusOK, status)
requireFacetCount(t, counts["publisher"], "Pinecrest", 2)
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
// AND-across: genre=history + publisher=Pinecrest → the genre facet
// is unfiltered by ITSELF (its own block is omitted) but still
// counts under the active publisher filter: both Pinecrest books
// (Alpha fantasy, Gamma history) surface as 1 each, Beta (Quartzmill)
// is filtered away. The publisher facet sees only history books, so
// Pinecrest counts Gamma alone.
status, counts = facetCountsGet(t, setup, url.Values{
"library_id": {libUUID.String()},
"genre_filter": {"history"},
"publisher_filter": {"Pinecrest"},
})
require.Equal(t, http.StatusOK, status)
requireFacetCount(t, counts["genre"], "fantasy", 1)
requireFacetCount(t, counts["publisher"], "Pinecrest", 1)
// All-Libraries view (no library_id): the visible library's values
// are present, the hidden library's are absent everywhere.
status, counts = facetCountsGet(t, setup, url.Values{})
require.Equal(t, http.StatusOK, status)
requireFacetCount(t, counts["genre"], "fantasy", 2)
for _, v := range counts["genre"] {
require.NotEqual(t, "cryptobotany", v.Value, "hidden-library genre must be absent")
}
for _, v := range counts["publisher"] {
require.NotEqual(t, "Shadowpress", v.Value, "hidden-library publisher must be absent")
}
// Family lens: an explicit HIDDEN library_id on the new shape is a
// 403 (AGENTS standing rule — never a silent pass).
status, _ = facetCountsGet(t, setup, url.Values{"library_id": {hiddenUUID.String()}})
require.Equal(t, http.StatusForbidden, status)
// The pinned single-facet shape is untouched by the extension.
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?facet=genre&library_id="+libUUID.String(), 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 single struct {
Facet string `json:"facet"`
Values []facetCountValue `json:"values"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&single))
require.Equal(t, "genre", single.Facet)
requireFacetCount(t, single.Values, "fantasy", 2)
requireFacetCount(t, single.Values, "history", 1)
}
// TestFieldValuesAllLibraries pins the All-Books suggestion path
// (filter-plan Phase 8): the field-values autocomplete treats
// library_id as OPTIONAL — absent aggregates every library VISIBLE to
// the caller (the per-user family lens is enforced inside the query),
// and an explicit out-of-set library_id is a 403. This is the
// regression net for the hard "library_id is required" 400 that broke
// typing in the filter drawer on the All-Books view.
func TestFieldValuesAllLibraries(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) database.Libraries {
t.Helper()
lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
Name: fmt.Sprintf("Test Field Values %s %d", name, time.Now().UnixNano()),
Description: pgtype.Text{String: "field values all-libraries test library", Valid: true},
LibraryTypeID: libType.ID,
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
})
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
return lib
}
visibleLib := makeLib("Visible")
hiddenLib := makeLib("Hidden")
hiddenUUID, err := uuid.FromBytes(hiddenLib.ID.Bytes[0:16])
require.NoError(t, err)
// One book per library with IDENTICAL distinctive values — the
// All-Books answer must aggregate across both libraries, and every
// value is trigram-disjoint from any real data ("Verdana…" nonsense).
for _, lib := range []struct {
id pgtype.UUID
name string
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: lib.id,
Title: fmt.Sprintf("Field Values %s Book", lib.name),
Authors: []string{"Verdana Scribe"},
Genre: pgtype.Text{String: "verdanafantasy", Valid: true},
Series: pgtype.Text{String: "Verdana Saga", Valid: true},
Language: pgtype.Text{String: "verdanish", Valid: true},
FilePath: fmt.Sprintf("/tmp/field-values/%s-%d.epub", lib.name, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
}
fieldValuesGet := func(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 "+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
}
findValue := func(results []map[string]interface{}, value string) (found bool, count float64) {
t.Helper()
for _, r := range results {
if r["value"] == value {
found = true
count, _ = r["count"].(float64)
}
}
return found, count
}
// Phase A — NO library_id (the All-Books view): 200, and the same
// author surfaces ONCE with count 2 — values aggregate across every
// visible library. The old bug answered 400 here.
status, results := fieldValuesGet(url.Values{"author": {"Verdana"}})
require.Equal(t, http.StatusOK, status, "no library_id must be 200, not the old hard 400")
found, count := findValue(results, "Verdana Scribe")
require.True(t, found, "author value present on the All-Books path")
require.Equal(t, float64(2), count, "value aggregated across both visible libraries")
for field, value := range map[string]string{
"genre": "verdanafantasy", "series": "Verdana Saga", "language": "verdanish",
} {
status, results = fieldValuesGet(url.Values{field: {value}})
require.Equal(t, http.StatusOK, status, "%s autocomplete with no library_id", field)
found, count = findValue(results, value)
require.True(t, found, "%s value present on the All-Books path", field)
require.Equal(t, float64(2), count, "%s aggregated across both libraries", field)
}
// Phase B — family lens on the same All-Books path: hiding one
// library from this user halves the counts without any library_id
// in the request at all.
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
hiddenLib.ID, userID)
require.NoError(t, err)
status, results = fieldValuesGet(url.Values{"author": {"Verdana"}})
require.Equal(t, http.StatusOK, status)
found, count = findValue(results, "Verdana Scribe")
require.True(t, found)
require.Equal(t, float64(1), count, "hidden library's books leave the All-Books suggestions")
// Phase C — an explicit out-of-set library_id is a 403, never a
// silent pass (AGENTS family-lens standing rule).
status, _ = fieldValuesGet(url.Values{"author": {"Verdana"}, "library_id": {hiddenUUID.String()}})
require.Equal(t, http.StatusForbidden, status)
status, _ = fieldValuesGet(url.Values{"author": {"Verdana"}, "library_id": {uuid.New().String()}})
require.Equal(t, http.StatusForbidden, status)
}
+790
View File
@@ -0,0 +1,790 @@
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")
// BROWSE (the user's click-the-box case): a blank typed query lists
// the field's top values UNDER the other rules — "assign an author,
// click publisher, see their publishers" without typing anything.
// The wire sends a single space as the sentinel.
status, results = suggest("genre", " ", qfAll(qfRule("author", "contains", "Quill")))
require.Equal(t, http.StatusOK, status)
assert.Contains(t, valuesOf(results), "fantasy", "browse shows the field's values under the rules")
assert.NotContains(t, valuesOf(results), "spacewestern", "zero-match values never offered")
assert.NotContains(t, valuesOf(results), "history", "values outside the rules never offered")
// Browse with NO other rules in place: the blank query still lists
// the field's values (the static similarity path would answer a
// single space with nothing). Regression for the empty-browse bug.
status, results = suggest("genre", " ", "")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, valuesOf(results), "fantasy", "browse with no rules lists the field's values")
status, results = suggest("author", " ", "")
require.Equal(t, http.StatusOK, status)
assert.Contains(t, valuesOf(results), "Amara Voss", "author browse with no rules")
// 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")
// BROWSE the library field UNDER rules (the blank-typed click): the
// id-source query must not choke on the unused typed parameter and
// must list exactly the library holding the surviving books.
status, results = suggest("library", " ", qfAll(qfRule("author", "contains", "Voss")))
require.Equal(t, http.StatusOK, status, "library browse under rules (blank typed)")
require.Len(t, results, 1, "exactly the fixture library survives the rules")
assert.Equal(t, libUUID.String(), results[0]["value"])
assert.InDelta(t, float64(2), results[0]["count"], 0.001, "both Voss books live there")
// Series browse (blank typed, under rules): full-membership source.
status, results = suggest("series", " ", qfAll(qfRule("author", "contains", "Voss")))
require.Equal(t, http.StatusOK, status)
assert.Contains(t, valuesOf(results), "Voss Saga")
// Added-by browse (blank typed): books without added_by → an empty
// menu, but NO error (the parameter-discipline regression net).
status, results = suggest("added_by", " ", "")
require.Equal(t, http.StatusOK, status)
// Collection browse (blank typed): the container sources ride the
// same id-source query — a collection holding a surviving book is
// offered with the surviving count; zero-match containers never are.
userA := getRegularUserID(t, setup.DB)
var collID pgtype.UUID
err = setup.DBPool.QueryRow(ctx,
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
require.NoError(t, err)
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
var tideID pgtype.UUID
err = setup.DBPool.QueryRow(ctx, `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
require.NoError(t, err)
status, results = suggest("collection", " ", "")
require.Equal(t, http.StatusOK, status)
assert.True(t, func() bool {
for _, r := range results {
if r["value"] == collID.String() {
return true
}
}
return false
}(), "the collection holding a book is offered on browse")
status, results = suggest("collection", " ", qfAll(qfRule("genre", "is", "spacewestern")))
require.Equal(t, http.StatusOK, status)
assert.Empty(t, results, "zero-match containers never 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))
}
+5 -3
View File
@@ -88,7 +88,7 @@ func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, l
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Ownership Suite Item",
Author: pgtype.Text{String: "Suite Author", Valid: true},
Authors: []string{"Suite Author"},
FilePath: relPath,
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
@@ -183,8 +183,10 @@ func (s *OwnershipAndAdminTestSuite) TestHighlightDelete_CrossUserNoOp() {
// create accounts even with self-service registration disabled, regular
// users cannot, and duplicates conflict.
func (s *OwnershipAndAdminTestSuite) TestAdminCreateUser() {
suffix := fmt.Sprintf("%d", s.setup.DBPool.Config().MaxConns) // stable-ish uniquifier
unique := fmt.Sprintf("admincreated%s", suffix)
// Real unique suffix: MaxConns is forced to 1 by the harness, so the old
// "stable-ish" uniquifier produced the same user every run and only
// survived on the setup-time cleanup sweep of the NEXT run.
unique := fmt.Sprintf("admincreated-%s", uuid.New().String()[:8])
// Regular user token → 403 (AdminMiddleware).
payload, _ := json.Marshal(map[string]interface{}{
+187
View File
@@ -0,0 +1,187 @@
package main
// Phase 13 (faceted plan): force rescan = original-scan semantics, and
// the manually_edited filter.
//
// Force rescan contract: re-review every field on every book from all
// sources (sidecars → folder structure → tags → fallbacks) on multi-
// file books too — the pre-Phase-13 skip is gone — with
// metadata_overrides as the only protection. A force pass also stamps
// the fresh manifest with the current parser version (success-only)
// and closes the book's open ledger issues.
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"bookhoard/internal/database"
"bookhoard/internal/services"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// pinExtractorVersionOne pins the SHARED dev DB's extractor version to
// the default 1 for the test's duration. Prior interrupted runs can
// leave it bumped (an early test failure aborts before its restore
// cleanup registers), and every stamp assertion in these tests assumes
// the default. Restore-to-prior rides inside setExtractorVersion: the
// suite leaves the setting exactly as it found it.
func pinExtractorVersionOne(t *testing.T, setup *TestServerSetup) {
t.Helper()
setExtractorVersion(t, setup, 1)
}
// forceScanTestFolder runs a FORCE scan over one folder.
func forceScanTestFolder(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folder string) {
t.Helper()
scanner := services.NewMediaScanner(setup.DB)
defer func() { _ = scanner.Close() }()
scanner.SetAdminID(adminID)
require.NoError(t, scanner.SetFolders([]string{folder}, false))
scanner.SetForce(true)
require.NoError(t, scanner.ScanFolders(context.Background()))
}
func TestForceRescanMultifileSidecarApply(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
pinExtractorVersionOne(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Force Rescan %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Force Author", "Force Title", "01 First.mp3"))
writeTestTrack(t, filepath.Join(folder, "Force Author", "Force Title", "02 Second.mp3"))
scanTestFolder(t, setup, admin, folder)
rel := filepath.ToSlash("Force Author/Force Title")
book, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found, "folder book must import")
require.Equal(t, "Force Title", book.Title)
// Corrupt, then FORCE: the pre-Phase-13 skip is gone — the full
// chain re-derives the title from the folder basename.
corruptItemColumn(t, setup, book.ID, "title", "ZZZ")
forceScanTestFolder(t, setup, admin, folder)
book, found = findAudioItemByPath(t, setup, libID, rel)
require.True(t, found)
require.Equal(t, "Force Title", book.Title, "force rescan must re-apply folder-book metadata")
require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "force rescan stamps the current parser version")
// metadata_overrides is the ONLY protection: a recorded override
// survives the force pass untouched.
_, err := setup.DBPool.Exec(ctx,
`UPDATE media_items SET metadata_overrides = ARRAY['title'], title = 'Manual Title' WHERE id = $1`,
book.ID)
require.NoError(t, err)
forceScanTestFolder(t, setup, admin, folder)
book, _ = findAudioItemByPath(t, setup, libID, rel)
require.Equal(t, "Manual Title", book.Title, "metadata_overrides protect manual edits under force rescan")
}
func TestForceRescanSingleFileSidecarReapply(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
pinExtractorVersionOne(t, setup)
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Force Single %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
bookDir := filepath.Join(folder, "Force Probe Author", "Force Probe Book")
require.NoError(t, os.MkdirAll(bookDir, 0o755))
opf := `<?xml version='1.0' encoding='utf-8'?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Force Probe Book</dc:title>
<dc:creator>Force Probe Author</dc:creator>
</metadata>
</package>`
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "metadata.opf"), []byte(opf), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "Force Probe Book.epub"), []byte("dummy epub content"), 0o644))
scanTestFolder(t, setup, admin, folder)
rel := filepath.ToSlash("Force Probe Author/Force Probe Book/Force Probe Book.epub")
book, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found)
// Single-file force rescan already ran the full chain — pin it, and
// pin the NEW part: the manifest comes out stamped.
corruptItemColumn(t, setup, book.ID, "title", "ZZZ")
forceScanTestFolder(t, setup, admin, folder)
book, found = findAudioItemByPath(t, setup, libID, rel)
require.True(t, found)
require.Equal(t, "Force Probe Book", book.Title, "force rescan re-derives sidecar metadata (single-file)")
require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "force rescan stamps the manifest")
}
func TestManuallyEditedFilter(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
libID, _ := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Manual Edits %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
createBook := func(path string) pgtype.UUID {
created, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libID,
Title: path,
Authors: []string{},
FilePath: path,
FileSize: pgtype.Int8{Int64: 64, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
return created.ID
}
editedID := createBook("edited-book.epub")
cleanID := createBook("clean-book.epub")
_, err := setup.DBPool.Exec(ctx,
`UPDATE media_items SET metadata_overrides = ARRAY['title'] WHERE id = $1`,
editedID)
require.NoError(t, err)
qfIDs := func(op string) []string {
status, rows := qfSearch(t, setup, setup.Token, map[string][]string{
"qf": {qfAll(qfRule("manually_edited", op))},
"library_id": {libID.String()},
})
require.Equal(t, http.StatusOK, status)
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r["id"].(string))
}
return ids
}
trueIDs := qfIDs("is_true")
require.Len(t, trueIDs, 1)
assert.Equal(t, editedID.String(), trueIDs[0], "is_true matches only manually-edited books")
falseIDs := qfIDs("is_false")
require.Len(t, falseIDs, 1)
assert.Equal(t, cleanID.String(), falseIDs[0], "is_false matches only untouched books")
// The deep-linkable JSON shape round-trips through the qf parser
// (arity-none rule: no values on the wire).
var parsed struct {
Mode string `json:"mode"`
Children []struct {
Field string `json:"field"`
} `json:"children"`
}
require.NoError(t, json.Unmarshal([]byte(qfAll(qfRule("manually_edited", "is_true"))), &parsed))
require.Len(t, parsed.Children, 1)
assert.Equal(t, "manually_edited", parsed.Children[0].Field)
}
@@ -0,0 +1,191 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
)
// A2c end-to-end: an ABS sidecar whose series array mixes a real series and
// an ordering construct must (a) land the real series on the item, (b)
// auto-create a curated playlist collection for the ordering construct with
// provenance + position, (c) survive a rescan without duplicates, (d)
// support owner-only reorder, and (e) filter finished books for Up Next /
// hide_finished with a completion marker when everything is read.
func TestPlaylistCollectionsEndToEnd(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
adminID, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Playlist %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// The user's exact Witcher shape: real series + reading-order construct.
itemDir := filepath.Join(folder, "Tag Author", "The Tagged Album")
fixtureAudio(t, "tagged.mp3", filepath.Join(itemDir, "tagged.mp3"))
sidecar := `{
"title": "The Tagged Album",
"authors": ["Tag Author"],
"series": ["The Tagged Saga #2", "The Tagged Saga (Reading Order) #7"]
}`
require.NoError(t, os.WriteFile(filepath.Join(itemDir, "metadata.json"), []byte(sidecar), 0644))
scanTestFolder(t, setup, adminID, folder)
// (a) The item carries the REAL series only; the reading-order entry
// never touches the series columns.
var series string
var seriesNum float64
var foundPath string
err := setup.DBPool.QueryRow(context.Background(),
`SELECT series, series_number, file_path FROM media_items WHERE title = 'The Tagged Album'`,
).Scan(&series, &seriesNum, &foundPath)
if err != nil {
var paths []string
rows, qerr := setup.DBPool.Query(context.Background(),
`SELECT file_path FROM media_items WHERE library_id = $1`, libID)
if qerr == nil {
for rows.Next() {
var p string
_ = rows.Scan(&p)
paths = append(paths, p)
}
rows.Close()
}
t.Fatalf("item not found by title (paths in library: %v): %v", paths, err)
}
require.Equal(t, "The Tagged Saga", series)
require.Equal(t, 2.0, seriesNum)
require.NotEmpty(t, foundPath)
// (b) The playlist collection exists with provenance, dashboard pin,
// and the item at reading position 7.
adminUUIDStr := adminID.String()
var collID uuid.UUID
var collName, collDesc string
var showOnDashboard bool
var viewSettingsRaw []byte
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT id, name, description, show_on_dashboard, view_settings
FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw))
require.True(t, showOnDashboard, "playlists are dashboard-eligible by design")
require.Contains(t, string(viewSettingsRaw), "imported_from_series")
require.Contains(t, string(viewSettingsRaw), "show_up_next")
require.Contains(t, collDesc, "safe to edit or delete")
_ = adminUUIDStr
var position *int32
var itemCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT ci.position, COUNT(*) OVER () FROM collection_items ci
WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount))
require.Equal(t, 1, itemCount)
require.NotNil(t, position)
require.Equal(t, int32(7), *position)
_ = adminUUIDStr
// (c) Rescan: no duplicate collection, no duplicate membership.
scanTestFolder(t, setup, adminID, folder)
var collectionCount, membershipCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
adminID).Scan(&collectionCount))
require.Equal(t, 1, collectionCount)
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount))
require.Equal(t, 1, membershipCount)
// (d) Owner-only reorder: positions become 1..n; a non-owner gets 403.
var itemID uuid.UUID
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT id FROM media_items WHERE title = 'The Tagged Album'`).Scan(&itemID))
reorderURL := fmt.Sprintf("%s/api/collections/%s/items/order", setup.Server.URL, collID.String())
body, _ := json.Marshal(map[string][]string{"item_ids": {itemID.String()}})
req, _ := http.NewRequest("PUT", reorderURL, bytes.NewBuffer(body))
req.Header.Set("Authorization", "Bearer "+setup.Token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
_ = resp.Body.Close()
regularToken := setup.RegularToken
req2, _ := http.NewRequest("PUT", reorderURL, bytes.NewBuffer(body))
req2.Header.Set("Authorization", "Bearer "+regularToken)
req2.Header.Set("Content-Type", "application/json")
resp2, err := http.DefaultClient.Do(req2)
require.NoError(t, err)
require.Equal(t, http.StatusForbidden, resp2.StatusCode)
_ = resp2.Body.Close()
// (e) hide_finished + Up Next + completion marker.
// Flip the collection's flag on, mark the single item finished for the
// admin, and the dashboard section becomes Complete with no items.
_, err = setup.DBPool.Exec(context.Background(),
`UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID)
require.NoError(t, err)
_, err = setup.DBPool.Exec(context.Background(),
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
VALUES ($1, $2, 1.0, NOW()) ON CONFLICT (media_item_id, user_id) DO UPDATE SET percentage = 1.0`,
itemID, adminID)
require.NoError(t, err)
sectionsURL := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", setup.Server.URL, libID.String())
req3, _ := http.NewRequest("GET", sectionsURL, nil)
req3.Header.Set("Authorization", "Bearer "+setup.Token)
resp3, err := http.DefaultClient.Do(req3)
require.NoError(t, err)
defer func() { _ = resp3.Body.Close() }()
require.Equal(t, http.StatusOK, resp3.StatusCode)
var payload struct {
Sections []struct {
CollectionID string `json:"collection_id"`
Complete bool `json:"complete"`
IsPlaylist bool `json:"is_playlist"`
Items []map[string]any `json:"items"`
} `json:"sections"`
}
require.NoError(t, json.NewDecoder(resp3.Body).Decode(&payload))
var found bool
for _, s := range payload.Sections {
if s.CollectionID == collID.String() {
found = true
if !s.Complete {
b, _ := json.Marshal(s)
t.Fatalf("section found but not complete: %s", b)
}
require.True(t, s.IsPlaylist)
require.Empty(t, s.Items, "finished items must not show under hide_finished")
}
}
if !found {
b, _ := json.Marshal(payload.Sections)
t.Fatalf("playlist section missing; sections = %s", b)
}
require.True(t, found, "the playlist section must appear on the owner's dashboard")
// Up Next on the collection detail: the finished queue is empty.
detailURL := fmt.Sprintf("%s/api/collections/%s", setup.Server.URL, collID.String())
req4, _ := http.NewRequest("GET", detailURL, nil)
req4.Header.Set("Authorization", "Bearer "+setup.Token)
resp4, err := http.DefaultClient.Do(req4)
require.NoError(t, err)
defer func() { _ = resp4.Body.Close() }()
var detail struct {
UpNext []map[string]any `json:"up_next"`
}
require.NoError(t, json.NewDecoder(resp4.Body).Decode(&detail))
require.Empty(t, detail.UpNext, "nothing is up next once everything is finished")
}
+177 -4
View File
@@ -1,12 +1,22 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"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"
)
@@ -119,8 +129,8 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
{
name: "Nonexistent library UUID",
libraryID: "00000000-0000-0000-0000-000000000000",
expectedStatus: http.StatusOK,
description: "Should return empty array for valid but nonexistent UUID",
expectedStatus: http.StatusForbidden,
description: "Family lens: an id outside the visible set is a 403, never a silent pass (nonexistent is trivially out-of-set)",
},
{
name: "Special characters in UUID",
@@ -293,8 +303,8 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
{
name: "Nonexistent library UUID",
libraryID: "00000000-0000-0000-0000-000000000000",
expectedStatus: http.StatusOK,
description: "Should return zero counts for valid but nonexistent UUID",
expectedStatus: http.StatusForbidden,
description: "Family lens: an id outside the visible set is a 403, never a silent pass (nonexistent is trivially out-of-set)",
},
{
name: "Special characters in UUID",
@@ -513,3 +523,166 @@ func TestProcessingIssuesInvalidTokens(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})
}
// ============================================================================
// Phase 11 (faceted plan): the honest ledger — row-less import failures,
// truthful upserts, self-healing resolution, family lens.
// ============================================================================
// createTestAdminOnce creates a fresh admin user (never the shared
// testuser) with unique credentials and registers its cleanup.
func createTestAdminOnce(t *testing.T, db *database.Queries, ts *httptest.Server) (UserTestData, string) {
t.Helper()
ctx := context.Background()
uniqueID := uuid.New().String()[:8]
email := fmt.Sprintf("issuesadmin-%s@tests.bookhoard.internal", uniqueID)
username := fmt.Sprintf("issuesadmin-%s", uniqueID)
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
newUser, err := db.CreateUser(ctx, database.CreateUserParams{
Email: email,
Username: username,
PasswordHash: passwordHash,
FirstName: pgtype.Text{String: "Issues", Valid: true},
LastName: pgtype.Text{String: "Admin", Valid: true},
Role: "admin",
Timezone: pgtype.Text{String: "UTC", Valid: true},
})
require.NoError(t, err, "Should create admin test user")
userUUID, err := uuid.FromBytes(newUser.ID.Bytes[0:16])
require.NoError(t, err, "Should parse admin user UUID")
t.Cleanup(func() {
db.DeleteUser(context.Background(), pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true})
})
user := UserTestData{ID: userUUID, Email: email, Username: username, Password: "Test@Pass123!"}
token := loginWithCredentials(t, ts, email, "Test@Pass123!")
return user, token
}
func TestProcessingIssuesLedgerImportFailures(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Issues Ledger %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// A book whose library-relative path exceeds media_items.file_path's
// VARCHAR(500): CreateMediaItem fails deterministically, and the
// relative path itself cannot ever import — the classic row-less
// ledger case. Filename and dir components stay within filesystem
// limits; only the total path overflows.
longRel := ""
for i := 0; i < 9; i++ {
longRel = filepath.Join(longRel, strings.Repeat("d", 60))
}
longRel = filepath.Join(longRel, "deepbook.epub")
require.Greater(t, len(longRel), 500, "relative path must exceed the column limit")
deepDir := filepath.Join(folder, filepath.Dir(longRel))
require.NoError(t, os.MkdirAll(deepDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(folder, longRel), []byte("dummy epub content"), 0o644))
scanTestFolder(t, setup, admin, folder)
// Fail → the row-less issue appears, keyed by (library, path).
open := func() []database.ListProcessingIssuesByLibraryRow {
rows, err := setup.DB.ListProcessingIssuesByLibrary(ctx, libID)
require.NoError(t, err)
return rows
}
issues := open()
require.Len(t, issues, 1, "the failed import must appear in the ledger")
issue := issues[0]
require.Equal(t, "import_failed", issue.IssueType)
require.Equal(t, "error", issue.Severity)
require.False(t, issue.MediaItemID.Valid, "row-less: no media_items row exists")
require.Equal(t, filepath.ToSlash(longRel), issue.FilePath, "row-less key is the library-relative path")
require.Empty(t, issue.Title, "no item row → no title")
// The JSON list surfaces the row-less shape: empty item id, title
// fallen back to the path (pins the LEFT JOIN — an inner join would
// silently drop the ledger's newest kind).
req := httptest.NewRequest("GET", "/api/libraries/"+libID.String()+"/issues/list", nil)
req.Header.Set("Authorization", "Bearer "+setup.Token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var listed []map[string]interface{}
require.NoError(t, json.NewDecoder(rec.Body).Decode(&listed))
require.Len(t, listed, 1)
assert.Equal(t, "", listed[0]["media_item_id"], "row-less issues carry no item id")
assert.Equal(t, filepath.ToSlash(longRel), listed[0]["title"], "title falls back to the file path")
assert.Equal(t, "import_failed", listed[0]["issue_type"])
// Repeat failure → the SAME row updates (no dups).
scanTestFolder(t, setup, admin, folder)
issues = open()
require.Len(t, issues, 1, "repeat failures upsert, never duplicate")
require.Equal(t, issue.ID, issues[0].ID)
// Success resolves by (library, path): a normal book imports at a
// path that has an open row-less issue — seeded here to model the
// transient failure the long path can't have (its path IS the
// failure).
goodRel := "Author/ledgerbook.epub"
_, err := setup.DBPool.Exec(ctx,
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
VALUES (NULL, $1, 'import_failed', 'seeded transient failure', 'error', $2)`,
libID, goodRel)
require.NoError(t, err)
require.NoError(t, os.MkdirAll(filepath.Join(folder, "Author"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(folder, "Author", "ledgerbook.epub"), []byte("dummy epub content"), 0o644))
scanTestFolder(t, setup, admin, folder)
var resolved int
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT count(*) FROM processing_issues WHERE library_id = $1 AND file_path = $2 AND resolved AND resolved_at IS NOT NULL`,
libID, goodRel).Scan(&resolved))
require.Equal(t, 1, resolved, "the file's successful import must resolve its open issue")
require.Len(t, open(), 1, "only the long-path issue stays open")
}
func TestProcessingIssuesFamilyLens(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
libID, _ := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Issues Lens %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// A fresh admin (never the shared testuser) + a hidden-library row:
// default-ALLOW visibility with one explicit hide.
lensAdmin, lensToken := createTestAdminOnce(t, setup.DB, setup.Server)
_, err := setup.DBPool.Exec(ctx,
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
libID, pgtype.UUID{Bytes: [16]byte(lensAdmin.ID), Valid: true})
require.NoError(t, err)
// An open issue in that library (row-less, no item to cascade).
_, err = setup.DBPool.Exec(ctx,
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
VALUES (NULL, $1, 'import_failed', 'lens probe', 'error', 'hidden/book.epub')`,
libID)
require.NoError(t, err)
get := func(token, suffix string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", "/api/libraries/"+libID.String()+"/issues/"+suffix, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
setup.Server.Config.Handler.ServeHTTP(rec, req)
return rec
}
// Hidden from the fresh admin → 403 on both endpoints (never silent).
assert.Equal(t, http.StatusForbidden, get(lensToken, "list").Code, "hidden library list is a 403")
assert.Equal(t, http.StatusForbidden, get(lensToken, "stats").Code, "hidden library stats are a 403")
// Visible to the shared admin (no hide row) → the rows show.
assert.Equal(t, http.StatusOK, get(setup.Token, "list").Code)
assert.Equal(t, http.StatusOK, get(setup.Token, "stats").Code)
}
+1 -1
View File
@@ -51,7 +51,7 @@ func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: library.ID,
Title: "Test Book for Queue",
Author: pgtype.Text{String: "Test Author", Valid: true},
Authors: []string{"Test Author"},
FilePath: "/tmp/test.epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+232
View File
@@ -0,0 +1,232 @@
package main
// Bulk scan-state tests (faceted plan Part 2 Phase 3): the scanner's
// one-query-per-library bulk load replaces the per-file
// GetMediaItemByFilePath flood. Behavioral equivalence asserted at the
// container level: unchanged rescans skip (updated_at stable), items
// created OUTSIDE the scanner (API create path) are seen by the bulk
// load, and size changes still take the update path.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestScanStateBulkLoad(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Scan State %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// Two scanner-created fixture books.
epubA := filepath.Join(folder, "Author A", "State Book A", "book.epub")
require.NoError(t, os.MkdirAll(filepath.Dir(epubA), 0755))
require.NoError(t, os.WriteFile(epubA, []byte("state book A content"), 0644))
epubB := filepath.Join(folder, "Author B", "State Book B", "book.epub")
require.NoError(t, os.MkdirAll(filepath.Dir(epubB), 0755))
require.NoError(t, os.WriteFile(epubB, []byte("state book B content"), 0644))
scanTestFolder(t, setup, admin, folder)
items := listLibraryAudioItems(t, setup, libID)
require.Len(t, items, 2, "both books ingested")
var updatedA time.Time
for _, item := range items {
switch item.Title {
case "State Book A":
updatedA = item.UpdatedAt.Time
case "State Book B":
default:
t.Errorf("unexpected item %q", item.Title)
}
}
require.False(t, updatedA.IsZero())
// Unchanged rescan: the skip path must not touch the rows.
scanTestFolder(t, setup, admin, folder)
items = listLibraryAudioItems(t, setup, libID)
require.Len(t, items, 2, "no duplicates on rescan")
for _, item := range items {
if item.Title == "State Book A" {
assert.Equal(t, updatedA, item.UpdatedAt.Time, "unchanged rescan leaves updated_at stable")
}
}
// A row created by the API create path (NOT by a scan) must be seen
// by the bulk load: the rescan skips it instead of re-creating. The
// file must really exist (the archive pass missing-checks rows
// whose files are absent).
apiFile := filepath.Join(folder, "Author C", "API Made Book", "book.epub")
require.NoError(t, os.MkdirAll(filepath.Dir(apiFile), 0755))
require.NoError(t, os.WriteFile(apiFile, []byte("api made book content"), 0644))
apiStat, err := os.Stat(apiFile)
require.NoError(t, err)
created, err := setup.DB.CreateMediaItem(context.Background(), database.CreateMediaItemParams{
LibraryID: libID,
Title: "API Made Book",
Authors: []string{"API Author"},
FilePath: "Author C/API Made Book/book.epub",
FileSize: pgtype.Int8{Int64: apiStat.Size(), Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
})
require.NoError(t, err)
// Stamp the file's real sha the way the scanner does — otherwise the
// skip path's one-time hash self-heal fires and bumps updated_at.
// The identifiers UPDATE also bumps updated_at, so read the row
// AFTER the stamp for the baseline.
apiSha := sha256.Sum256([]byte("api made book content"))
stamped, err := setup.DB.UpdateMediaItemIdentifiers(context.Background(), database.UpdateMediaItemIdentifiersParams{
ID: created.ID,
FileSha256: pgtype.Text{String: hex.EncodeToString(apiSha[:]), Valid: true},
})
require.NoError(t, err)
apiUpdated := stamped.UpdatedAt.Time
scanTestFolder(t, setup, admin, folder)
items = listLibraryAudioItems(t, setup, libID)
require.Len(t, items, 3, "API-created item survives the rescan as-is")
for _, item := range items {
if item.Title == "API Made Book" {
assert.Equal(t, apiUpdated, item.UpdatedAt.Time, "bulk-loaded state sees scanner-external rows; skip path leaves them alone")
}
}
// Size change: the update path still fires (with a fresh full-row
// read + hash recompute, then a write-through of the new size).
require.NoError(t, os.WriteFile(epubA, []byte("state book A content — now longer"), 0644))
scanTestFolder(t, setup, admin, folder)
items = listLibraryAudioItems(t, setup, libID)
require.Len(t, items, 3, "size change updates in place, never duplicates")
for _, item := range items {
if item.Title == "State Book A" {
assert.NotEqual(t, updatedA, item.UpdatedAt.Time, "size change takes the update path")
}
}
}
// TestScanManifestLifecycle exercises the manifest-gated change ladder
// end to end (faceted plan Part 2 Phase 4): seeding at create, repoint
// reconciliation (mtime drift, identical content → no re-extraction),
// same-size sidecar edits (the size-only detection blind spot),
// playlist sync on the change path, and the no-leak guard.
func TestScanManifestLifecycle(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
admin, _ := moveTestAdminID(t, setup)
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Manifest Lifecycle %d", time.Now().UnixNano()), "ebooks")
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
// The book ships WITH an ABS sidecar: the create path seeds the
// manifest so the next scan compares instead of backfilling.
bookDir := filepath.Join(folder, "Manifest Author", "Manifest Book")
require.NoError(t, os.MkdirAll(bookDir, 0755))
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "book.epub"), []byte("manifest book content"), 0644))
opfPath := filepath.Join(bookDir, "metadata.json")
require.NoError(t, os.WriteFile(opfPath, []byte(`{"title":"Manifest Book"}`), 0644))
scanTestFolder(t, setup, admin, folder)
var itemID pgtype.UUID
var itemIDStr, updatedStr string
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT id::text, updated_at::text FROM media_items WHERE title = 'Manifest Book' AND library_id = $1`,
libID,
).Scan(&itemIDStr, &updatedStr))
require.NoError(t, itemID.Scan(itemIDStr))
// Seeded manifest exists.
seeded, err := setup.DB.GetMediaScanState(context.Background(), itemID)
require.NoError(t, err, "create path must seed the manifest")
require.NotEmpty(t, seeded.Manifest)
// Unchanged rescan: skip path, nothing moves.
scanTestFolder(t, setup, admin, folder)
var updated2 string
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT updated_at::text FROM media_items WHERE id = $1`, itemID).Scan(&updated2))
assert.Equal(t, updatedStr, updated2, "unchanged rescan is a pure skip")
// Repoint: mtime drift with identical content → reconciled, ZERO
// re-extraction; the stored stat pair refreshes.
past := time.Now().Add(-2 * time.Hour)
require.NoError(t, os.Chtimes(opfPath, past, past))
scanTestFolder(t, setup, admin, folder)
var updated3 string
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT updated_at::text FROM media_items WHERE id = $1`, itemID).Scan(&updated3))
assert.Equal(t, updatedStr, updated3, "repoint reconciliation must not re-extract")
reconciled, err := setup.DB.GetMediaScanState(context.Background(), itemID)
require.NoError(t, err)
var storedManifest struct {
Entries []struct {
Name string `json:"n"`
MTime int64 `json:"m"`
} `json:"entries"`
}
require.NoError(t, json.Unmarshal(reconciled.Manifest, &storedManifest))
var sawRefreshed bool
for _, e := range storedManifest.Entries {
if e.Name == "metadata.json" && e.MTime == past.UnixNano() {
sawRefreshed = true
}
}
assert.True(t, sawRefreshed, "repoint refreshes the stored stat pair")
// Same-size sidecar edit — the size-only detection blind spot: the
// stat pair moves, the hash differs → re-extract consumes the edit.
sameLen := []byte(`{"title":"Manifesu Book"}`)
require.Len(t, sameLen, len(`{"title":"Manifest Book"}`), "fixture must be a same-size edit")
require.NoError(t, os.WriteFile(opfPath, sameLen, 0644))
scanTestFolder(t, setup, admin, folder)
var title4 string
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title4))
assert.Equal(t, "Manifesu Book", title4, "same-size sidecar edit is detected and consumed")
// Playlist sync on the change path (Correction 2): the sidecar gains
// a reading-order series → the collection is created for an
// EXISTING book, and a rescan does not duplicate it.
require.NoError(t, os.WriteFile(opfPath, []byte(`{"title":"Manifesu Book","series":["Lifecycle Saga (Reading Order) #1"]}`), 0644))
scanTestFolder(t, setup, admin, folder)
var collCount int
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
assert.Equal(t, 1, collCount, "reading-order sidecar added to an existing book creates its collection")
scanTestFolder(t, setup, admin, folder)
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
).Scan(&collCount))
assert.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
// No-leak guard (plan Part 2 test 7): the library-scoped list emits
// raw rows — scanner state must never ride along (structural by
// design; this assertion keeps it honest).
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items?library_id="+libID.String(), nil)
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
assert.NotContains(t, string(body), `"manifest":`, "scanner state must not leak into media list JSON")
assert.NotContains(t, string(body), `"sidecar`, "scanner state must not leak into media list JSON")
}
@@ -0,0 +1,182 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"path/filepath"
"testing"
"time"
"bookhoard/internal/services"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/require"
)
// TestRescanMediaItemPreservesAudioMetadata pins the 2026-10-21 incident:
// a per-item rescan of a multi-file audiobook routes book-level metadata
// (sidecars/tags — AudioMeta nil) through the generic row update, whose
// unguarded write NULLed audio_metadata. A NULL payload drops every
// track/chapter and the duration, so the book displays as 1 second long
// and the audio reader loses its track manifest. Re-extraction must be
// write-after-success: no fresh audio payload from the audio chain means
// the existing column survives byte-for-byte.
func TestRescanMediaItemPreservesAudioMetadata(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Guard %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "01 One.mp3"))
writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "02 Two.mp3"))
scanTestFolder(t, setup, admin, folder)
rel := filepath.ToSlash("Guard Author/Guard Book")
book, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found, "folder book must import")
audioBytes := func(t *testing.T) []byte {
t.Helper()
var raw []byte
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
return raw
}
before := audioBytes(t)
require.NotNil(t, before, "initial scan must populate audio_metadata")
var payload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(before, &payload), "payload must be valid JSON")
require.Greater(t, payload.DurationSeconds, 0.0, "initial scan must carry a duration")
require.Len(t, payload.Tracks, 2, "initial scan must carry the track manifest")
scanner := services.NewMediaScanner(setup.DB)
defer func() { _ = scanner.Close() }()
scanner.SetAdminID(admin)
require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false), "per-item rescan must succeed")
after := audioBytes(t)
require.NotNil(t, after, "rescan must never NULL audio_metadata (2026-10-21 incident)")
var afterPayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(after, &afterPayload), "surviving payload must stay valid JSON")
require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the rescan")
require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the rescan")
}
// TestMetadataEditorSavePreservesAudioMetadata pins the editor-side half
// of the incident: PUT /api/media-items/:id (the metadata editor) writes
// the FULL row, and audio_metadata has no editor field — an unguarded
// save NULLed every audiobook's payload on a plain metadata edit.
func TestMetadataEditorSavePreservesAudioMetadata(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Editor Guard %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "01 One.mp3"))
writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "02 Two.mp3"))
scanTestFolder(t, setup, admin, folder)
rel := filepath.ToSlash("Editor Author/Editor Book")
book, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found, "folder book must import")
audioBytes := func(t *testing.T) []byte {
t.Helper()
var raw []byte
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
return raw
}
before := audioBytes(t)
require.NotNil(t, before, "initial scan must populate audio_metadata")
// A plain metadata edit through the editor endpoint (admin token —
// the editor is admin-only), no audio fields involved.
editorSave(t, setup, book.ID, map[string]interface{}{
"title": "Editor Book Renamed",
"cover_action": "keep",
})
after := audioBytes(t)
require.NotNil(t, after, "metadata edit must never NULL audio_metadata")
var afterPayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(after, &afterPayload))
require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the edit")
require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the edit")
renamed, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found)
require.Equal(t, "Editor Book Renamed", renamed.Title, "the edit itself must still apply")
}
// TestRescanMediaItemPreservesAudioMetadataSingleFileM4B is the incident
// class verbatim: the failing books were SINGLE-FILE m4b audiobooks
// (audio_metadata without a layout key), which RescanMediaItem routes
// through the generic per-file updater — the same writer whose unguarded
// pass NULLed their payloads. The whole flow must keep the m4b's
// duration and chapters intact.
func TestRescanMediaItemPreservesAudioMetadataSingleFileM4B(t *testing.T) {
setup := setupTestServer(t)
t.Cleanup(func() { _ = setup.Close() })
ctx := context.Background()
admin, _ := moveTestAdminID(t, setup)
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test M4B Guard %d", time.Now().UnixNano()))
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
writeTestTrackM4B(t, filepath.Join(folder, "M4B Guard Author", "M4B Guard Book.m4b"))
scanTestFolder(t, setup, admin, folder)
rel := filepath.ToSlash("M4B Guard Author/M4B Guard Book.m4b")
book, found := findAudioItemByPath(t, setup, libID, rel)
require.True(t, found, "single-file m4b must import")
audioBytes := func(t *testing.T) []byte {
t.Helper()
var raw []byte
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
return raw
}
before := audioBytes(t)
require.NotNil(t, before, "initial scan must populate audio_metadata")
var beforePayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(before, &beforePayload))
require.Greater(t, beforePayload.DurationSeconds, 0.0, "m4b probe must carry a real duration")
scanner := services.NewMediaScanner(setup.DB)
defer func() { _ = scanner.Close() }()
scanner.SetAdminID(admin)
require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false))
after := audioBytes(t)
require.NotNil(t, after, "m4b rescan must never NULL audio_metadata")
var afterPayload services.AudioMetadataPayload
require.NoError(t, json.Unmarshal(after, &afterPayload))
require.Greater(t, afterPayload.DurationSeconds, 0.0, "m4b duration must survive the rescan")
}
// editorSave drives PUT /api/media-items/:id as the admin (the metadata
// editor's endpoint; admin-only per the handler).
func editorSave(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID, fields map[string]interface{}) {
t.Helper()
body, _ := json.Marshal(fields)
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+uuid.UUID(itemID.Bytes).String(), bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "metadata save must succeed")
}
+9 -4
View File
@@ -413,10 +413,15 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
shouldContain: book2ID,
},
{
name: "invalid library_id",
query: "Harry",
libraryID: "00000000-0000-0000-0000-000000000000",
expectedStatus: http.StatusNotFound,
name: "invalid library_id",
query: "Harry",
libraryID: "00000000-0000-0000-0000-000000000000",
// Family-lens standing rule (AGENTS.md): an explicit
// library_id outside the visible set is a 403, never a
// silent pass — and a nonexistent id is out-of-set too,
// so existence is not enumerable. Behavior shipped in
// 7e586df4 (Phase 1); this pin update was missed then.
expectedStatus: http.StatusForbidden,
},
}
+1 -1
View File
@@ -544,7 +544,7 @@ func TestLibraryUsageStats(t *testing.T) {
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
Title: title,
Author: pgtype.Text{String: author, Valid: true},
Authors: []string{author},
Genre: pgtype.Text{String: genre, Valid: true},
FilePath: fmt.Sprintf("/tmp/usage-%s-%d.bin", title, time.Now().UnixNano()),
FileSize: pgtype.Int8{Int64: size, Valid: true},
+1 -1
View File
@@ -95,7 +95,7 @@ func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UU
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libraryID,
Title: "Sync Test Book",
Author: pgtype.Text{String: "Test Author", Valid: true},
Authors: []string{"Test Author"},
FilePath: "/tmp/test.epub",
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
+67 -2
View File
@@ -25,6 +25,7 @@ import (
"github.com/go-playground/validator/v10"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
@@ -181,6 +182,54 @@ func getUploadPath() string {
return "./uploads" // Host path (left side of volume mount)
}
// setSystemSettingRestored writes a system setting through the sanctioned
// API path and registers a cleanup that restores the value the row had
// BEFORE the write. The testuser-experience rule: the suite must leave
// shared settings exactly as it found them — a hardcoded restore would
// silently clobber a deliberate dev bump (e.g. scan_extractor_version
// pinned mid-deploy), and the live dev server obeys this setting on its
// next poll, so what tests write here reaches real scans.
//
// Writes ride the settings API (never raw SQL) because the registry
// caches values and only the API path reloads it. If the row did not
// exist before the write, the test value is left in place and the test
// log says so — there is no sanctioned delete; settings rows that
// matter (scan_extractor_version) always exist.
func setSystemSettingRestored(t *testing.T, setup *TestServerSetup, key, value string) {
t.Helper()
var prior *string
err := setup.DBPool.QueryRow(context.Background(),
`SELECT setting_value FROM system_settings WHERE setting_key = $1`, key).Scan(&prior)
if err != nil && err != pgx.ErrNoRows {
require.NoError(t, err, "failed to read prior value of %s", key)
}
setSettingViaAPI(t, setup, key, value)
if prior == nil {
t.Logf("setSystemSettingRestored: %s had no prior row; leaving %q in place after the test", key, value)
return
}
priorValue := *prior
t.Cleanup(func() {
setSettingViaAPI(t, setup, key, priorValue)
})
}
// setSettingViaAPI PUTs one setting through /api/system/settings — the
// only sanctioned write path (the settings registry reloads here).
func setSettingViaAPI(t *testing.T, setup *TestServerSetup, key, value string) {
t.Helper()
body, _ := json.Marshal(map[string]string{"key": key, "value": value})
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/system/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+setup.Token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "settings API is the only sanctioned write path")
}
// getCachePath returns the appropriate cache path based on runtime environment
func getCachePath() string {
// Check for explicit override first
@@ -467,6 +516,17 @@ func setupTestServer(t *testing.T) *TestServerSetup {
queries := database.New(dbPool)
// Wire the tunable settings registry exactly like main.go: Load
// populates the cache (missing rows fall back to compiled defaults)
// and the SystemSettingsHandler must hold it or every
// /api/system/settings call 503s ("settings registry not
// initialized") — tests that write settings through the API (the
// only sanctioned path) need this wired.
settingsRegistry := database.NewSettingsRegistry(queries)
if err := settingsRegistry.Load(context.Background()); err != nil {
t.Logf("settings registry Load failed (defaults apply): %v", err)
}
// Create login attempt tracker
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
@@ -492,7 +552,12 @@ func setupTestServer(t *testing.T) *TestServerSetup {
conflictHandler := handlers.NewConflictHandler(queries, connManager, progressService)
analyticsHandler := handlers.NewAnalyticsHandler(queries)
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
// main.go:112 wires the registry into the auth handler too — without it
// defaultTimezone() short-circuits to UTC and every new user (Register +
// AdminCreateUser) ignores the system default_timezone setting in tests.
authHandler.SetSettings(settingsRegistry)
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
systemSettingsHandler.SetSettings(settingsRegistry)
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
// Create refactored handlers (matching main.go)
@@ -507,7 +572,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
statisticsService := services.NewStatisticsService(queries)
statsHandler := handlers.NewStatsHandler(statisticsService)
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)
// Create conversion service for OPDS
@@ -571,7 +636,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
// RegisterRoutes dereferences Settings for the auth rate limit;
// the main binary wires database.NewSettingsRegistry(queries) —
// the test harness must too or route registration nil-panics.
Settings: database.NewSettingsRegistry(queries),
Settings: settingsRegistry,
// main.go:246 passes the shared LibraryService here; omitting it
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
+8 -31
View File
@@ -157,37 +157,14 @@ func TestWebSocketProgressBroadcast(t *testing.T) {
assert.Equal(t, "web", sourceDevice["type"])
}
// TestWebSocketPingPong tests that ping/pong messages work correctly
func TestWebSocketPingPong(t *testing.T) {
setup := setupTestServer(t)
token := setup.Token
wsURL := strings.Replace(setup.Server.URL, "http", "ws", 1) + "/ws/sync?token=" + token
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
require.NoError(t, err)
defer ws.Close()
// Read initial message
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
_, _, _ = ws.ReadMessage()
// Send a ping message (as a text message for testing)
err = ws.WriteMessage(websocket.TextMessage, []byte(`{"type":"ping"}`))
require.NoError(t, err)
// Server should respond with pong
ws.SetReadDeadline(time.Now().Add(2 * time.Second))
_, msg, err := ws.ReadMessage()
if err == nil {
var pongMsg map[string]interface{}
err = json.Unmarshal(msg, &pongMsg)
if err == nil {
// Server might respond with pong
assert.Equal(t, "pong", pongMsg["type"])
}
}
}
// TestWebSocketPingPong was deleted (2026-10-20, Phase 6 full-suite
// repair): it asserted a TEXT-level {"type":"ping"} → {"type":"pong"}
// exchange that no server code has ever implemented and no client
// (web/src or Android) has ever sent — it passed vacuously through its
// read-timeout path until interleaved scan_progress broadcasts started
// failing the assert. Keepalive is the standard protocol-level ping the
// hub already sends (websocket.go's ticker); if a text-level app ping
// is ever wanted, build it server-side first, then re-pin it.
// TestWebSocketConnectionLimit tests that the server handles multiple connections
func TestWebSocketConnectionLimit(t *testing.T) {
+157 -3
View File
@@ -37,6 +37,11 @@ CREATE TABLE IF NOT EXISTS users (
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- User-level default audiobook playback speed (player plan §8): books
-- without a per-book audio_playback_speed row pick this up. REAL
-- mirrors the per-book table's column.
ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEFAULT 1.0;
-- Create system_settings table
CREATE TABLE IF NOT EXISTS system_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -61,6 +66,8 @@ ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40);
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
('scan_extractor_version', '1', 'Parser version stamped on each book scan manifest; raising it re-extracts metadata from books scanned under an older parser (bounded batch per scan)', 'int', '1', '1000000', FALSE, 'scanner'),
('auto_playlist_collections', 'true', 'Auto-create curated playlist collections from sidecar series marked as reading orders', 'bool', NULL, NULL, FALSE, 'scanner'),
('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
-- security / auth (live)
('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
@@ -136,7 +143,8 @@ CREATE TABLE IF NOT EXISTS media_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
author VARCHAR(255),
subtitle TEXT, -- Series-y/edition subtitle from sidecars; display field, never part of `title`
author VARCHAR(255), -- DEPRECATED for writes: display string regenerated from `authors` by trigger
isbn VARCHAR(13), -- Still relevant for ebooks
description TEXT,
file_path VARCHAR(500) NOT NULL,
@@ -144,10 +152,15 @@ CREATE TABLE IF NOT EXISTS media_items (
mime_type VARCHAR(100),
cover_image_path VARCHAR(500),
series VARCHAR(255),
series_number INTEGER,
series_number DOUBLE PRECISION, -- Real ordinals are decimals (0.5, 1.5 novellas); was INTEGER
tags TEXT[],
narrators TEXT[], -- Audiobook narrators (sidecar first; user-editable)
authors TEXT[] NOT NULL DEFAULT '{}', -- Write truth for authorship; `author` above is trigger-regenerated display
authors_search TEXT[] NOT NULL DEFAULT '{}',
publishers TEXT[] NOT NULL DEFAULT '{}', -- Write truth; `publisher` below is trigger-regenerated display
publishers_search TEXT[] NOT NULL DEFAULT '{}',
abridged BOOLEAN NOT NULL DEFAULT FALSE, -- Libation/Audible abridged flag
explicit BOOLEAN NOT NULL DEFAULT FALSE, -- Adult flag from sidecars (ABS explicit / Audible isAdult)
asin VARCHAR(20), -- Still relevant for ebooks
date_published DATE,
publisher VARCHAR(255),
@@ -267,6 +280,30 @@ ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators_search TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS abridged BOOLEAN NOT NULL DEFAULT FALSE;
-- A2b: subtitle + explicit flags, authors/publishers arrays (narrators
-- precedent — arrays are write truth, varchar display columns regenerate).
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS subtitle TEXT;
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS explicit BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS authors TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS authors_search TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS publishers TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS publishers_search TEXT[] NOT NULL DEFAULT '{}';
-- Series ordinals are real-world decimals (0.5, 1.5 novellas); INTEGER
-- truncated them to the wrong shelf order. In-place widening; no-op on a
-- fresh install (column is already double precision from CREATE TABLE).
ALTER TABLE media_items ALTER COLUMN series_number TYPE double precision
USING series_number::double precision;
-- Backfill the arrays from the legacy single-value columns. Deliberately NO
-- comma-splitting ("Tolkien, J.R.R." would corrupt); every existing row is
-- effectively single-valued because the scanner historically kept only
-- authors[0]. Guarded so the pass is free after the first startup.
UPDATE media_items SET authors = ARRAY[author]
WHERE author IS NOT NULL AND author <> '' AND authors = '{}';
UPDATE media_items SET publishers = ARRAY[publisher]
WHERE publisher IS NOT NULL AND publisher <> '' AND publishers = '{}';
-- audio_metadata is declared in the CREATE TABLE above, which only fresh
-- installs ever see — this ALTER is the upgrade path for pre-A2 installs
-- (its absence 42703'd every scan and archive pass on upgraded deployments).
@@ -316,6 +353,20 @@ BEGIN
IF NEW.narrators_search IS NULL THEN
NEW.narrators_search := '{}';
END IF;
-- A2b: authors/publishers arrays are NOT NULL — Go nil slices become
-- explicit NULLs in params and would 23502 (the narrators lesson).
IF NEW.authors IS NULL THEN
NEW.authors := '{}';
END IF;
IF NEW.authors_search IS NULL THEN
NEW.authors_search := '{}';
END IF;
IF NEW.publishers IS NULL THEN
NEW.publishers := '{}';
END IF;
IF NEW.publishers_search IS NULL THEN
NEW.publishers_search := '{}';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
@@ -333,6 +384,35 @@ CREATE TRIGGER set_library_type_name_on_library_change
FOR EACH ROW
EXECUTE FUNCTION set_library_type_name();
-- Display sync: `author`/`publisher` are denormalized display strings
-- regenerated from the authors/publishers arrays on every write. The varchar
-- columns face search (trgm), facets and API responses; the arrays are the
-- write truth. array_to_string is STABLE on PG15 (not IMMUTABLE), so this
-- must be a trigger — a generated column would reject it. Absent arrays
-- yield NULL display (matches the legacy no-author row shape).
DROP TRIGGER IF EXISTS sync_author_publisher_display_trg ON media_items;
DROP FUNCTION IF EXISTS sync_author_publisher_display();
CREATE OR REPLACE FUNCTION sync_author_publisher_display() RETURNS TRIGGER AS $$
BEGIN
-- Defense in depth: normalize NULL arrays first (this trigger may fire
-- before set_library_type_name depending on name-ordered execution).
IF NEW.authors IS NULL THEN
NEW.authors := '{}';
END IF;
IF NEW.publishers IS NULL THEN
NEW.publishers := '{}';
END IF;
NEW.author := CASE WHEN NEW.authors = '{}' THEN NULL
ELSE array_to_string(NEW.authors, ', ') END;
NEW.publisher := CASE WHEN NEW.publishers = '{}' THEN NULL
ELSE array_to_string(NEW.publishers, ', ') END;
RETURN NEW;
END $$ LANGUAGE plpgsql;
CREATE TRIGGER sync_author_publisher_display_trg
BEFORE INSERT OR UPDATE ON media_items
FOR EACH ROW
EXECUTE FUNCTION sync_author_publisher_display();
-- Add GIN indexes for pg_trgm fuzzy search performance
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
ON media_items USING GIN (author gin_trgm_ops);
@@ -546,8 +626,33 @@ CREATE INDEX IF NOT EXISTS idx_processing_issues_library
CREATE INDEX IF NOT EXISTS idx_processing_issues_severity
ON processing_issues(severity, resolved);
-- Phase 11 (faceted plan): the ledger records ROW-LESS import failures
-- too — a book whose media_items row was never created has no item to
-- reference. media_item_id becomes nullable; file_path carries the
-- failing file's library-relative path for those rows. The table-level
-- UNIQUE stays (item-backed safety; row-less rows have NULL item ids,
-- which unique constraints treat as pairwise-distinct). The widened
-- dedup key is a COALESCE expression index covering BOTH classes in one
-- shape: item-backed = (item, library, type, ''), row-less =
-- (zero-uuid, library, type, path). An expression index (not NULLS NOT
-- DISTINCT) because the two classes key on different columns.
ALTER TABLE processing_issues ALTER COLUMN media_item_id DROP NOT NULL;
-- TEXT, deliberately NOT VARCHAR(500): the ledger must be able to
-- record paths LONGER than media_items' limit — an over-long path is
-- itself a failure mode, and a 500-char cap would make the issue row
-- die on the exact error it exists to report.
ALTER TABLE processing_issues ADD COLUMN IF NOT EXISTS file_path TEXT;
ALTER TABLE processing_issues ALTER COLUMN file_path TYPE TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_processing_issues_dedup
ON processing_issues (
COALESCE(media_item_id, '00000000-0000-0000-0000-000000000000'::uuid),
library_id,
issue_type,
COALESCE(file_path, '')
);
-- Add comment for documentation
COMMENT ON TABLE processing_issues IS 'Tracks media items that cannot be properly processed in their assigned library due to format mismatches or other issues';
COMMENT ON TABLE processing_issues IS 'Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.';
-- ============================================
@@ -1173,6 +1278,28 @@ CREATE TABLE IF NOT EXISTS media_item_formats (
CREATE INDEX IF NOT EXISTS idx_media_item_formats_media ON media_item_formats(media_item_id, format_type);
CREATE INDEX IF NOT EXISTS idx_media_item_formats_sha256 ON media_item_formats(file_sha256);
-- media_scan_state: the scanner's per-item fingerprint cache (faceted
-- plan Part 2). Deliberately NOT a media_items column: the library-
-- scoped media list emits raw sqlc rows as JSON, and scanner
-- bookkeeping must never leak into API payloads (nor churn the model
-- or the contract pins). One row per item; cascade on delete.
-- manifest shape v1:
-- {"v":1,"entries":[{"n":"metadata.opf","s":3013013,"m":1697500000123456789,"h":"<sha256>"}, ...]}
-- n basename, s size, m mtime as unix NANOS (second granularity
-- would hide edits landing in the backfill scan's own second), h
-- sha256 of the content — present for ALL entries, covers included:
-- the repoint reconciliation needs a baseline hash for any stat-
-- drifted sidecar. "ev" (extractor version, default 1): the parser
-- version that produced the item's row. ABSENT means "v1-or-unknown"
-- — backfill stamps are unset so a row never claims a version its
-- data can't prove; every post-extraction store stamps the setting's
-- current value. A stored ev != scan_extractor_version → re-extract
-- (bounded per scan, resumable across polls).
CREATE TABLE IF NOT EXISTS media_scan_state (
media_item_id UUID PRIMARY KEY REFERENCES media_items(id) ON DELETE CASCADE,
manifest JSONB
);
-- Create device_file_aliases table (track file paths per device for cross-device matching)
CREATE TABLE IF NOT EXISTS device_file_aliases (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1256,12 +1383,21 @@ CREATE TABLE IF NOT EXISTS collection_items (
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
excluded BOOLEAN DEFAULT false,
-- Manual playlist ordering (reading-order collections). NULL = un-ordered
-- legacy item; ordering falls back to added_at ASC.
position INTEGER,
UNIQUE(collection_id, media_item_id)
);
CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_id);
CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_item_id);
-- Playlist ordering (A2c): position-first ordering for collection listings;
-- NULLs fall back to added_at ASC.
ALTER TABLE collection_items ADD COLUMN IF NOT EXISTS position INTEGER;
CREATE INDEX IF NOT EXISTS idx_collection_items_order
ON collection_items(collection_id, position ASC NULLS LAST);
-- Index for excluding auto-assigned items
CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded)
WHERE excluded = true;
@@ -1432,6 +1568,24 @@ CREATE TABLE IF NOT EXISTS reading_speed (
CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);
-- Per-user PER-BOOK playback speed preference (player plan §8): a
-- preference, NOT a measurement (reading_speed holds measured rates),
-- and NOT a reading_progress column (a speed tap must not create a
-- "currently reading" row). Resolution lives at the chapters endpoint:
-- per-book row → users.default_audio_speed → 1.0.
CREATE TABLE IF NOT EXISTS audio_playback_speed (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
speed REAL NOT NULL DEFAULT 1.0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, media_item_id)
);
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_user ON audio_playback_speed(user_id);
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_item ON audio_playback_speed(media_item_id);
-- Dictionary cache (for offline use)
CREATE TABLE IF NOT EXISTS dictionary_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+295
View File
@@ -119,3 +119,298 @@ cover_image_path, has_conflict, format_group, badge`.
- `media_items.audio_metadata` payload gained additive JSON keys
`layout` and `tracks` (see schema.sql comment). Consumers parse
additively; stored single-file payloads are unchanged.
## 2026-10-19 — A2b: metadata array/model overhaul (phase 1)
- `media_items.series_number` widened INTEGER → DOUBLE PRECISION across all
surfaces. JSON serializers emit the same key; values are JSON numbers, so
integer-valued ordinals are byte-identical ("3") and decimal ordinals
(0.5/1.5) now serialize as decimals instead of truncating. Clients reading
JSON numbers are unaffected; the Android client parses either form.
- `GET /api/media-items*` / detail responses gain ADDITIVE keys:
`subtitle` (string), `explicit` (bool), `authors` ([]string, write truth —
legacy `author` display string still emitted, now trigger-regenerated),
`publishers` ([]string; legacy `publisher` likewise).
- `POST/PUT /api/media-items*` accept ADDITIVE request keys `subtitle`,
`explicit`, `authors`, `publishers`; legacy `author`/`publisher` singles
still accepted (arrays win when both present).
- Override keys unchanged: `author`/`publisher` override names are preserved
for existing rows and now protect the arrays.
## 2026-10-19 — A2c: collections/playlists phase
- `GET /api/collections/:id` gained ADDITIVE `up_next` (array of
`{id, title, author, cover, format, progress}` — the static collection's
reading queue head in position order, viewer-progress-aware; empty array
for smart collections).
- `GET /api/dashboard/sections` rows gained ADDITIVE `is_playlist` (bool —
collection was imported from a sidecar ordering construct) and
`complete` (bool — hide_finished filtered every item out of a non-empty
collection; render "playlist complete").
- `PUT /api/collections/:id/items/order` is a NEW endpoint (owner-only,
full ordered id list → positions 1..n).
- User collections are dashboard-eligible by default (the
show_on_dashboard gate is gone from GetUserCollectionsForDashboard);
hiding stays dashboard-prefs (`hidden_collections`) territory.
- `GET /api/media-items` (unscoped resolved-map branch) and
`GET /api/media-items/:id` gained the same ADDITIVE keys as the raw
branch: `subtitle`, `explicit`, `authors`, `publishers`.
- `GET /api/media-items/search` rows gained the same ADDITIVE keys plus
the search pairs `authors_search`, `publishers_search`,
`narrators_search` (the unified SELECT now carries the new columns).
- `author_filter` / `publisher_filter` are now per-element against the
arrays; a comma-joined value is OR-within-facet multi-select. The
param NAMES and single-value behavior are unchanged.
## 2026-10-19 — Faceted navigation Batch A: new filter facets
- `GET /api/media-items/search` accepts ADDITIVE REQUEST params (query
string, response shapes unchanged): `story_arc_filter`,
`imprint_filter`, `edition_filter` (scalar-fuzzy),
`age_rating_filter`, `format_group_filter`, `manga_type_filter`,
`reading_direction_filter` (exact), `explicit_filter`,
`abridged_filter` (bool: absent = unset, "true"/"false" = bound).
A comma-joined value is OR-within-facet multi-select on every new
TEXT facet (the SQL splits via string_to_array). `services/facet_registry.go`
is the Go-side source of truth; the drift-guard unit test
(`TestFacetRegistrySQLDrift`) pins registry ↔ SQL agreement.
- Genre model v2 changes SCANNER OUTPUT (not a wire contract): sidecar
genre selection now stores only top-level shelves in `genre`
(subgenres become tags mapped to their parent shelf; unknown-only
input leaves `genre` empty). Existing rows are unaffected until a
rescan/re-extract.
## 2026-10-19 — Faceted navigation Batch B/C: chips, FacetLink, facets endpoint
- `GET /api/media-items/facets?facet=X&library_id=...` is a NEW pinned
surface (registry-driven value+count listing for the filter panel):
200 → `{facet, values[]: {value, display?, count}}` (display present
only for language codes → names); 400 for unknown facets and for
bool/range facets (they render as toggles, not lists); 403 for an
explicit library_id outside the requester's visible set.
- `GET /api/media-items/search` (JSON) and its HTMX grid partial now
share ONE param parser (`handlers.ParseMediaSearchParams`). Behavior
notes: the HTMX branch previously dropped `publisher_filter` and
`narrator_filter` silently — it now honors every facet param; and an
explicit hidden-library `library_id` is now a 403 on BOTH branches
(was: silent empty set on JSON). Response shapes are untouched; the
search row pin (47 keys) is unchanged.
- `language_filter` now normalizes comma elements through the ingest
language map, so display names ("English") match rows stored as ISO
codes ("en"). The param name is unchanged; single-code values behave
identically.
- Web: the bookshelf gains an active-filter chips bar (client-local
form state rendered by Alpine; templ owns the markup) and
`applyURLFilters` accepts the new facet params.
## 2026-10-19 — Scanner Phase 4: manifest-gated sidecar detection
- No wire change: `media_scan_state` lives in its own table, so the
raw-row list JSON cannot contain scanner state (guarded by an
assertion in TestScanManifestLifecycle).
- Scanner BEHAVIOR: size-stable books are now checked against a stored
sidecar fingerprint (`{"v":1,"entries":[{n,s,m-nanos,h-sha256},...]}`).
Stat-pair-identical → skip with zero reads; stat drift → hash
reconciliation (equal hash = host repoint, stat pair refreshed, no
re-extraction; differing hash = metadata re-extract + fresh manifest,
metadata_overrides protected, playlist collections synced on the
change path — a "(Reading Order)" entry added to an EXISTING book now
creates its collection). NULL stored manifest → backfill only, no
re-extraction (deploy-day rule). Multifile folder/zip books keep the
Phase 3 behavior (their change path does not consume sidecar
extractors yet — gating them would flip books "changed" for nothing).
## 2026-10-19 — Scanner Phase 5: quiet scans, coalescing, watcher probe
- `POST /api/scanner/scan` now answers **409** (with `active_job_id`)
when a scan for the same library is already pending/running —
coalescing instead of stacking a second full scan. The endpoint was
never pinned; no existing response shape changed. Poll ticks and
watcher events coalesce silently (no second job enqueued).
- Scan jobs carry a `trigger` label (poll/watcher/admin/initial); the
per-scan log is now ONE summary line:
`Scan of <folders> (trigger: <t>): N files, N changed, N new, Xs, N errors`.
Per-file "processing/success/skipping" chatter is gone; inserts,
changes, and errors still log individually.
- `JobResult` gains additive `job_type` (coalescing needs it; the
scan-progress surfaces tolerate unknown keys).
- Watcher: a failed capability probe (one `Add` on a root folder)
disables the watcher with ONE startup line instead of per-directory
warning spam.
## 2026-10-20 — Stale pin repair (behavior shipped in 7e586df4)
- `GET /api/media-items/search` with an explicit `library_id` outside
the requesting user's visible set — INCLUDING a nonexistent id —
answers **403** (`library not accessible`), not 404. The family-lens
standing rule (AGENTS.md) mandates 403 for every out-of-set id so
library existence is not enumerable; Phase 1 (7e586df4) shipped it
and documented it here, but the `invalid library_id` subtest in
search_test.go still pinned the pre-family-lens 404. Pin widened to
the shipped 403; no server change (discovered during the Phase 6
full-suite run; the Phase 6 diff touches no handler code).
- `TestWebSocketPingPong` DELETED: it asserted a text-level
`{"type":"ping"}` → `{"type":"pong"}` exchange that no server code
has ever implemented and no client (web/src, Android) has ever sent.
It passed vacuously through its read-timeout path (deadline error
skipped the asserts) until interleaved `scan_progress` broadcasts
from the live server's scans started reaching the socket first.
Keepalive is the standard protocol-level ping the hub already sends.
Never-built behavior is not a pinnable contract.
## 2026-10-20 — Scanner Phase 7: all-facets shape + SSR facet panel
- `GET /api/media-items/facets` gains a SECOND shape: with NO `facet`
param it answers `{"facets": {<param>: [{"value","display?","count"},
…]}}` — every listable facet in one response, counts computed under
the ACTIVE FILTERS (the same query params the search endpoint takes;
each facet is counted under the others — its own filter does not
restrict its own list). `library_id` is optional on this shape:
absent = the All-Libraries view. Family-lens unchanged: an explicit
out-of-set `library_id` is 403 on both shapes.
- The PINNED single-facet shape (`?facet=X&library_id=…` →
`{facet, values[]}`) is byte-identical in semantics; its
implementation now routes through the same UNION-ALL query with
neutral filters (the 14 per-column SQL queries were deleted — no
wire change).
- NEW SSR partial: `GET /media-items/facet-panel` (session-cookie
auth, bookshelf-protected group) renders the drawer's value lists —
templ-rendered, registry order, library-type-scoped sections, active
values marked. An out-of-set explicit `library_id` is 403 HTML.
- The 14 per-column facet SQL queries are replaced by one
`FacetCountsUnderFilters` UNION-ALL statement; the registry drift
guard now reflects over both generated param structs.
## 2026-10-20 — Filter plumbing: facet-panel partial removed; field-values `library_id` optional
- `GET /media-items/facet-panel` (the SSR partial documented above,
same day) is REMOVED — the panel UX was rejected on scale grounds
and the filter drawer becomes a rule builder with no other mode.
Never contract-pinned while it lived (same-day entry, same-day
removal); `TestFacetPanelPartial` was deleted with the behavior.
The all-facets JSON endpoint and its two shapes are UNCHANGED and
stay pinned; `FacetCountsUnderFilters` and the registry drift guard
keep their roles (the engine outlives the panel).
- `GET /api/media-items/search?field=value` autocomplete
(author/genre/tags/narrator/series/language): `library_id` is now
OPTIONAL — absent means every library VISIBLE to the caller (the
per-user family lens applies inside the query), fixing the All-Books
view's "library_id is required" 400. An explicit out-of-set
`library_id` is now a 403 on this endpoint too (was: silent empty
result for hidden libraries — the silent pass is the anti-pattern
the standing rule forbids). The single-facet
`/api/media-items/facets?facet=X` shape still REQUIRES `library_id`
— 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).
## 2026-10-20 — Scanner Phase 10: extractor version (ev) mechanism
- `media_scan_state.manifest` JSONB gains the reserved `ev` key:
the parser version that produced the item's CURRENT row. ABSENT
means "v1-or-unknown" — decode normalizes it to 1.
- NEW setting `scan_extractor_version` (int, default 1, min 1,
category scanner): the unified `GET/PUT /api/system/settings` list
now includes this key (additive; the list was not a pinned closed
set). Writes stay settings-API-only — direct SQL leaves the registry
cache stale (known gotcha).
- Scanner behavior: a stored manifest whose ev ≠ the setting is
treated as CHANGED → full re-extract + the fresh manifest stamps
the new ev. Stamping is TRUTHFUL-ONLY: create paths and
post-extraction stores stamp; deploy-day BACKFILL and the
extract-failure guard store UNSET (the row never claims a version
its data can't prove — a mid-backfill bump self-heals next poll).
Repoint reconciliation preserves the stored ev (stat-only refresh,
nothing re-extracted).
- Catch-up is BOUNDED per scan pass (`evReextractBudgetPerScan = 100`,
atomic, reset at each ScanFolders): resumable across polls, no
force rescan, no flood. Budget exhaustion leaves ev stale → later
polls resume.
- Deploy-day rule (unchanged, re-verified live): manifests without ev
ride v1; at default setting 1 the first post-release scan re-extracts
NOTHING (0 changed on 1198 files).
## 2026-10-20 — Scanner Phase 11: the processing-issues ledger
- `processing_issues.media_item_id` is now NULLABLE and the table gains
`file_path` (TEXT, deliberately unbounded — an over-long path is
itself a failure mode the ledger must survive): ROW-LESS import
failures (no media_items row exists) key on (library, file_path);
item-backed rows keep (item, type). The widened dedup is the
COALESCE-expression unique index idx_processing_issues_dedup; the old
table-level UNIQUE stays (row-less rows have NULL items, pairwise
distinct under unique constraints).
- `GET /api/libraries/:id/issues/list` — the media_items join is now
LEFT: row-less issues APPEAR (they were unrepresentable before).
Response shape unchanged for item-backed rows; row-less rows carry
`media_item_id: ""` and a `title` fallen back to the file path.
- FAMILY LENS now applies to ALL issues endpoints (list, stats, resolve,
delete) and the SSR page — they leak media metadata. An explicit
library id outside the requester's visible set is a **403** (this
tightens one pre-lens behavior: a nonexistent library id used to
return 200-empty; it is trivially out-of-set → 403 now, pinned).
- NEW scanner wirings: CreateMediaItem failure → row-less
`import_failed` (error); extraction hard-fail with fallback import →
item-backed `metadata_extraction_failed` (warning); change-path
re-extraction failure → item-backed `metadata_extraction_failed`.
- Auto-resolution: a successful import resolves row-less issues by
(library, file_path); a successful (re)extraction resolves the item's
open issues. Unchanged-skip scans do NOT resolve (extraction never
re-ran — the degraded metadata is still degraded). A repeat failure
reopens a resolved row (upsert sets resolved=false).
## 2026-10-21 — Phase 13: force rescan = original scan; the manual-edits filter
- **Force rescan contract change (owner-directed)**: force rescan now
behaves like an original import — the full metadata chain (sidecars →
folder structure → tags → fallbacks) re-lands on MULTI-FILE books
too (the pre-Phase-13 editorial-keep skip is retired; the owner's
metadata_overrides count in the single digits, and overrides are the
only protection, honored inside the apply). A force pass also STAMPS
each re-derived book's manifest with the current parser version
(success-only) and closes its open processing issues. Single-file
force rescan already behaved this way — now pinned by test.
- **NEW filter field** `manually_edited` ("Manually edited", boolean):
is_true = `cardinality(mi.metadata_overrides) > 0`, is_false = `= 0`.
Composed-engine (qf) only — no flat param, no value menu, no
suggestion surface; family lens inherited.
- Deployment ordering note (owner Q&A): set scan_extractor_version
BEFORE Rescan All — force rescan stamps books with the current
setting, so bump-first + force = one pass of work; force-first +
bump = the trickle re-derives everything again (wasted, not harmful).
+55 -32
View File
@@ -8,6 +8,15 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
type AudioPlaybackSpeed struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Speed float32 `db:"speed" json:"speed"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type CollectionItems struct {
ID pgtype.UUID `db:"id" json:"id"`
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
@@ -15,6 +24,7 @@ type CollectionItems struct {
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"`
Excluded pgtype.Bool `db:"excluded" json:"excluded"`
Position pgtype.Int4 `db:"position" json:"position"`
}
type Collections struct {
@@ -232,25 +242,31 @@ type MediaItemFormats struct {
}
type MediaItems struct {
ID pgtype.UUID `db:"id" json:"id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Title string `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
Tags []string `db:"tags" json:"tags"`
Narrators []string `db:"narrators" json:"narrators"`
Abridged bool `db:"abridged" json:"abridged"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors []string `db:"contributors" json:"contributors"`
ID pgtype.UUID `db:"id" json:"id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
Title string `db:"title" json:"title"`
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
Author pgtype.Text `db:"author" json:"author"`
Isbn pgtype.Text `db:"isbn" json:"isbn"`
Description pgtype.Text `db:"description" json:"description"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
Series pgtype.Text `db:"series" json:"series"`
SeriesNumber pgtype.Float8 `db:"series_number" json:"series_number"`
Tags []string `db:"tags" json:"tags"`
Narrators []string `db:"narrators" json:"narrators"`
Authors []string `db:"authors" json:"authors"`
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
Publishers []string `db:"publishers" json:"publishers"`
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
Abridged bool `db:"abridged" json:"abridged"`
Explicit bool `db:"explicit" json:"explicit"`
Asin pgtype.Text `db:"asin" json:"asin"`
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
Contributors []string `db:"contributors" json:"contributors"`
// ISO 639-1 language code (e.g., en, es, fr)
Language pgtype.Text `db:"language" json:"language"`
// Edition information (e.g., "First Edition", "Revised Edition")
@@ -365,6 +381,11 @@ type MediaReviews struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type MediaScanState struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Manifest []byte `db:"manifest" json:"manifest"`
}
type OpdsTokens struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
@@ -384,7 +405,7 @@ type PanelData struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
// Tracks media items that cannot be properly processed in their assigned library due to format mismatches or other issues
// Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.
type ProcessingIssues struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
@@ -395,6 +416,7 @@ type ProcessingIssues struct {
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
}
type ReaderSettings struct {
@@ -562,16 +584,17 @@ type UserDashboardPreferencesGlobal struct {
}
type Users struct {
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
FirstName pgtype.Text `db:"first_name" json:"first_name"`
LastName pgtype.Text `db:"last_name" json:"last_name"`
Role string `db:"role" json:"role"`
Theme pgtype.Text `db:"theme" json:"theme"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
ID pgtype.UUID `db:"id" json:"id"`
Email string `db:"email" json:"email"`
Username string `db:"username" json:"username"`
PasswordHash string `db:"password_hash" json:"password_hash"`
FirstName pgtype.Text `db:"first_name" json:"first_name"`
LastName pgtype.Text `db:"last_name" json:"last_name"`
Role string `db:"role" json:"role"`
Theme pgtype.Text `db:"theme" json:"theme"`
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Timezone pgtype.Text `db:"timezone" json:"timezone"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
}
+97 -1
View File
@@ -14,6 +14,9 @@ type Querier interface {
// COLLECTION ITEMS QUERIES
// Add book to collection
AddBookToCollection(ctx context.Context, arg AddBookToCollectionParams) (CollectionItems, error)
// A2c: playlist upsert — position set ONLY on insert, so rescans and
// re-adds never clobber the user's manual drag ordering.
AddBookToCollectionOrdered(ctx context.Context, arg AddBookToCollectionOrderedParams) (CollectionItems, error)
// ============================================
// KOBO SHELF MANAGEMENT QUERIES
// ============================================
@@ -42,6 +45,10 @@ type Querier interface {
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
CountAdmins(ctx context.Context) (int64, error)
CountArchivedMediaItems(ctx context.Context) (int64, error)
// A2c §3.5: distinguish "playlist complete" from "genuinely empty" — the
// dashboard renders a completion marker when hide_finished filtered
// everything out of a non-empty collection.
CountCollectionItems(ctx context.Context, collectionID pgtype.UUID) (int64, error)
// Live item count for a STATIC collection (rule-free cards on the
// collections page); same live filter as GetCollectionItems, plus the
// future-hybrid excluded exclusion and an optional library scope.
@@ -105,6 +112,11 @@ type Querier interface {
// ============================================================================
// PROCESSING ISSUES QUERIES
// ============================================================================
// Widened upsert (faceted plan Phase 11): media_item_id and file_path
// are nullable — item-backed rows key on (item, library, type, ''),
// row-less import failures on (zero-uuid, library, type, path). The
// conflict target matches idx_processing_issues_dedup's expressions.
// A repeat failure REOPENS a resolved row (resolved=false).
CreateProcessingIssue(ctx context.Context, arg CreateProcessingIssueParams) (ProcessingIssues, error)
// Create reading history entry
CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error)
@@ -157,6 +169,27 @@ type Querier interface {
// historically stuff the user id into DeviceID (it only fed a broadcast
// label before stats); a capture row must only reference a REAL device.
DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error)
// Facet-value listings (faceted-navigation Batch C v2, faceted plan
// Phase 7): ALL listable facets' top values + counts in ONE statement —
// one round trip serves the filter drawer's panel, with classic faceted
// semantics: each arm OMITS its own facet's filter block and carries
// every OTHER registered facet's block (copied mechanically from
// SearchMediaItemsUnified — keep the two in lockstep), so with filters
// active each facet counts under the others, and with no filters the
// blocks are neutral and the counts are the v1 global-per-library ones.
// One query serves both cases.
//
// Visibility: same family-lens join as the search query — a user's
// hidden-library rows are absent; the handler 403s an out-of-set
// explicit library_id. library_id is nullable: NULL = the All-Libraries
// view (no scope beyond the lens). Bool/range facets (has_cover,
// explicit, abridged, year) filter every arm but list no values — they
// render as toggles/inputs, not lists.
//
// Per-arm LIMIT (parens required: ORDER BY/LIMIT inside a compound
// select bind per-arm only in parenthesized subqueries). The same
// sqlc.narg names repeat across arms; sqlc binds them once.
FacetCountsUnderFilters(ctx context.Context, arg FacetCountsUnderFiltersParams) ([]FacetCountsUnderFiltersRow, error)
// Find content-duplicate groups (same library + SHA-256, more than one row)
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
@@ -169,17 +202,37 @@ type Querier interface {
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
// Per-book playback speed preference (player plan §8).
GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error)
// Lifetime session rows for ONE book (the book-detail stats card).
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
// Get collection
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
// A2c §2.4: playlist-collection discovery — provenance key first so a user
// rename can't spawn duplicates on the next scan.
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
// Get collection items
// A2c: position-first ordering (playlist reading order); un-positioned
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
// which walked a playlist backwards.
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
// The excluded filter lives here (was: callers filtered ci.excluded in
// Go over a 69-field row + a 45-field converter to MediaItems; embed
// returns the real row so neither exists).
// A2c: position-first ordering; optional hide_finished — when the flag is
// set AND a viewer is supplied, items the viewer finished (percentage
// >= 0.999) drop out, turning the section into the reading queue head.
// viewer_user_id NULL = no progress filter (never-finished semantics for
// anonymous/legacy callers is NOT implied: hide_finished without a viewer
// is a caller bug and shows everything).
GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error)
// A2c §3.4: the collection's reading queue — first N un-finished items in
// position order. Strict playlist semantics: finished items drop out as
// they complete, so the head IS "up next". Library visibility is enforced
// by the family-lens array param (never NULL for user-facing callers).
GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNextParams) ([]GetCollectionUpNextRow, error)
// Get collection with book count
GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID) (GetCollectionWithBookCountRow, error)
// Get collections by user
@@ -334,6 +387,9 @@ type Querier interface {
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error)
// GetMediaScanState reads one item's manifest (per-item rescan paths
// and the manifest integration tests).
GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error)
GetNextRetryTime(ctx context.Context) (interface{}, error)
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
// Get OPDS token
@@ -343,6 +399,8 @@ type Querier interface {
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
// Get most popular books for a user
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
// Single-issue read for the resolve/delete endpoints' family-lens gate.
GetProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
GetProcessingIssueStats(ctx context.Context, libraryID pgtype.UUID) (GetProcessingIssueStatsRow, error)
GetReaderSettings(ctx context.Context, userID pgtype.UUID) ([]byte, error)
// Get reading history for a user and book
@@ -354,6 +412,17 @@ type Querier interface {
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
// GetScanStateByLibrary: the scanner's ONE bulk query per library scan
// (faceted plan Part 2 §2) — every item's existence + scanner-owned
// facts + sidecar manifest in a single pass. Replaces the per-file
// GetMediaItemByFilePath flood (~3,094 queries per scan in prod).
// Lean projection: metadata blobs (audio_metadata, chapter_metadata,
// authors, description...) are NEVER loaded — the skip path never
// needs them, and the change path re-reads the full row.
// archived_at + missing_scan_count + file_sha256 ride along so the
// skip path's archive-restore and hash self-heal checks stay
// DB-free too.
GetScanStateByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]GetScanStateByLibraryRow, error)
// Scoped to ONE library: same-name series in different libraries are
// different stories (manga adaptations, LN splits). Callers must pass a
// library_id and verify the requester can see that library.
@@ -393,6 +462,10 @@ type Querier interface {
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
// A2c (2026-10-19): user collections are dashboard-ELIGIBLE BY DEFAULT —
// show_on_dashboard gated them forever-false (no writer ever set it on
// user rows), and hiding is the dashboard-prefs job (hidden_collections).
// The flag remains system-section provisioning state only.
GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
// Per-device reading breakdown for a range. LEFT JOIN so pushes that
// carry no device (web reader until it identifies) still appear as one
@@ -471,6 +544,10 @@ type Querier interface {
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
// silently disabled the offline flush until 2026-10-17).
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
// row — an inner join would silently drop the ledger's newest kind.
// Item-side projections COALESCE to keep them non-nullable strings;
// the display file path falls back to the issue's own for row-less rows.
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
@@ -510,10 +587,19 @@ type Querier interface {
// Remove book from collection
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
// A2c: full-order rewrite for the drag-to-reorder endpoint. The payload is
// the complete ordered id list; positions become 1..n in payload order.
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
// item successfully closes its open issues (extraction failures heal).
ResolveOpenProcessingIssuesForItem(ctx context.Context, mediaItemID pgtype.UUID) (int64, error)
// Row-less issues resolve by (library, file_path) when the file finally
// imports — the page heals itself without admin action.
ResolveOpenProcessingIssuesForPath(ctx context.Context, arg ResolveOpenProcessingIssuesForPathParams) (int64, error)
ResolveProcessingIssue(ctx context.Context, arg ResolveProcessingIssueParams) (ProcessingIssues, error)
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
// Resolve unlinked book
@@ -556,6 +642,9 @@ type Querier interface {
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
// of the JSONB without touching metadata stale clients did not send).
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
// A2c §2.4: provenance + playlist flags on an existing collection without
// touching the rest of the row.
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error)
@@ -583,7 +672,7 @@ type Querier interface {
// books): rescans recompute the track manifest and the aggregate facts
// that follow from it. Editorial fields stay untouched — admins may have
// edited title/narrators after ingest.
UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (MediaItems, error)
UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (UpdateMediaItemAudioBookPayloadRow, error)
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
// Update media item format
UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error)
@@ -609,15 +698,22 @@ type Querier interface {
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
// Update universal progress
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error)
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
// UpsertMediaScanState stores a book's sidecar manifest fingerprint
// (faceted plan Part 2 §3). Written on backfill (first sighting —
// no re-extraction), on repoint reconciliation (stat pairs refreshed,
// content identical), and after a manifest-gated re-extract.
UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+13
View File
@@ -58,6 +58,8 @@ type SettingDefault struct {
var SettingDefaults = []SettingDefault{
{Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"},
{Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"},
{Key: "auto_playlist_collections", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Auto-create curated playlist collections from sidecar series marked as reading orders"},
{Key: "scan_extractor_version", Value: "1", Type: SettingTypeInt, Min: "1", Max: "1000000", Category: "scanner", Group: "Scanning", Description: "Parser version stamped on each book's scan manifest. Raising it re-extracts metadata from books scanned under an older parser, a bounded batch per scan until all are caught up"},
{Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"},
{Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"},
{Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"},
@@ -189,6 +191,17 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
// AutoScanEnabled reports whether auto-scanning is on.
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
// AutoPlaylistCollections reports whether the scanner should turn
// obviously-playlist sidecar series ("The Witcher (Reading Order)") into
// curated static collections (A2c §2.4).
func (r *SettingsRegistry) AutoPlaylistCollections() bool { return r.getBool("auto_playlist_collections") }
// ScanExtractorVersion is the parser version stamped on each book's scan
// manifest. Books whose stored stamp differs re-extract their metadata on
// later scans (bounded per scan) — the mechanism that lets parser fixes
// reach rows ingested under an older parser without a force rescan.
func (r *SettingsRegistry) ScanExtractorVersion() int { return r.getInt("scan_extractor_version") }
// RegistrationEnabled reports whether public signup is allowed. Admin user
// creation is never affected — only self-service registration rides this
// flag; handlers additionally grant the first-user exception (see
+32 -7
View File
@@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"html/template"
"math"
"net/http"
"os"
"path/filepath"
@@ -120,6 +121,8 @@ type UserProfile struct {
LastName string `json:"last_name"`
Role string `json:"role"`
Theme string `json:"theme,omitempty"`
// User-level default audiobook playback speed (plan §8); 0 omits.
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
}
type UpdateProfileRequest struct {
@@ -129,6 +132,9 @@ type UpdateProfileRequest struct {
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
// Per the SPEEDS list bounds; the profile select always sends it,
// JSON clients omit it (0 = untouched).
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
}
type AdminUpdateUserRequest struct {
@@ -139,6 +145,8 @@ type AdminUpdateUserRequest struct {
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
// Same semantics as UpdateProfileRequest.DefaultAudioSpeed.
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
}
// Register handles POST /api/auth/register
@@ -538,13 +546,14 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
lastName = user.LastName.String
}
return c.JSON(http.StatusOK, UserProfile{
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
FirstName: firstName,
LastName: lastName,
Role: user.Role,
Theme: user.Theme.String,
ID: uuid.UUID(user.ID.Bytes).String(),
Email: user.Email,
Username: user.Username,
FirstName: firstName,
LastName: lastName,
Role: user.Role,
Theme: user.Theme.String,
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
})
}
@@ -693,6 +702,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
}
}
// Update the default audiobook playback speed (plan §8). The form
// select always sends it; JSON clients that omit it decode as 0 and
// leave the column untouched.
if req.DefaultAudioSpeed != 0 {
if req.DefaultAudioSpeed < 0.25 || req.DefaultAudioSpeed > 4 || math.IsNaN(req.DefaultAudioSpeed) || math.IsInf(req.DefaultAudioSpeed, 0) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "default playback speed out of range"})
}
err := h.db.UpdateUserDefaultAudioSpeed(c.Request().Context(), database.UpdateUserDefaultAudioSpeedParams{
ID: targetUserUUID,
DefaultAudioSpeed: float32(req.DefaultAudioSpeed),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
}
// Browser path (htmx modal + profile page): refresh so every rendered
// value (timezone select, table rows, header) reflects the save and the
// modal closes. A bare JSON body swapped into #profile-result read as
+131
View File
@@ -52,6 +52,12 @@ type UpdateCollectionRequest struct {
Icon string `form:"icon" json:"icon"`
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
// A2c §3.3: playlist toggles — merged into view_settings server-side
// (nil = untouched). []string because the form posts a hidden "false"
// sibling next to the checkbox; the LAST value wins. JSON clients may
// send a single-element array (or omit — untouched).
HideFinished []string `form:"hide_finished" json:"hide_finished"`
ShowUpNext []string `form:"show_up_next" json:"show_up_next"`
}
type AddBooksRequest struct {
@@ -79,6 +85,10 @@ type SectionData struct {
Items []BookInfo `json:"items"`
ViewAllURL string `json:"view_all_url"`
Priority int `json:"priority"`
// A2c: curated playlist marker (imported reading-order collection) and
// the hide_finished completion state — dashboard.ts renders both.
IsPlaylist bool `json:"is_playlist"`
Complete bool `json:"complete"`
}
func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
@@ -284,10 +294,28 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
}
}
} else if libUUID.Valid {
// A2c §3.3: the detail page honors the same per-collection
// hide_finished as the dashboard section, against the viewer's
// progress. The viewer_percentage rides along for card graying.
hideFinished := false
if len(collection.ViewSettings) > 0 {
var vs map[string]interface{}
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
if b, ok := vs["hide_finished"].(bool); ok {
hideFinished = b
}
}
}
var viewerID pgtype.UUID
if u, ok := c.Get("user").(database.Users); ok {
viewerID = u.ID
}
collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(),
database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
LibraryID: libUUID,
ViewerUserID: viewerID,
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: hideFinished},
Limit: pgtype.Int4{Int32: 10000, Valid: true},
})
if collErr != nil {
@@ -317,6 +345,40 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
}
}
// A2c §3.4: the collection's reading queue for the Up Next strip.
// Library visibility enforced — a hidden library's books never surface
// here even if they sit in a visible collection.
upNext := []map[string]interface{}{}
if !isSmart {
hideFinished := false
if b, ok := viewSettings["hide_finished"].(bool); ok {
hideFinished = b
}
_ = hideFinished
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
if visErr == nil {
user := c.Get("user").(database.Users)
rows, upErr := h.db.GetCollectionUpNext(c.Request().Context(), database.GetCollectionUpNextParams{
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
ViewerUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
VisibleLibraryIds: visibleIDs,
Limit: pgtype.Int4{Int32: 10, Valid: true},
})
if upErr == nil {
for _, row := range rows {
upNext = append(upNext, map[string]interface{}{
"id": uuid.UUID(row.MediaItems.ID.Bytes).String(),
"title": row.MediaItems.Title,
"author": utils.PgTextString(row.MediaItems.Author),
"cover": utils.PgTextString(row.MediaItems.CoverImagePath),
"format": row.MediaItems.FormatGroup,
"progress": row.ViewerPercentage,
})
}
}
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
@@ -329,6 +391,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
"books": bookList,
"book_count": len(bookList),
"is_smart": isSmart,
"up_next": upNext,
"created_at": collection.CreatedAt.Time.String(),
})
}
@@ -347,6 +410,23 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
// A2c §3.3: playlist toggles ride the edit form as top-level fields and
// merge into view_settings server-side (the form can't bind a map).
// The form posts a hidden "false" sibling before the checkbox, so the
// LAST value is the checkbox state.
if v := optionalFlag(req.HideFinished); v != nil {
if req.ViewSettings == nil {
req.ViewSettings = map[string]interface{}{}
}
req.ViewSettings["hide_finished"] = *v
}
if v := optionalFlag(req.ShowUpNext); v != nil {
if req.ViewSettings == nil {
req.ViewSettings = map[string]interface{}{}
}
req.ViewSettings["show_up_next"] = *v
}
collection, err := h.collectionService.UpdateCollection(
c.Request().Context(),
collectionID,
@@ -1238,3 +1318,54 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
}
// ReorderItems applies a static collection's manual playlist order
// (A2c §3.2). Owner-only; the payload is the COMPLETE ordered id list —
// ids omitted keep their relative order appended after (the service never
// deletes membership).
func (h *CollectionHandler) ReorderItems(c *echo.Context) error {
collectionID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
}
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
}
var req struct {
ItemIDs []string `json:"item_ids"`
}
if err := c.Bind(&req); err != nil || len(req.ItemIDs) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "item_ids must be a non-empty ordered id list"})
}
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes))
if err != nil || !owns {
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can reorder it"})
}
ordered := make([]uuid.UUID, 0, len(req.ItemIDs))
for _, raw := range req.ItemIDs {
id, parseErr := uuid.Parse(raw)
if parseErr != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id: " + raw})
}
ordered = append(ordered, id)
}
if err := h.collectionService.ReorderItems(c.Request().Context(), collectionID, ordered); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "reordered"})
}
// optionalFlag interprets a multi-valued form flag: last value wins
// (hidden-false sibling precedes the checkbox), absent → nil (untouched).
func optionalFlag(values []string) *bool {
if len(values) == 0 {
return nil
}
v := strings.EqualFold(values[len(values)-1], "true")
return &v
}
+2
View File
@@ -211,6 +211,8 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID, libra
Items: bookCards,
ViewAllURL: getViewAllURL(ds.CollectionID.String(), currentLibraryID),
Priority: ds.Priority,
IsPlaylist: ds.IsPlaylist,
Complete: ds.Complete,
})
}
+479 -117
View File
@@ -12,6 +12,7 @@ import (
"fmt"
"io"
"log"
"math"
"mime/multipart"
"net/http"
"net/url"
@@ -39,10 +40,14 @@ type CreateMediaItemRequest struct {
MimeType string `json:"mime_type" validate:"required"`
CoverImagePath string `json:"cover_image_path"`
Series string `json:"series"`
SeriesNumber int32 `json:"series_number"`
SeriesNumber float64 `json:"series_number"`
Tags []string `json:"tags"`
ASIN string `json:"asin"`
DatePublished string `json:"date_published"`
Subtitle string `json:"subtitle"`
Explicit bool `json:"explicit"`
Authors []string `json:"authors"` // write truth; legacy `author` still accepted
Publishers []string `json:"publishers"` // write truth; legacy `publisher` still accepted
Publisher string `json:"publisher"`
Contributors []string `json:"contributors"`
// NEW: Reading direction and comic metadata fields
@@ -69,16 +74,20 @@ type CreateMediaItemRequest struct {
type UpdateMediaItemRequest struct {
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
Author string `form:"author" json:"author"`
Authors []string `form:"authors" json:"authors"` // write truth; legacy `author` still accepted
Subtitle string `form:"subtitle" json:"subtitle"`
Explicit bool `form:"explicit" json:"explicit"`
ISBN string `form:"isbn" json:"isbn"`
Description string `form:"description" json:"description"`
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
CoverAction string `form:"cover_action" json:"cover_action"`
Series string `form:"series" json:"series"`
SeriesNumber int32 `form:"series_number" json:"series_number"`
SeriesNumber float64 `form:"series_number" json:"series_number"`
Tags []string `form:"tags" json:"tags"`
ASIN string `form:"asin" json:"asin"`
DatePublished string `form:"date_published" json:"date_published"`
Publisher string `form:"publisher" json:"publisher"`
Publishers []string `form:"publishers" json:"publishers"` // write truth; legacy `publisher` still accepted
Contributors []string `form:"contributors" json:"contributors"`
Language string `form:"language" json:"language"`
Edition string `form:"edition" json:"edition"`
@@ -218,11 +227,11 @@ type MediaHandler struct {
// NewMediaHandler requires the shared ProgressService — progress writes
// must funnel through SaveProgress (the LWW-seeded canonical writer);
// a nil service has nowhere to fall back to and must not be wired.
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler {
func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler {
mh := &MediaHandler{
db: db,
libraryService: libraryService,
searchService: services.NewSearchService(db),
searchService: services.NewSearchService(db, raw),
progressSvc: progressSvc,
annotationSvc: annotationSvc,
}
@@ -588,13 +597,16 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
updateParams := database.UpdateMediaItemParams{
ID: mediaUUID,
Title: existingMedia.Title,
Author: existingMedia.Author,
Subtitle: existingMedia.Subtitle,
Authors: existingMedia.Authors,
AuthorsSearch: existingMedia.AuthorsSearch,
Genre: existingMedia.Genre,
Language: existingMedia.Language,
Tags: existingMedia.Tags,
TagsSearch: existingMedia.TagsSearch,
Description: existingMedia.Description,
Publisher: existingMedia.Publisher,
Publishers: existingMedia.Publishers,
PublishersSearch: existingMedia.PublishersSearch,
CopyrightYear: existingMedia.CopyrightYear,
Isbn: existingMedia.Isbn,
Series: existingMedia.Series,
@@ -623,6 +635,7 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
AlternateInfo: existingMedia.AlternateInfo,
ScanInformation: existingMedia.ScanInformation,
Summary: existingMedia.Summary,
Explicit: existingMedia.Explicit,
}
// Bulk edits are user customizations: keep existing overrides and mark
@@ -633,7 +646,8 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
}
if update.Updates.Author != nil {
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
updateParams.Authors = []string{*update.Updates.Author}
updateParams.AuthorsSearch = utils.NormalizeTagsSearch(updateParams.Authors)
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
}
if update.Updates.Genre != nil {
@@ -831,7 +845,9 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
"id": uuid.UUID(item.ID.Bytes).String(),
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
"title": item.Title,
"subtitle": utils.PgTextString(item.Subtitle),
"author": utils.PgTextString(item.Author),
"authors": item.Authors,
"isbn": utils.PgTextString(item.Isbn),
"description": utils.PgTextString(item.Description),
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
@@ -844,6 +860,8 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
"asin": utils.PgTextString(item.Asin),
"date_published": item.DatePublished.Time.Format("2006-01-02"),
"publisher": utils.PgTextString(item.Publisher),
"publishers": item.Publishers,
"explicit": item.Explicit,
"contributors": item.Contributors,
"language": utils.PgTextString(item.Language),
"edition": utils.PgTextString(item.Edition),
@@ -903,7 +921,9 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
"id": uuid.UUID(item.ID.Bytes).String(),
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
"title": item.Title,
"subtitle": utils.PgTextString(item.Subtitle),
"author": utils.PgTextString(item.Author),
"authors": item.Authors,
"isbn": utils.PgTextString(item.Isbn),
"description": utils.PgTextString(item.Description),
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
@@ -916,6 +936,8 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
"asin": utils.PgTextString(item.Asin),
"date_published": item.DatePublished.Time.Format("2006-01-02"),
"publisher": utils.PgTextString(item.Publisher),
"publishers": item.Publishers,
"explicit": item.Explicit,
"contributors": item.Contributors,
"language": utils.PgTextString(item.Language),
"edition": utils.PgTextString(item.Edition),
@@ -1415,7 +1437,9 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
created, err := mh.db.CreateMediaItemIfNew(c.Request().Context(), database.CreateMediaItemIfNewParams{
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Subtitle: pgtype.Text{String: req.Subtitle, Valid: req.Subtitle != ""},
Authors: utils.SingleOrSlice(req.Author, req.Authors),
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Author, req.Authors)),
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
FilePath: req.FilePath,
@@ -1423,15 +1447,17 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
SeriesNumber: pgtype.Float8{Float64: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: req.Tags,
TagsSearch: tagsSearch,
Narrators: narrators,
NarratorsSearch: narratorsSearch,
Abridged: req.Abridged,
Explicit: req.Explicit,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Publishers: utils.SingleOrSlice(req.Publisher, req.Publishers),
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Publisher, req.Publishers)),
Contributors: req.Contributors,
ContributorsSearch: contributorsSearch,
AddedByAdminID: user.ID,
@@ -1541,20 +1567,24 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
params := database.UpdateMediaItemParams{
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
Subtitle: pgtype.Text{String: req.Subtitle, Valid: req.Subtitle != ""},
Authors: utils.SingleOrSlice(req.Author, req.Authors),
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Author, req.Authors)),
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
CoverImagePath: pgtype.Text{String: coverPath, Valid: coverPath != ""},
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
SeriesNumber: pgtype.Float8{Float64: req.SeriesNumber, Valid: req.SeriesNumber > 0},
Tags: req.Tags,
TagsSearch: tagsSearch,
Narrators: narrators,
NarratorsSearch: narratorsSearch,
Abridged: req.Abridged,
Explicit: req.Explicit,
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
Publishers: utils.SingleOrSlice(req.Publisher, req.Publishers),
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Publisher, req.Publishers)),
Contributors: req.Contributors,
ContributorsSearch: contributorsSearch,
Language: pgtype.Text{String: req.Language, Valid: req.Language != ""},
@@ -1581,6 +1611,14 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
}
// audio_metadata is scan-owned (track manifest, chapters, duration)
// and has no editor field: the full-row update writes it
// unconditionally, so an unguarded save NULLed every audiobook's
// payload — books dropped back to "1 second long" on a plain
// metadata edit (2026-10-21 incident class). Write-after-success:
// carry the existing bytes; only the audio chain replaces them.
params.AudioMetadata = existing.AudioMetadata
// Fields the user actually changed become overrides so future scans keep
// the custom values. An explicit cover upload/removal always overrides.
overrides := utils.DetectMetadataOverrides(params, existing)
@@ -2299,102 +2337,53 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
}
// Detect autocomplete queries first (author=value, genre=value, etc.)
if author := c.QueryParam("author"); author != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "author", author)
}
if genre := c.QueryParam("genre"); genre != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre)
}
if tags := c.QueryParam("tags"); tags != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags)
}
if narrator := c.QueryParam("narrator"); narrator != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "narrator", narrator)
}
if series := c.QueryParam("series"); series != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "series", series)
}
if language := c.QueryParam("language"); language != "" {
return mh.handleFieldValuesSearch(c, userID.ID, "language", language)
// Autocomplete/suggestion queries: ANY registry field key in its
// bare form (author=, genre=, note_text=, collection=, ...) routes
// to the field-values path — static for the classic six without a
// rule tree, composed under the tree otherwise (faceted plan
// Phase 9: same route, no new API surface).
for _, key := range services.FilterFieldKeys() {
if v := c.QueryParam(key); v != "" {
return mh.handleFieldValuesSearch(c, userID.ID, key, v)
}
}
// Handle media item search with filters
query := c.QueryParam("q")
libraryID := c.QueryParam("library_id")
params := ParseMediaSearchParams(c)
params.UserID = userID.ID
// Validate library_id if provided
var libUUID pgtype.UUID
if libraryID != "" {
lib, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
} else {
libUUID = pgtype.UUID{Valid: false}
// Filter rule builder: a malformed qf is a 400 (bad request), never
// a silently-ignored token and never a 500.
if params.FilterParseErr != "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid filter: " + params.FilterParseErr,
})
}
c.Logger().Info("Search parameters",
"query", query,
"library_id", libraryID,
"libUUID_valid", libUUID.Valid,
"libUUID_bytes", libUUID.Bytes)
"query", params.SearchQuery,
"library_id", libraryIDString(c),
"libUUID_valid", params.LibraryID.Valid,
"libUUID_bytes", params.LibraryID.Bytes)
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if limit == 0 {
limit = 50
}
offset, _ := strconv.Atoi(c.QueryParam("offset"))
// Extract filter parameters
authorFilter := c.QueryParam("author_filter")
seriesFilter := c.QueryParam("series_filter")
genreFilter := c.QueryParam("genre_filter")
publisherFilter := c.QueryParam("publisher_filter")
tagsFilter := c.QueryParam("tags_filter")
narratorFilter := c.QueryParam("narrator_filter")
languageFilter := c.QueryParam("language_filter")
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
hasCover := false
hasCoverValid := false
if _, exists := c.QueryParams()["has_cover"]; exists {
hasCover = c.QueryParam("has_cover") == "true"
hasCoverValid = true
}
// Extract sort parameter
sortParam := c.QueryParam("sort")
if sortParam == "" {
sortParam = "title ASC" // Default sort
}
// Build search params
params := services.SearchParams{
UserID: userID.ID,
LibraryID: libUUID,
SearchQuery: query,
AuthorFilter: authorFilter,
SeriesFilter: seriesFilter,
GenreFilter: genreFilter,
PublisherFilter: publisherFilter,
TagsFilter: tagsFilter,
NarratorFilter: narratorFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
Sort: sortParam,
Limit: limit,
Offset: offset,
// Family-lens gate (AGENTS.md → Library visibility): an explicit
// library_id outside the requester's visible set is a 403, never a
// silent empty set.
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"})
}
}
c.Logger().Info("SearchMediaItems called",
"query", query,
"library_id_provided", libraryID != "",
"library_id_uuid", libUUID.Valid,
"library_id_bytes", libUUID.Bytes)
"query", params.SearchQuery,
"library_id_provided", params.LibraryID.Valid,
"library_id_uuid", params.LibraryID.Valid,
"library_id_bytes", params.LibraryID.Bytes)
// Call shared search service
results, _, err := mh.searchService.ExecuteSearch(c.Request().Context(), params)
@@ -2405,7 +2394,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
if len(results) == 0 {
return c.JSON(http.StatusNotFound, map[string]interface{}{
"error": "no results found",
"query": query,
"query": params.SearchQuery,
"results": []interface{}{},
})
}
@@ -2416,18 +2405,240 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
return c.JSON(http.StatusOK, results)
}
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
// Returns distinct field values with counts and similarity scores for dropdown population
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
// libraryIDString returns the raw library_id query param (logging only).
func libraryIDString(c *echo.Context) string { return c.QueryParam("library_id") }
// searchParamSource abstracts the query-param surface (echo.Context)
// so the shared parser serves both handlers and router.
type searchParamSource interface {
QueryParam(name string) string
QueryParams() url.Values
}
// parseMediaSearchParams parses the unified search's query params into
// services.SearchParams — the SINGLE parse for both the JSON endpoint
// (MediaHandler.SearchMediaItems) and the HTMX grid partial
// (router handleSearchHTML), so the two branches can never disagree.
// Text facet params: comma = OR-within (the SQL splits via
// string_to_array). Bool facet params: presence-bound (absent = no
// filter, "true"/"false" = bound). Facet names come from the facet
// registry (services/facet_registry.go) — adding a facet there means
// one line here, mirroring its narg block in queries.sql.
func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if limit == 0 {
limit = 50
}
offset, _ := strconv.Atoi(c.QueryParam("offset"))
sortParam := c.QueryParam("sort")
if sortParam == "" {
sortParam = "title ASC" // Default sort
}
var libUUID pgtype.UUID
if libraryID := c.QueryParam("library_id"); libraryID != "" {
if lib, err := uuid.Parse(libraryID); err == nil {
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
}
}
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
boolFacet := func(name string) pgtype.Bool {
if _, exists := c.QueryParams()[name]; !exists {
return pgtype.Bool{Valid: false}
}
return pgtype.Bool{Bool: c.QueryParam(name) == "true", Valid: true}
}
params := services.SearchParams{
SearchQuery: c.QueryParam("q"),
LibraryID: libUUID,
AuthorFilter: c.QueryParam("author_filter"),
SeriesFilter: c.QueryParam("series_filter"),
GenreFilter: c.QueryParam("genre_filter"),
PublisherFilter: c.QueryParam("publisher_filter"),
TagsFilter: c.QueryParam("tags_filter"),
NarratorFilter: c.QueryParam("narrator_filter"),
LanguageFilter: c.QueryParam("language_filter"),
YearMin: yearMin,
YearMax: yearMax,
HasCover: boolFacet("has_cover"),
StoryArcFilter: c.QueryParam("story_arc_filter"),
ImprintFilter: c.QueryParam("imprint_filter"),
EditionFilter: c.QueryParam("edition_filter"),
AgeRatingFilter: c.QueryParam("age_rating_filter"),
FormatGroupFilter: c.QueryParam("format_group_filter"),
MangaTypeFilter: c.QueryParam("manga_type_filter"),
ReadingDirectionFilter: c.QueryParam("reading_direction_filter"),
ExplicitFilter: boolFacet("explicit_filter"),
AbridgedFilter: boolFacet("abridged_filter"),
Sort: sortParam,
Limit: limit,
Offset: offset,
}
// Filter rule builder (faceted plan Phase 9): the qf param carries
// the rule tree. Parse + validate HERE (the shared parser — every
// entry point validates identically); a failure lands in
// FilterParseErr for the endpoints to answer 400, never a silently
// ignored token. Precedence: qf present → flat facet params are
// ignored by the engine (qf wins).
if qf := c.QueryParam("qf"); qf != "" {
tree, err := services.ParseFilterTree(qf)
if err != nil {
params.FilterParseErr = err.Error()
} else {
params.FilterTree = tree
}
}
return params
}
// libraryVisible reports whether the requested library is inside the
// requester's visible-library set. Slice compare on pgtype.UUID bytes —
// immune to string-format differences between the query param and the
// membership map keys.
func (mh *MediaHandler) libraryVisible(c *echo.Context, libraryID pgtype.UUID) (bool, error) {
_, ids, err := mh.visibleLibrarySet(c)
if err != nil {
return false, err
}
for _, id := range ids {
if id == libraryID {
return true, nil
}
}
return false, nil
}
// GetFacets handles GET /api/media-items/facets — the filter panel's
// value+count listing (faceted-navigation Batch C, extended in Phase 7).
//
// Two shapes:
// - ?facet=X&library_id=… → the PINNED single-facet shape
// ({facet, values[]}); facet must name a FacetArray or FacetScalar
// entry (bool/range facets render as toggles, not lists).
// - no facet param → the all-facets shape ({"facets": {param:
// [value,…]}}), computed under the ACTIVE FILTERS (the same query
// params the search endpoint takes) — each facet counts under the
// others, the SQL omitting each arm's own block. library_id is
// optional: absent = the All-Libraries view.
//
// Family-lens: an explicit library_id outside the visible set is 403
// in both shapes.
func (mh *MediaHandler) GetFacets(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"})
}
facetParam := c.QueryParam("facet")
libraryID := c.QueryParam("library_id")
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if limit == 0 {
limit = 50
}
if facetParam == "" {
// All-facets shape: filters bind exactly like the search
// endpoint's (ParseMediaSearchParams), so the panel's counts
// and the grid's rows can never disagree about what's active.
var libPg pgtype.UUID
if libraryID != "" {
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libPg = pgtype.UUID{Bytes: libUUID, Valid: true}
visible, err := mh.libraryVisible(c, libPg)
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"})
}
}
counts, err := mh.searchService.FacetCounts(c.Request().Context(), user.ID, libPg, ParseMediaSearchParams(c), limit)
if err != nil {
c.Logger().Error("facet counts error", "error", err.Error())
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"facets": counts,
})
}
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
}
facet := services.FacetByParam(facetParam)
if facet == nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown facet: " + facetParam})
}
if facet.Kind != services.FacetArray && facet.Kind != services.FacetScalar {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "facet does not list values: " + facetParam})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libPg := pgtype.UUID{Bytes: libUUID, Valid: true}
visible, err := mh.libraryVisible(c, libPg)
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"})
}
values, err := mh.searchService.FacetValues(c.Request().Context(), user.ID, *facet, libPg, limit)
if err != nil {
c.Logger().Error("facet values error", "error", err.Error(), "facet", facetParam)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"facet": facetParam,
"values": values,
})
}
// handleFieldValuesSearch handles autocomplete/suggestion queries
// (author=value, genre=value, note_text=value, …). Returns distinct
// field values with counts and similarity scores for dropdown
// population.
//
// library_id is OPTIONAL: absent means "every library the requesting user
// can see" — the per-user family lens is enforced inside the query (the
// library_visibility join), so the All-Books view's suggestions aggregate
// the visible world. An explicit library_id outside the visible set is a
// 403 (AGENTS standing rule: never silently pass).
//
// Routing (faceted plan Phase 9): with a qf rule tree, or for fields
// beyond the classic six, suggestions come from the COMPOSED engine —
// the other rules bind as filters (symmetric: every rule's menu sees
// every other rule; zero-match values are never offered). Without a
// tree, the classic six keep their static queries (pinned contract).
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
var libPg pgtype.UUID
if libraryID := c.QueryParam("library_id"); libraryID != "" {
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
libPg = pgtype.UUID{Bytes: libUUID, Valid: true}
visible, err := mh.libraryVisible(c, libPg)
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"})
}
}
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if limit == 0 {
@@ -2435,27 +2646,98 @@ func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.U
}
offset, _ := strconv.Atoi(c.QueryParam("offset"))
params := services.FieldSearchParams{
UserID: userID,
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
FieldType: fieldType,
SearchQuery: searchQuery,
Limit: limit,
Offset: offset,
// qf: the OTHER completed rules (the client strips the rule being
// edited). Parse + validate here — a malformed tree is a 400.
var tree *services.FilterTree
if qf := c.QueryParam("qf"); qf != "" {
t, err := services.ParseFilterTree(qf)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid filter: " + err.Error()})
}
tree = t
}
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
// Static path: the classic six with a REAL typed query and no rule
// tree (unchanged contract — the tag-dropdown island and deep links
// still ride it). A BLANK typed query is BROWSE (the builder's
// click-the-box top-values list): the static queries would
// similarity-match a single space into nothing, so browse rides the
// composed engine, which trims the query and answers the top values
// under the rules.
staticSix := tree == nil && strings.TrimSpace(searchQuery) != ""
if staticSix {
switch fieldType {
case "author", "genre", "tags", "narrator", "series", "language":
params := services.FieldSearchParams{
UserID: userID,
LibraryID: libPg,
FieldType: fieldType,
SearchQuery: searchQuery,
Limit: limit,
Offset: offset,
}
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
if err != nil {
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(results),
})
}
}
// Composed path: registry field under the other rules.
results, err := mh.searchService.ComposedFieldValues(c.Request().Context(), services.ComposedSuggestParams{
UserID: userID,
LibraryID: libPg,
FieldKey: fieldType,
Typed: searchQuery,
Tree: tree,
Limit: limit,
})
if err != nil {
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"results": results,
"total": len(results),
})
}
// FilterCount handles GET /api/media-items/filter-count — the rule
// builder's live match count: the composed rules + the library scope,
// no pagination (a true COUNT(*), not the page length).
func (mh *MediaHandler) FilterCount(c *echo.Context) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
}
params := ParseMediaSearchParams(c)
if params.FilterParseErr != "" {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "invalid filter: " + params.FilterParseErr,
})
}
if params.LibraryID.Valid {
visible, err := mh.libraryVisible(c, params.LibraryID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if !visible {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
}
}
count, err := mh.searchService.ComposedCount(c.Request().Context(), user.ID, params.LibraryID, params.FilterTree)
if err != nil {
c.Logger().Error("filter count error", "error", err.Error())
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"count": count})
}
// getFullFilePath returns the absolute filesystem path for a media item
// Uses LibraryService for resolution (one source of truth)
func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
@@ -2709,16 +2991,96 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
})
}
// Playback speed (plan §8): resolve per-book row → user default →
// 1.0 IN the payload so the player applies it with zero extra round
// trips; default_playback_speed powers the dropdown's "(default)"
// marker. Speed is a preference, so this rides the authenticated
// user — anonymous readers just get 1.0.
playbackSpeed, defaultSpeed := 1.0, 1.0
if userID, ok := c.Get("user_id").(string); ok {
if userUUID, err := uuid.Parse(userID); err == nil {
userRow, err := mh.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err == nil && userRow.DefaultAudioSpeed > 0 {
defaultSpeed = float64(userRow.DefaultAudioSpeed)
}
bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
})
if err == nil && bookSpeed > 0 {
playbackSpeed = float64(bookSpeed)
} else {
playbackSpeed = defaultSpeed
}
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": item.ID,
"chapters": payload.Chapters,
"duration_seconds": payload.DurationSeconds,
"codec_profile": payload.CodecProfile,
"layout": payload.Layout,
"tracks": tracks,
"media_item_id": item.ID,
"chapters": payload.Chapters,
"duration_seconds": payload.DurationSeconds,
"codec_profile": payload.CodecProfile,
"layout": payload.Layout,
"tracks": tracks,
"playback_speed": playbackSpeed,
"default_playback_speed": defaultSpeed,
})
}
// SetAudioSpeed handles PUT /api/media-items/:id/audio-speed — the
// per-user per-book playback speed upsert (plan §8). The client SPEEDS
// list is the real gate; the server sanity-checks the range.
func (mh *MediaHandler) SetAudioSpeed(c *echo.Context) error { mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
userID, ok := c.Get("user_id").(string)
if !ok || userID == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"})
}
userUUID, err := uuid.Parse(userID)
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user"})
}
var req struct {
Speed float64 `json:"speed"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if req.Speed < 0.25 || req.Speed > 4 || math.IsNaN(req.Speed) || math.IsInf(req.Speed, 0) {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "speed out of range"})
}
// The book must exist (and be visible): a preference for a hidden
// library must not be writable — family-lens rule.
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
if item.LibraryID.Valid {
visibleSet, _, err := mh.visibleLibrarySet(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
}
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
}
speed, err := mh.db.UpsertAudioPlaybackSpeed(c.Request().Context(), database.UpsertAudioPlaybackSpeedParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Speed: float32(req.Speed),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save playback speed"})
}
return c.JSON(http.StatusOK, map[string]float64{"playback_speed": float64(speed)})
}
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
// per-track byte stream for multi-file audiobooks (folder books serve the
// real file; zip books extract the entry in place). Library visibility is
+85 -8
View File
@@ -9,6 +9,8 @@ import (
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
"bookhoard/internal/services"
)
type ProcessingIssuesHandler struct {
@@ -19,6 +21,45 @@ func NewProcessingIssuesHandler(db *database.Queries) *ProcessingIssuesHandler {
return &ProcessingIssuesHandler{db: db}
}
// visibleLibrarySetFor resolves the requesting user's visible-library
// set (family-lens) from the request context identity. The issues
// endpoints leak media metadata (titles, paths) — a hidden library's
// rows must never reach a user it's hidden from, same rule as every
// media surface. Identity follows the router's visibleLibraryIDsFor
// (the JWT middleware sets "user_id"; AdminMiddleware only reads the
// role — there is no "user" object on this chain).
func (h *ProcessingIssuesHandler) visibleLibrarySetFor(c *echo.Context) (map[string]bool, error) {
userUUIDStr, ok := c.Get("user_id").(string)
if !ok {
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
userU, err := uuid.Parse(userUUIDStr)
if err != nil {
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
set, _, err := services.NewLibraryService(h.db).VisibleLibrarySet(
c.Request().Context(), pgtype.UUID{Bytes: userU, Valid: true})
return set, err
}
// gateLibrary enforces the family lens on a library-scoped request:
// an explicit library id outside the requester's visible set is a 403,
// never a silent pass (faceted plan family-lens rule).
func (h *ProcessingIssuesHandler) gateLibrary(c *echo.Context, libraryIDStr string) error {
libUUID, err := uuid.Parse(libraryIDStr)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid library ID")
}
visibleSet, visErr := h.visibleLibrarySetFor(c)
if visErr != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to check library visibility")
}
if !visibleSet[libUUID.String()] {
return echo.NewHTTPError(http.StatusForbidden, "Library not accessible")
}
return nil
}
type ProcessingIssueResponse struct {
ID string `json:"id"`
MediaItemID string `json:"media_item_id"`
@@ -40,10 +81,10 @@ type ProcessingIssueStats struct {
// ListProcessingIssues returns all processing issues for a library
func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
libraryID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
if err := h.gateLibrary(c, c.Param("id")); err != nil {
return err
}
libraryID, _ := uuid.Parse(c.Param("id"))
issues, err := h.db.ListProcessingIssuesByLibrary(
c.Request().Context(),
@@ -55,10 +96,21 @@ func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
response := make([]ProcessingIssueResponse, len(issues))
for i, issue := range issues {
// Row-less import failures have no media item — emit an empty id
// rather than the zero UUID, and fall back the display title to
// the file path so the ledger reads like itself.
itemID := ""
if issue.MediaItemID.Valid {
itemID = issue.MediaItemID.String()
}
title := issue.Title
if title == "" {
title = issue.FilePath
}
response[i] = ProcessingIssueResponse{
ID: issue.ID.String(),
MediaItemID: issue.MediaItemID.String(),
Title: issue.Title,
MediaItemID: itemID,
Title: title,
FilePath: issue.FilePath,
FormatGroup: issue.FormatGroup,
LibraryTypeName: issue.LibraryTypeName,
@@ -74,10 +126,10 @@ func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
// GetProcessingIssueStats returns statistics about processing issues
func (h *ProcessingIssuesHandler) GetProcessingIssueStats(c *echo.Context) error {
libraryID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
if err := h.gateLibrary(c, c.Param("id")); err != nil {
return err
}
libraryID, _ := uuid.Parse(c.Param("id"))
stats, err := h.db.GetProcessingIssueStats(
c.Request().Context(),
@@ -106,6 +158,10 @@ func (h *ProcessingIssuesHandler) ResolveProcessingIssue(c *echo.Context) error
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
}
if err := h.gateIssue(c, issueID); err != nil {
return err
}
_, err = h.db.ResolveProcessingIssue(
c.Request().Context(),
database.ResolveProcessingIssueParams{
@@ -130,6 +186,10 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"})
}
if err := h.gateIssue(c, issueID); err != nil {
return err
}
_, err = h.db.DeleteProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to delete issue"})
@@ -141,6 +201,23 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
})
}
// gateIssue enforces the family lens on an issue-addressed request: the
// issue's library must be in the requester's visible set (403 otherwise).
func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID) error {
issue, err := h.db.GetProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "Issue not found")
}
visibleSet, visErr := h.visibleLibrarySetFor(c)
if visErr != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to check library visibility")
}
if !issue.LibraryID.Valid || !visibleSet[uuid.UUID(issue.LibraryID.Bytes).String()] {
return echo.NewHTTPError(http.StatusForbidden, "Library not accessible")
}
return nil
}
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
return h.db.GetProcessingIssueStats(ctx, libraryID)
+18
View File
@@ -84,6 +84,23 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "either library_id or folder_paths required for scanning"})
}
// Coalescing (plan Part 2 §5): a scan for this library is already
// pending or running — 409 with the active job so the UI can show
// progress instead of stacking a second full scan.
if req.LibraryID != "" && h.worker.HasActiveScanForLibrary(req.LibraryID) {
var activeJobID string
for _, active := range h.worker.GetActiveScanJobs() {
if active.JobType == services.JobTypeScan && active.LibraryID == req.LibraryID {
activeJobID = active.JobID
break
}
}
return c.JSON(http.StatusConflict, map[string]string{
"error": "a scan for this library is already pending or running",
"active_job_id": activeJobID,
})
}
jobID := uuid.New().String()
job := &services.Job{
@@ -96,6 +113,7 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
"admin_id": userUUID.String(),
"db": h.db,
"force": req.Force,
"trigger": "admin",
},
Status: services.JobStatusPending,
Context: h.ctx,
+1
View File
@@ -16,6 +16,7 @@ func registerCollectionsRoutes(cfg *Config) {
collections.DELETE("/:id", cfg.CollectionHandler.DeleteCollection)
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
+34 -3
View File
@@ -605,6 +605,19 @@ func registerFrontendRoutes(cfg *Config) {
Icon: utils.PgTextString(collection.Icon),
}
// A2c §3.3: playlist toggles from view_settings.
if len(collection.ViewSettings) > 0 {
var vs map[string]interface{}
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
if b, ok := vs["hide_finished"].(bool); ok {
colData.HideFinished = b
}
if b, ok := vs["show_up_next"].(bool); ok {
colData.ShowUpNext = b
}
}
}
// Ensure default color if empty
if colData.Color == "" {
colData.Color = "blue"
@@ -1210,6 +1223,15 @@ func registerFrontendRoutes(cfg *Config) {
if err != nil {
return renderErrorPage(c, "Invalid library ID", "invalid_id")
}
// Family lens: issues leak media metadata (titles, file paths) —
// a library outside the requester's visible set renders nothing.
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
if visErr != nil {
return renderErrorPage(c, "Error checking library access", "visibility_error")
}
if !visibleSet[libraryID] {
return renderErrorPage(c, "Library not accessible", "library_not_accessible")
}
// Fetch stats
stats, err := cfg.Queries.GetProcessingIssueStats(
c.Request().Context(),
@@ -1226,14 +1248,23 @@ func registerFrontendRoutes(cfg *Config) {
if err != nil {
return renderErrorPage(c, "Error loading issues", "issues_load_error")
}
// Convert to template data
// Convert to template data (row-less import failures have no
// media item: empty id, and the title falls back to the path).
issues := make([]templates.ProcessingIssueData, len(dbIssues))
for i, issue := range dbIssues {
issueUUID, _ := uuid.FromBytes(issue.ID.Bytes[0:16])
itemID := ""
if issue.MediaItemID.Valid {
itemID = uuid.UUID(issue.MediaItemID.Bytes).String()
}
title := issue.Title
if title == "" {
title = issue.FilePath
}
issues[i] = templates.ProcessingIssueData{
ID: issueUUID.String(),
MediaItemID: uuid.UUID(issue.MediaItemID.Bytes).String(),
Title: issue.Title,
MediaItemID: itemID,
Title: title,
FilePath: issue.FilePath,
FormatGroup: issue.FormatGroup,
LibraryTypeName: issue.LibraryTypeName,
+11 -10
View File
@@ -56,16 +56,17 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
}
return templates.User{
ID: userID,
Email: userEmail,
Username: userUsername,
Role: userRole,
Theme: userTheme,
Token: token,
Timezone: userTimezone,
FirstName: utils.PgTextString(userDB.FirstName),
LastName: utils.PgTextString(userDB.LastName),
CreatedAt: userDB.CreatedAt.Time,
ID: userID,
Email: userEmail,
Username: userUsername,
Role: userRole,
Theme: userTheme,
Token: token,
Timezone: userTimezone,
FirstName: utils.PgTextString(userDB.FirstName),
LastName: utils.PgTextString(userDB.LastName),
CreatedAt: userDB.CreatedAt.Time,
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
}, nil
}
+1
View File
@@ -22,6 +22,7 @@ func registerMediaRoutes(cfg *Config) {
// books stream through the existing /uploads Range-serving path;
// multi-file books additionally expose per-track streaming.
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
protected.PUT("/media-items/:id/audio-speed", cfg.MediaHandler.SetAudioSpeed)
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
// Media rating routes (all authenticated users)
+39 -54
View File
@@ -3,15 +3,11 @@ package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/templates"
"bytes"
"log"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
@@ -30,6 +26,12 @@ func registerSearchRoutes(cfg *Config) {
// Otherwise return JSON (API clients)
return cfg.MediaHandler.SearchMediaItems(c)
})
// Facet-value listings for the filter panel (faceted-navigation
// Batch C): /api/media-items/facets?facet=X&library_id=...
protected.GET("/media-items/facets", cfg.MediaHandler.GetFacets)
// Rule-builder live count (faceted plan Phase 9): the composed
// tree's true match count (COUNT(*)), rules + library scope.
protected.GET("/media-items/filter-count", cfg.MediaHandler.FilterCount)
protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks)
}
@@ -39,60 +41,43 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
if !ok {
return c.String(http.StatusUnauthorized, "Unauthorized")
}
// Extract query parameters
query := c.QueryParam("q")
// Extract filters via the SHARED parser (handlers.ParseMediaSearchParams)
// — the HTMX grid partial and the JSON endpoint can never disagree on
// which facet params exist or how they parse. (This branch previously
// dropped publisher_filter and narrator_filter entirely — the shared
// parser fixes that too.)
params := handlers.ParseMediaSearchParams(c)
params.UserID = user.ID
// Filter rule builder: a malformed qf is a 400, never a silently
// ignored token.
if params.FilterParseErr != "" {
return c.HTML(http.StatusBadRequest, `<div style="color: red;">Invalid filter</div>`)
}
limit := params.Limit
offset := params.Offset
libraryID := c.QueryParam("library_id")
limit, _ := strconv.Atoi(c.QueryParam("limit"))
if limit == 0 {
limit = 50
}
offset, _ := strconv.Atoi(c.QueryParam("offset"))
// Validate library_id
var libUUID pgtype.UUID
if libraryID != "" {
lib, err := uuid.Parse(libraryID)
// Family-lens gate: an explicit library_id outside the requester's
// visible set is a 403 (same rule as the JSON branch).
if params.LibraryID.Valid {
_, ids, err := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
if err != nil {
return c.String(http.StatusBadRequest, "invalid library_id")
log.Printf("VisibleLibrarySet failed: %v", err)
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
}
visible := false
for _, id := range ids {
if id == params.LibraryID {
visible = true
break
}
}
if !visible {
return c.HTML(http.StatusForbidden, `<div style="color: red;">Library not visible</div>`)
}
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
} else {
libUUID = pgtype.UUID{Valid: false}
}
// Extract filters
authorFilter := c.QueryParam("author_filter")
seriesFilter := c.QueryParam("series_filter")
genreFilter := c.QueryParam("genre_filter")
tagsFilter := c.QueryParam("tags_filter")
languageFilter := c.QueryParam("language_filter")
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
hasCover := false
hasCoverValid := false
if _, exists := c.QueryParams()["has_cover"]; exists {
hasCover = c.QueryParam("has_cover") == "true"
hasCoverValid = true
}
sortParam := c.QueryParam("sort")
if sortParam == "" {
sortParam = "title ASC"
}
// Build search params
params := services.SearchParams{
UserID: user.ID,
LibraryID: libUUID,
SearchQuery: query,
AuthorFilter: authorFilter,
SeriesFilter: seriesFilter,
GenreFilter: genreFilter,
TagsFilter: tagsFilter,
LanguageFilter: languageFilter,
YearMin: yearMin,
YearMax: yearMax,
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
Sort: sortParam,
Limit: limit,
Offset: offset,
}
// Execute search using handler method
results, totalCount, err := cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
if err != nil {
+49
View File
@@ -155,6 +155,23 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
// UpdateCollection updates a collection
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
// A2c §3.3: the edit form carries only the toggles it renders — merge
// them OVER the stored view_settings so provenance keys
// (imported_from_series) and anything else on the row survive.
if len(viewSettings) > 0 {
existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err == nil && len(existing.ViewSettings) > 0 {
var stored map[string]any
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
merged := stored
for k, v := range viewSettings {
merged[k] = v
}
viewSettings = merged
}
}
}
// Convert rules to JSONB ([]byte)
var rulesJSON []byte
if len(autoAssignRules) > 0 {
@@ -785,3 +802,35 @@ func parseFloat(s string) (float64, error) {
return sign * result / divisor, nil
}
// UserOwnsCollection gates playlist-ordering writes (A2c §3.2): the
// collection row's owner is the only user whose drag actions rewrite
// positions.
func (s *CollectionService) UserOwnsCollection(ctx context.Context, collectionID, userID uuid.UUID) (bool, error) {
collection, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
if err != nil {
return false, err
}
return uuid.UUID(collection.UserID.Bytes) == userID, nil
}
// ReorderItems applies a full playlist order (A2c §3.2): the payload is the
// complete ordered id list; positions become 1..n in payload order. Ids
// not in the payload keep their relative order after — the SQL only
// touches the given ids, and NULL positions sort last. Never deletes.
func (s *CollectionService) ReorderItems(ctx context.Context, collectionID uuid.UUID, orderedIDs []uuid.UUID) error {
if len(orderedIDs) == 0 {
return nil
}
ids := make([]pgtype.UUID, 0, len(orderedIDs))
for _, id := range orderedIDs {
ids = append(ids, pgtype.UUID{Bytes: id, Valid: true})
}
if err := s.db.ReorderCollectionItems(ctx, database.ReorderCollectionItemsParams{
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
MediaItemIds: ids,
}); err != nil {
return fmt.Errorf("failed to reorder collection items: %v", err)
}
return nil
}
+64
View File
@@ -24,6 +24,34 @@ func NewDashboardService(db *database.Queries) *DashboardService {
}
}
// viewSettingsFlag reads a boolean key from a collection's view_settings
// JSONB. Malformed or absent → false.
func viewSettingsFlag(raw []byte, key string) bool {
if len(raw) == 0 {
return false
}
var settings map[string]interface{}
if err := json.Unmarshal(raw, &settings); err != nil {
return false
}
b, ok := settings[key].(bool)
return ok && b
}
// viewSettingsHasKey reports whether the key exists at all (any value) —
// used for provenance markers like imported_from_series.
func viewSettingsHasKey(raw []byte, key string) bool {
if len(raw) == 0 {
return false
}
var settings map[string]interface{}
if err := json.Unmarshal(raw, &settings); err != nil {
return false
}
_, ok := settings[key]
return ok
}
type DashboardSection struct {
CollectionID uuid.UUID
CollectionName string
@@ -34,6 +62,13 @@ type DashboardSection struct {
Title string
Description string
Icon string
// A2c: the collection's own ordering-construct marker — the section is
// a curated playlist ("The Witcher (Reading Order)"), rendered with a
// badge by the frontend.
IsPlaylist bool
// A2c §3.5: hide_finished filtered every item out of a NON-empty
// collection — render "playlist complete" instead of dropping silently.
Complete bool
}
func (s *DashboardService) GetDashboardSections(
@@ -81,7 +116,29 @@ func (s *DashboardService) GetDashboardSections(
continue
}
// A2c §3.5: hide_finished filtered everything out — but the
// collection is NOT empty. That's a finished playlist: render a
// completion marker instead of silently vanishing.
if len(items) == 0 {
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
if hideFinished {
collUUIDBytes, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
if count, countErr := s.db.CountCollectionItems(ctx, pgtype.UUID{Bytes: collUUIDBytes, Valid: true}); countErr == nil && count > 0 {
results = append(results, DashboardSection{
CollectionID: coll.ID.Bytes,
CollectionName: coll.Name,
Items: []database.MediaItems{},
QueryType: coll.QueryType.String,
Priority: int(coll.Priority.Int32),
IsSystem: false,
Title: coll.Name,
Description: coll.Description.String,
Icon: coll.Icon.String,
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
Complete: true,
})
}
}
continue
}
@@ -95,6 +152,7 @@ func (s *DashboardService) GetDashboardSections(
Title: coll.Name,
Description: coll.Description.String,
Icon: coll.Icon.String,
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
})
}
@@ -218,9 +276,15 @@ func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, co
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
// A2c §3.3: per-collection hide_finished (view_settings), evaluated
// against the VIEWER's reading progress. The section then shows the
// head of the reading queue in playlist order.
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: libraryID,
ViewerUserID: pgtype.UUID{Bytes: userID, Valid: true},
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: true},
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
})
if err != nil {
+153
View File
@@ -0,0 +1,153 @@
package services
// Facet registry (faceted-navigation plan, Part 1 Batch A).
//
// The single declarative source of truth for bookshelf filter facets.
// sqlc has NO dynamic WHERE — each registry entry corresponds to an
// explicit `sqlc.narg('<param>_filter')` block in
// `SearchMediaItemsUnified` (internal/database/queries/queries.sql);
// the drift-guard test (facet_registry_test.go) fails the build when an
// entry's block is missing. Adding a facet = one registry entry + one
// narg block + one handler line (+ one templ call in Batch B).
//
// Wire semantics: the query param is `<Param>_filter`; a comma-joined
// value is multi-select (OR within the facet, AND across facets) for
// every text kind — the SQL splits via string_to_array. Bools are
// presence-bound: absent → NULL (no filtering), "true"/"false" → bound.
import "strings"
// FacetKind classifies the media_items column shape a facet filters on.
type FacetKind string
const (
FacetArray FacetKind = "array" // TEXT[] column, per-element match
FacetScalar FacetKind = "scalar" // varchar column
FacetBool FacetKind = "bool" // bool column
FacetRange FacetKind = "range" // int min/max pair (year_min/year_max)
)
// FacetMatch classifies how values are compared within the kind's SQL
// shape.
type FacetMatch string
const (
MatchPerElementFuzzy FacetMatch = "per_element_fuzzy" // word_similarity per array element
MatchScalarFuzzy FacetMatch = "scalar_fuzzy" // word_similarity against the column
MatchExact FacetMatch = "exact" // = / ANY() / bool equality
)
// Facet is one registry entry.
type Facet struct {
Param string // "publisher" → ?publisher_filter= (see WireParam)
// Wire is an optional full query-param override for facets whose
// wire name predates the _filter convention (has_cover). Existing
// facets migrate in with their frozen wire names — behavior does
// not change.
Wire string
Kind FacetKind
Match FacetMatch
// Column is the media_items column(s) the facet filters (documentation).
Column string
// LibraryTypes limits the facet to those library_types.name values
// (e.g. manga_type only on "manga"). Empty/nil = every library type.
// The All-Libraries view shows the UNION of all facets — matching is
// type-partitioned by the data, so cross-type filtering is correct
// by construction (plan Batch D decision).
LibraryTypes []string
}
// facetRegistry — the inventory. Order is display order for the filter
// panel (Batch B); the existing facets come first (they shipped
// 646477af..7537063c and their wire behavior is frozen).
var facetRegistry = []Facet{
{Param: "author", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "authors"},
{Param: "publisher", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "publishers"},
{Param: "narrator", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "narrators", LibraryTypes: []string{"audiobooks"}},
{Param: "tags", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "tags"},
{Param: "series", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "series"},
{Param: "genre", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "genre"},
{Param: "language", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "language"},
{Param: "year", Kind: FacetRange, Match: MatchExact, Column: "date_published / copyright_year"},
{Param: "has_cover", Wire: "has_cover", Kind: FacetBool, Match: MatchExact, Column: "cover_image_path"},
// Batch A additions:
{Param: "story_arc", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "story_arc"},
{Param: "imprint", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "imprint"},
{Param: "edition", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "edition"},
{Param: "age_rating", Kind: FacetScalar, Match: MatchExact, Column: "age_rating"},
{Param: "format_group", Kind: FacetScalar, Match: MatchExact, Column: "format_group"},
{Param: "manga_type", Kind: FacetScalar, Match: MatchExact, Column: "manga_type", LibraryTypes: []string{"manga"}},
{Param: "reading_direction", Kind: FacetScalar, Match: MatchExact, Column: "reading_direction", LibraryTypes: []string{"manga", "comics"}},
{Param: "explicit", Kind: FacetBool, Match: MatchExact, Column: "explicit"},
{Param: "abridged", Kind: FacetBool, Match: MatchExact, Column: "abridged"},
}
// Facets returns a copy of the registry.
func Facets() []Facet {
out := make([]Facet, len(facetRegistry))
copy(out, facetRegistry)
return out
}
// WireParam is the query-param name for the facet: the Wire override
// when set, else `<Param>_filter`.
func (f Facet) WireParam() string {
if f.Wire != "" {
return f.Wire
}
return f.Param + "_filter"
}
// SplitFacetValues splits a wire value into its OR-within alternatives.
// Shared by the SQL semantics (string_to_array) and later by the
// facet-count endpoint so display and matching can never disagree.
func SplitFacetValues(wireValue string) []string {
if wireValue == "" {
return nil
}
parts := strings.Split(wireValue, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, t)
}
}
return out
}
// FacetByParam finds a registry entry by its base param name (nil when
// absent).
func FacetByParam(param string) *Facet {
for i := range facetRegistry {
if facetRegistry[i].Param == param {
f := facetRegistry[i]
return &f
}
}
return nil
}
// FacetsForLibraryType returns the facets that RENDER for a view. An
// empty libraryTypeName is the All-Libraries view → the UNION of every
// registered facet (plan Batch D: no special SQL — matching is
// type-partitioned by the data). A specific type gets facets with no
// type restriction plus those listing it.
func FacetsForLibraryType(libraryTypeName string) []Facet {
if libraryTypeName == "" {
return Facets()
}
var out []Facet
for _, f := range facetRegistry {
if len(f.LibraryTypes) == 0 {
out = append(out, f)
continue
}
for _, t := range f.LibraryTypes {
if t == libraryTypeName {
out = append(out, f)
break
}
}
}
return out
}
+202
View File
@@ -0,0 +1,202 @@
package services
import (
"reflect"
"strings"
"testing"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
// The generated SQL constant for the unified search query. The
// drift-guard test asserts every registry entry's narg block exists in
// it — sqlc has no dynamic WHERE, so registry ↔ SQL agreement is
// enforced here instead (plan: "one registry entry + one narg block").
func TestFacetRegistryShape(t *testing.T) {
seen := map[string]bool{}
knownLibraryTypes := map[string]bool{
"ebooks": true, "comics": true, "manga": true, "audiobooks": true,
}
for _, f := range facetRegistry {
if f.Param == "" {
t.Fatalf("facet with empty Param: %+v", f)
}
wire := f.WireParam()
if seen[wire] {
t.Errorf("duplicate wire param %q", wire)
}
seen[wire] = true
if f.Column == "" {
t.Errorf("%s: empty Column", wire)
}
// Kind ↔ Match consistency.
switch f.Kind {
case FacetArray:
if f.Match != MatchPerElementFuzzy {
t.Errorf("%s: array facets are per-element fuzzy, got %s", wire, f.Match)
}
case FacetScalar:
if f.Match != MatchScalarFuzzy && f.Match != MatchExact {
t.Errorf("%s: scalar facets are scalar-fuzzy or exact, got %s", wire, f.Match)
}
case FacetBool:
if f.Match != MatchExact {
t.Errorf("%s: bool facets are exact, got %s", wire, f.Match)
}
case FacetRange:
if f.Match != MatchExact {
t.Errorf("%s: range facets are exact, got %s", wire, f.Match)
}
default:
t.Errorf("%s: unknown kind %q", wire, f.Kind)
}
for _, lt := range f.LibraryTypes {
if !knownLibraryTypes[lt] {
t.Errorf("%s: unknown library type %q (seed names: ebooks, comics, manga, audiobooks)", wire, lt)
}
}
}
}
func TestFacetRegistrySQLDrift(t *testing.T) {
// sqlc substitutes sqlc.narg(...) with ? placeholders in the emitted
// const, so the drift guard reflects over the GENERATED params
// struct instead: a narg block exists in the query iff the struct
// has its field. This checks the artifact the binary actually runs.
paramsType := reflect.TypeOf(database.SearchMediaItemsUnifiedParams{})
dbTags := map[string]reflect.Type{}
for i := 0; i < paramsType.NumField(); i++ {
tag := strings.Split(paramsType.Field(i).Tag.Get("db"), ",")[0]
dbTags[tag] = paramsType.Field(i).Type
}
boolType := reflect.TypeOf(pgtype.Bool{})
for _, f := range facetRegistry {
wire := f.WireParam()
switch f.Kind {
case FacetRange:
// The year range binds the year_min/year_max pair.
if _, ok := dbTags["year_min"]; !ok {
t.Error("year range facets must bind year_min in SearchMediaItemsUnified")
}
if _, ok := dbTags["year_max"]; !ok {
t.Error("year range facets must bind year_max in SearchMediaItemsUnified")
}
case FacetBool:
ft, ok := dbTags[wire]
if !ok {
t.Errorf("registry facet %q missing from SearchMediaItemsUnified (want a %s param)", wire, wire)
continue
}
if ft != boolType {
t.Errorf("%s: bool facet must generate pgtype.Bool (the ::bool cast), got %s", wire, ft)
}
default:
if _, ok := dbTags[wire]; !ok {
t.Errorf("registry facet %q missing from SearchMediaItemsUnified (want a %s param)", wire, wire)
}
}
}
// The Batch A additions specifically (pin the param names).
for _, wire := range []string{
"story_arc_filter", "imprint_filter", "edition_filter",
"age_rating_filter", "format_group_filter", "manga_type_filter",
"reading_direction_filter", "explicit_filter", "abridged_filter",
} {
if _, ok := dbTags[wire]; !ok {
t.Errorf("Batch A facet %s missing from SearchMediaItemsUnified", wire)
}
}
// Phase 7: the counts query (FacetCountsUnderFilters) carries the
// same filter blocks — copied mechanically from the search query —
// so the panel's under-filter counts and the grid's rows can never
// disagree about what a filter means. Reflect the generated params
// struct the same way.
countParamsType := reflect.TypeOf(database.FacetCountsUnderFiltersParams{})
countTags := map[string]bool{}
for i := 0; i < countParamsType.NumField(); i++ {
tag := strings.Split(countParamsType.Field(i).Tag.Get("db"), ",")[0]
countTags[tag] = true
}
for _, want := range []string{"user_id", "library_id", "limit"} {
if !countTags[want] {
t.Errorf("FacetCountsUnderFilters missing %s param", want)
}
}
for _, f := range facetRegistry {
wire := f.WireParam()
if f.Kind == FacetRange {
if !countTags["year_min"] || !countTags["year_max"] {
t.Error("year range must bind year_min/year_max in FacetCountsUnderFilters")
}
continue
}
if !countTags[wire] {
t.Errorf("registry facet %q missing from FacetCountsUnderFilters (each arm binds every OTHER facet's block, so every facet's param must exist)", wire)
}
}
}
func TestFacetsForLibraryType(t *testing.T) {
// Empty = All Libraries → the UNION of every facet (Batch D decision).
all := FacetsForLibraryType("")
if len(all) != len(facetRegistry) {
t.Fatalf("all-libraries union = %d facets, want %d", len(all), len(facetRegistry))
}
byWire := func(fs []Facet) map[string]bool {
m := map[string]bool{}
for _, f := range fs {
m[f.WireParam()] = true
}
return m
}
manga := byWire(FacetsForLibraryType("manga"))
if !manga["manga_type_filter"] || !manga["reading_direction_filter"] {
t.Error("manga view must include manga_type and reading_direction")
}
if manga["narrator_filter"] {
t.Error("narrator facet is audiobooks-only, must not render on manga")
}
if !manga["author_filter"] || !manga["genre_filter"] {
t.Error("unrestricted facets render on every library type")
}
audio := byWire(FacetsForLibraryType("audiobooks"))
if !audio["narrator_filter"] {
t.Error("audiobooks view must include narrator")
}
if audio["manga_type_filter"] || audio["reading_direction_filter"] {
t.Error("manga facets must not render on audiobooks")
}
comics := byWire(FacetsForLibraryType("comics"))
if !comics["reading_direction_filter"] {
t.Error("comics view must include reading_direction")
}
if comics["manga_type_filter"] {
t.Error("manga_type is manga-only, must not render on comics")
}
unknown := byWire(FacetsForLibraryType("woodcuts"))
if unknown["manga_type_filter"] || unknown["narrator_filter"] {
t.Error("restricted facets must not render for an unknown type")
}
if !unknown["author_filter"] {
t.Error("unrestricted facets still render for an unknown type")
}
}
func TestSplitFacetValues(t *testing.T) {
if got := SplitFacetValues(""); got != nil {
t.Errorf("empty → nil, got %v", got)
}
got := SplitFacetValues("Vertigo, DC Black Label ,Vertigo")
if len(got) != 3 || got[0] != "Vertigo" || got[1] != "DC Black Label" || got[2] != "Vertigo" {
t.Errorf("SplitFacetValues = %v, want [Vertigo DC Black Label Vertigo]", got)
}
}
File diff suppressed because it is too large. Load diff
+560
View File
@@ -0,0 +1,560 @@
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: "manually_edited", Label: "Manually edited", Group: GroupBookFacts, Kind: KindBool,
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
// metadata_overrides is NOT NULL TEXT[] — '{}' means untouched.
Clause: func(r FilterRule, b *filterBuilder) string {
switch r.Op {
case OpIsTrue:
return "(cardinality(mi.metadata_overrides) > 0)"
case OpIsFalse:
return "(cardinality(mi.metadata_overrides) = 0)"
}
return "TRUE"
}, 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
}
+131
View File
@@ -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)
}
}
+198
View File
@@ -0,0 +1,198 @@
package services
import "strings"
// Genre model v2 (faceted-navigation plan, Batch B): the `genre` column
// holds ONLY top-level shelves; every more specific candidate is a tag
// mapped to its parent shelf. User rule: *a subgenre is not a genre, it's
// a tag*.
//
// Sources arrive as comma-blobs ("Epic, Action & Adventure, Military,
// Sword & Sorcery" — one Audible genre string), media-format noise
// ("Audiobook"), maximally generic tags ("Fiction"), or library-type
// words ("Manga", "Comics" — ComicInfo files that describe the TYPE,
// not a shelf). Deterministic ranked rule set — no network, no guessing:
//
// 1. split candidate strings on , ; /
// 2. alias-map variants to canonical forms (shelf or subgenre)
// 3. discard demoted tokens (formats/editions/type-words) — never win
// 4. pick: first DIRECT shelf (source order) > first MAPPED subgenre
// shelf > Fiction/Nonfiction floor > "" (unknown-only: tags carry it)
// 5. every candidate (split parts included) still lands in tags
//
// "adventure" and "action & adventure" are BOTH shelves — provider data
// (Audible/Calibre) uses both, and the user-verified traces pin them:
// {Pirates} → adventure (mapped), Malazan blob → action & adventure
// (direct beats mapped). Merging them is deferred aliasing work.
var genreShelves = map[string]bool{
// Fiction side
"fiction": true, "action & adventure": true, "adventure": true,
"fantasy": true, "science fiction": true, "horror": true,
"mystery": true, "thriller": true, "romance": true,
"historical fiction": true, "western": true, "war & military": true,
"young adult": true, "middle grade": true, "children's": true,
"classics": true, "humor": true, "poetry": true, "short stories": true,
// Nonfiction side
"nonfiction": true, "biography & memoir": true, "history": true,
"science": true, "true crime": true, "self-help": true,
"philosophy": true, "business": true,
}
// genreFloors are shelves that only win when NO other shelf candidate
// exists (direct or mapped) — maximally generic positions. {Fiction,
// Epic Fantasy} → fantasy, {Fiction} alone → fiction.
func isGenreFloor(lower string) bool {
return lower == "fiction" || lower == "nonfiction"
}
// genreSubgenreShelf maps subgenre candidates (lowercase) to their parent
// shelf. Anything here is a TAG by definition; its shelf wins the genre
// column only when no direct shelf hit exists.
var genreSubgenreShelf = map[string]string{
// Fantasy subgenres
"epic fantasy": "fantasy", "sword & sorcery": "fantasy",
"dark fantasy": "fantasy", "urban fantasy": "fantasy",
"paranormal": "fantasy", "high fantasy": "fantasy",
"fairy tales": "fantasy", "mythology": "fantasy",
"folklore": "fantasy", "litrpg": "fantasy",
"progression fantasy": "fantasy",
// Science fiction subgenres
"space opera": "science fiction", "cyberpunk": "science fiction",
"military science fiction": "science fiction", "dystopian": "science fiction",
"post-apocalyptic": "science fiction", "apocalyptic": "science fiction",
"time travel": "science fiction", "alternate history": "science fiction",
"first contact": "science fiction", "steampunk": "science fiction",
// Romance subgenres
"contemporary romance": "romance", "historical romance": "romance",
"paranormal romance": "romance",
// Mystery subgenres
"cozy mystery": "mystery", "crime": "mystery",
"police procedural": "mystery", "noir": "mystery", "hardboiled": "mystery",
// Thriller subgenres
"suspense": "thriller", "espionage": "thriller",
// Horror subgenres
"gothic": "horror", "supernatural": "horror",
// Fiction-side themes
"literary fiction": "fiction", "coming of age": "fiction",
"satire": "fiction", "anthology": "fiction", "alternate world": "fiction",
// War & Military subgenres
"military": "war & military", "war": "war & military",
// Adventure subgenres
"pirates": "adventure",
// Biography & Memoir subgenres
"biography": "biography & memoir", "autobiography": "biography & memoir",
"memoir": "biography & memoir",
// Remaining nonfiction topics → the nonfiction umbrella shelf
"psychology": "nonfiction", "economics": "nonfiction",
"politics": "nonfiction", "religion": "nonfiction",
"spirituality": "nonfiction", "travel": "nonfiction",
"cooking": "nonfiction", "food": "nonfiction",
"health": "nonfiction", "fitness": "nonfiction",
"sports": "nonfiction", "music": "nonfiction",
"art": "nonfiction", "photography": "nonfiction",
"education": "nonfiction", "reference": "nonfiction",
"legal": "nonfiction", "medical": "nonfiction",
}
// genreAliases maps provider variants to a canonical shelf OR subgenre
// form (which the maps above then resolve).
var genreAliases = map[string]string{
"sci-fi": "science fiction", "scifi": "science fiction",
"sf": "science fiction", "sff": "science fiction",
"sci fi": "science fiction", "sci_fi": "science fiction",
"speculative fiction": "science fiction",
"detectives": "mystery", "detective": "mystery",
"whodunnit": "mystery", "whodunit": "mystery",
"adventure fiction": "action & adventure",
"action and adventure": "action & adventure",
"ya": "young adult", "mg": "middle grade",
"coming-of-age": "coming of age",
"histfiction": "historical fiction",
"epic": "epic fantasy",
"military scifi": "military science fiction",
"military sf": "military science fiction",
"post apocalyptic": "post-apocalyptic",
"self help": "self-help",
}
// genreDemoted never wins the genre column — media formats, editions,
// and library-TYPE words (ComicInfo genre="Manga" describes the type,
// not a shelf; shelves apply uniformly across all library types). They
// still pass through to tags.
var genreDemoted = map[string]bool{
"audiobook": true, "unabridged": true, "abridged": true,
"dramatization": true, "dramatized": true, "audio drama": true,
"manga": true, "comics": true, "graphic novel": true,
"graphic novels": true,
}
// splitGenreCandidates splits compound genre strings ("Epic, Action &
// Adventure" — one Audible blob) into individual candidates.
func splitGenreCandidates(genres []string) []string {
var out []string
for _, g := range genres {
for _, part := range strings.Split(g, ",") {
for _, p := range strings.Split(part, ";") {
for _, piece := range strings.Split(p, "/") {
t := strings.TrimSpace(piece)
if t != "" {
out = append(out, t)
}
}
}
}
}
return out
}
// normalizeGenreCandidate lowercases, trims, and alias-resolves one
// candidate; "" means noise (empty input).
func normalizeGenreCandidate(c string) string {
lower := strings.ToLower(strings.TrimSpace(c))
if lower == "" {
return ""
}
if a, ok := genreAliases[lower]; ok {
return a
}
return lower
}
// pickSmartGenre chooses the SHELF for the genre column from candidate
// strings (genre model v2). Returns the winner ("" when no shelf is
// known — unknown-only input stays tag-only) and the full candidate
// list for the tags array.
func pickSmartGenre(genres []string) (genre string, all []string) {
all = splitGenreCandidates(genres)
direct := "" // first non-floor shelf, source order
mapped := "" // first subgenre-resolved shelf, source order
floor := "" // fiction / nonfiction, source order
for _, c := range all {
lower := normalizeGenreCandidate(c)
if lower == "" || genreDemoted[lower] {
continue
}
switch {
case genreShelves[lower]:
if isGenreFloor(lower) {
if floor == "" {
floor = lower
}
} else if direct == "" {
direct = lower
}
case genreSubgenreShelf[lower] != "":
if mapped == "" {
mapped = genreSubgenreShelf[lower]
}
}
// Unknown candidates are tags-only — never guess a shelf.
}
if direct != "" {
return direct, all
}
if mapped != "" {
return mapped, all
}
return floor, all
}
File diff suppressed because it is too large. Load diff
+6 -8
View File
@@ -408,12 +408,10 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
metadata.Title = album
}
}
// Subtitle joins the title Calibre-style, mirroring the ABS sidecar
// path — there is no separate subtitle column. Skip when the title
// already contains it (tools sometimes pre-join).
if subtitle := report.tag("----:com.apple.itunes:subtitle", "subtitle"); subtitle != "" && metadata.Title != "" &&
!strings.Contains(strings.ToLower(metadata.Title), strings.ToLower(subtitle)) {
metadata.Title = metadata.Title + ": " + subtitle
// Subtitle is a first-class column (A2b) — no more Calibre-style title
// joining; the tool-provided subtitle lands in its own column.
if subtitle := report.tag("----:com.apple.itunes:subtitle", "subtitle"); subtitle != "" {
metadata.Subtitle = subtitle
}
if metadata.Author == "" {
if artist := report.tag("album_artist"); artist != "" {
@@ -437,8 +435,8 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
if series := report.tag("grouping", "----:com.apple.itunes:series"); series != "" {
metadata.Series = series
if part := report.tag("----:com.apple.itunes:series-part"); part != "" {
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 32); err == nil {
metadata.SeriesNumber = int32(idx)
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 64); err == nil {
metadata.SeriesNumber = idx
}
}
}
@@ -0,0 +1,33 @@
package services
import (
"testing"
)
// TestAudioPayloadIsEmpty pins the write-after-success guard on
// updateAudioBookItem (2026-10-21 incident: multi-file audiobooks
// surfaced as 1 second long because a failed re-extraction overwrote a
// populated audio_metadata). An empty payload — nil, or no tracks, no
// chapters, no duration — is the signature of a failed extraction and
// must never replace existing audio data.
func TestAudioPayloadIsEmpty(t *testing.T) {
require := func(cond bool, msg string) {
if !cond {
t.Fatal(msg)
}
}
require(audioPayloadIsEmpty(nil), "nil payload must read as empty")
require(audioPayloadIsEmpty(&AudioMetadataPayload{}), "zero-value payload must read as empty")
require(audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 0}), "zero-duration payload must read as empty")
// A real book always carries at least one of the audio facts.
require(!audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 302.6}),
"duration-only payload (single-track book) is not empty")
require(!audioPayloadIsEmpty(&AudioMetadataPayload{
Tracks: []AudioTrack{{Index: 0, Path: "01.mp3"}},
}), "track manifest is not empty")
require(!audioPayloadIsEmpty(&AudioMetadataPayload{
Chapters: []AudioChapter{{Index: 0, Title: "Chapter 1"}},
}), "chapters are not empty")
}
@@ -148,9 +148,13 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
"----:com.apple.iTunes:ASIN": "B0ASIN1234",
"----:com.apple.iTunes:ABRIDGED": "1",
}))
if meta.Title != "The Tagged Album: Or, A Life" {
// A2b: subtitle is a first-class column — no title joining.
if meta.Title != "The Tagged Album" {
t.Fatalf("title = %q", meta.Title)
}
if meta.Subtitle != "Or, A Life" {
t.Fatalf("subtitle = %q", meta.Subtitle)
}
if meta.Author != "Tag Author" {
t.Fatalf("author = %q", meta.Author)
}
@@ -163,7 +167,7 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
t.Fatal("series not filled from tags")
}
if meta.SeriesNumber != 4 {
t.Fatalf("series number = %d", meta.SeriesNumber)
t.Fatalf("series number = %v", meta.SeriesNumber)
}
if meta.ASIN != "B0ASIN1234" {
t.Fatalf("asin = %q", meta.ASIN)
@@ -35,6 +35,7 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
@@ -533,10 +534,15 @@ func (s *MediaScanner) extractAudioBookMetadata(representative, rootFolder strin
// zip books. The hash columns stay NULL — content dedup is path-based for
// multi-file books (there is no single byte stream to hash).
func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype.UUID, metadata *MediaMetadata, relPath string, totalSize int64, mimeType string, modTime pgtype.Timestamptz) (database.MediaItems, error) {
return s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
// A2c §2.7: same title/subtitle-derived series fallback as the
// single-file path — folder-of-tracks books carry the same naming.
applyTitleDerivedSeries(metadata)
created, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libraryID,
Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
@@ -545,20 +551,36 @@ func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype
MimeType: pgtype.Text{String: mimeType, Valid: mimeType != ""},
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
Contributors: utils.NormalizeContributors(metadata.Contributors),
Narrators: nilToEmpty(metadata.Narrators),
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
Abridged: metadata.Abridged,
Explicit: metadata.Explicit,
Tags: utils.NormalizeTags(metadata.Tags),
AddedByAdminID: s.adminID,
CreatedAt: modTime,
ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
})
if err != nil {
// Ledger (Phase 11): row-less import failure keyed by
// (library, path) — the folder/zip book's row was never created.
if logErr := s.LogRowlessProcessingIssue(ctx,
uuid.UUID(libraryID.Bytes), relPath,
"import_failed", fmt.Sprintf("File failed to import: %v", err), "error"); logErr != nil {
fmt.Printf("Warning: failed to record import-failure issue for %s: %v\n", relPath, logErr)
}
return database.MediaItems{}, err
}
// Ledger self-heal (Phase 11): the book finally imports — close any
// open row-less import-failure issues keyed to this (library, path).
s.resolveIssuesForPath(ctx, libraryID, relPath)
return created, nil
}
// setAudioFormatGroup writes the audiobook format facts after create.
@@ -833,7 +855,25 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
}
existing, existingErr := s.getMediaItemByFilePath(ctx, rootRel, libraryID)
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(tracks)) {
// The representative drives the sidecar/folder-structure checks; they
// all look at Dir(representative), so a sentinel INSIDE the root
// resolves covers and metadata.json living there (including disc-
// layout roots where track zero sits deeper).
sentinel := filepath.Join(rootDir, ".audiobook-root")
// Manifest-gated sidecar detection (faceted plan Phase 6): a
// size-stable folder book is changed by EDITING its sidecars —
// including on scans where the track fingerprint says "unchanged".
// The ladder is stat-only in the steady state; a detected edit falls
// through to the full probe below so the once-extracted metadata can
// feed the editorial apply.
manifestChanged := false
if existingErr == nil && !s.forceRescan {
manifestChanged = s.multifileSidecarChanged(ctx, libraryID, rootDir, sentinel, rootDir)
}
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(tracks)) && !manifestChanged {
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive folder book %s: %v\n", rootRel, err)
@@ -881,11 +921,9 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
}
orderTracksByTags(probed)
// The representative path drives the sidecar/folder-structure checks;
// they all look at Dir(representative), so a sentinel INSIDE the root
// resolves covers and metadata.json living there (including disc-
// layout roots where track zero sits deeper).
sentinel := filepath.Join(rootDir, ".audiobook-root")
// The sentinel is defined above, next to the manifest check that
// shares it; the probe pass reuses it as the extraction
// representative.
metadata := s.extractAudioBookMetadata(sentinel, rootFolder, firstReport, tracks[0], filepath.Base(rootDir))
payload := buildMultiFileAudioPayload("folder", metadata.AudioMeta, probed)
metadata.AudioMeta = payload
@@ -908,10 +946,39 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
switch {
case err == nil:
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload)
if changed || s.forceRescan {
if changed || s.forceRescan || manifestChanged {
// Editorial apply BEFORE the payload write (rescanMultiFileAudioBook's
// ordering): the generic update's format-group pass derives the
// mime from the sentinel (none) and chapters from
// metadata.ChapterCount (unset) — the focused payload write below
// must win. Force rescan applies TOO (faceted plan Phase 13:
// force rescan = original-scan semantics — the full chain
// re-lands on every field, with metadata_overrides as the only
// protection, honored inside the apply).
if manifestChanged || s.forceRescan {
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, sentinel, metadata); applyErr != nil {
fmt.Printf("Warning: %v\n", applyErr)
} else {
// Ledger self-heal (Phase 11): the book's metadata
// re-applied successfully — close its open issues.
s.resolveIssuesForItem(ctx, existing.ID)
}
// Fresh full manifest: the next scan compares against
// TODAY's sidecars, not the stale pre-edit fingerprint.
s.storeMultiFileManifest(ctx, libraryID, rootDir, sentinel, rootDir)
}
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
return err
}
s.changedItems++
// The book is demonstrably back and fresh: lift any archive
// state the missing-root scans set (the same lift the
// unchanged path applies).
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive folder book %s: %v\n", rootRel, err)
}
}
} else if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
// Unchanged content that was archived while the root was gone.
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
@@ -928,6 +995,11 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
fmt.Printf("Warning: failed to set format group for folder book %s: %v\n", rootRel, err)
}
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
// Seed the sidecar manifest at create (Phase 6): names derive
// from the sentinel — the exact representative the extractor
// reads — so the manifest can never disagree with extraction.
s.seedMultiFileManifest(ctx, libraryID, created.ID, rootRel, sentinel, rootDir, totalSize)
fmt.Printf("[AUDIO] Created folder book '%s' (%d tracks) at %s\n", metadata.Title, len(tracks), rootRel)
s.newItems++
default:
@@ -943,6 +1015,16 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
// rescan saw the track set change (or the admin forced a rescan). Only
// scan-derived fields move — editorial columns keep admin edits.
func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing database.MediaItems, payload *AudioMetadataPayload, mimeType string, totalSize int64, chapterCount int) error {
// Write-after-success discipline (2026-10-21 incident: multi-file
// audiobooks surfaced as 1 second long because a re-extraction wrote
// a NULL/empty audio_metadata over a populated one). An empty payload
// is a FAILED extraction, not a book with no audio: when the row
// already carries audio data, keep it and skip the write — the next
// scan retries, and the reader keeps its manifest in the meantime.
if audioPayloadIsEmpty(payload) && existing.AudioMetadata != nil {
fmt.Printf("[AUDIO] WARNING: re-extraction of '%s' produced an empty audio payload; keeping the existing audio_metadata\n", existing.Title)
return nil
}
if _, err := s.db.UpdateMediaItemAudioBookPayload(ctx, database.UpdateMediaItemAudioBookPayloadParams{
ID: existing.ID,
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
@@ -956,6 +1038,76 @@ func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing databas
return nil
}
// audioPayloadIsEmpty reports whether a payload carries no audio facts
// at all — no tracks, no chapters, no duration. Such a payload is the
// signature of a failed/absent extraction, never of a real book.
func audioPayloadIsEmpty(payload *AudioMetadataPayload) bool {
return payload == nil ||
(len(payload.Tracks) == 0 && len(payload.Chapters) == 0 && payload.DurationSeconds <= 0)
}
// multifileSidecarChanged runs the manifest ladder for a folder/zip book
// (faceted plan Phase 6): a size-stable multi-file book is changed by
// EDITING its sidecars — the track-count/size fingerprint never saw it,
// not even on force rescan. The bulk-map entry is keyed by the item's
// file_path, but sidecar NAMES derive from the representative the
// extractor reads (the sentinel inside a folder root, the zip itself).
// NULL stored manifest → backfill only (deploy-day rule); no bulk entry
// (nil map, brand-new item) → not detected this pass.
func (s *MediaScanner) multifileSidecarChanged(ctx context.Context, libraryID pgtype.UUID, itemPath, representative, dir string) bool {
entry, ok := s.scanStateLookup(libraryID, itemPath)
if !ok {
return false
}
return s.detectSidecarChangeWithRepresentative(ctx, entry, libraryID, representative, dir)
}
// applyMultiFileSidecarEdit lands the once-extracted book metadata
// (sidecars → folder structure → first-track tags → fallback title) on
// the editorial columns of an EXISTING folder/zip book — exactly what
// rescanMultiFileAudioBook applies; metadata_overrides stay protected
// inside the update. The caller runs the focused payload write AFTER
// this: the generic update's format-group pass derives the mime type
// from the sentinel/zip path (none/audio-container) and the chapter
// count from metadata.ChapterCount (unset on the book chain), so the
// payload write must win. The playlist hook re-runs because a
// "(Reading Order)" entry added to an EXISTING book's sidecar still
// creates its collection (add-if-missing is idempotent).
func (s *MediaScanner) applyMultiFileSidecarEdit(ctx context.Context, existing database.MediaItems, representative string, metadata *MediaMetadata) error {
if err := s.updateMediaItemWithMetadata(ctx, existing, representative, metadata); err != nil {
return fmt.Errorf("failed to apply sidecar metadata to %s: %w", existing.FilePath, err)
}
s.syncPlaylistCollectionsAfterCreate(ctx, existing.ID, metadata.PlaylistSeries)
fmt.Printf("[AUDIO] Sidecar metadata applied to '%s'\n", existing.Title)
return nil
}
// seedMultiFileManifest inserts the new book's scan-state entry and
// hashes its sidecars into the initial manifest — the multifile twin of
// the single-file create tail: the next scan compares instead of
// backfilling, so an edit is detected on its first post-seed scan.
func (s *MediaScanner) seedMultiFileManifest(ctx context.Context, libraryID pgtype.UUID, itemID pgtype.UUID, relPath, representative, dir string, totalSize int64) {
newEntry := &scanEntry{ID: itemID, FilePath: relPath, FileSize: totalSize}
s.scanStateUpsert(libraryID, newEntry)
if computed := statSidecarManifest(representative, dir); len(computed.Entries) > 0 {
s.storeManifest(ctx, libraryID, newEntry, hashSidecarManifest(representative, dir, computed))
} else {
// Sidecar-less book still needs an ev home (faceted plan Phase
// 10): stamp — the book was JUST extracted by the current
// parser, so the stamp is truthful.
s.storeManifest(ctx, libraryID, newEntry, &sidecarManifest{Version: sidecarManifestVersion})
}
}
// storeMultiFileManifest refreshes the book's full manifest after a
// change-path apply: the next scan compares against TODAY's sidecars,
// not the stale pre-edit fingerprint.
func (s *MediaScanner) storeMultiFileManifest(ctx context.Context, libraryID pgtype.UUID, itemPath, representative, dir string) {
if entry, ok := s.scanStateLookup(libraryID, itemPath); ok {
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(representative, dir, statSidecarManifest(representative, dir)))
}
}
// processZipAudioBook ingests a zip archive of audio tracks as ONE book.
// The zip is the item's file (downloadable like any single file);
// audio_metadata carries layout "zip" and per-entry track paths the
@@ -982,7 +1134,26 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
}
existing, existingErr := s.getMediaItemByFilePath(ctx, zipRel, libraryID)
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(entries)) {
// Manifest-gated sidecar detection (faceted plan Phase 6) — the zip
// itself is both the map key and the representative: its sidecars are
// the siblings the extractor reads (metadata.json next to the zip,
// "<zip-basename>.<ext>" covers).
manifestChanged := false
if existingErr == nil && !s.forceRescan {
manifestChanged = s.multifileSidecarChanged(ctx, libraryID, zipPath, zipPath, filepath.Dir(zipPath))
}
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(entries)) && !manifestChanged {
// Archive-restore parity with the folder path (Phase 6 fix): a
// zip book whose file returned must not stay archived forever.
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
} else {
fmt.Printf("[AUDIO] Restored zip book '%s' (file is back)\n", existing.Title)
}
}
return nil
}
@@ -1037,10 +1208,38 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
switch {
case err == nil:
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload)
if changed || s.forceRescan {
if changed || s.forceRescan || manifestChanged {
// Editorial apply BEFORE the payload write — see the folder
// processor for the format-group ordering rationale. Force
// rescan applies too (Phase 13: original-scan semantics,
// metadata_overrides honored inside the apply).
if manifestChanged || s.forceRescan {
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, zipPath, metadata); applyErr != nil {
fmt.Printf("Warning: %v\n", applyErr)
} else {
// Ledger self-heal (Phase 11): the book's metadata
// re-applied successfully — close its open issues.
s.resolveIssuesForItem(ctx, existing.ID)
}
s.storeMultiFileManifest(ctx, libraryID, zipPath, zipPath, filepath.Dir(zipPath))
}
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
return err
}
s.changedItems++
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
}
}
} else if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
// Parity with the folder change branch: content unchanged but
// the row was archived while the zip was gone.
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
} else {
fmt.Printf("[AUDIO] Restored zip book '%s' (file is back)\n", existing.Title)
}
}
case errors.Is(err, pgx.ErrNoRows):
created, err := s.createAudioBookItem(ctx, libraryID, metadata, zipRel, totalSize, firstMime, modTime)
@@ -1050,6 +1249,8 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
fmt.Printf("Warning: failed to set format group for zip book %s: %v\n", zipRel, err)
}
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
s.seedMultiFileManifest(ctx, libraryID, created.ID, zipRel, zipPath, filepath.Dir(zipPath), totalSize)
fmt.Printf("[AUDIO] Created zip book '%s' (%d tracks) at %s\n", metadata.Title, len(entries), zipRel)
s.newItems++
default:
@@ -0,0 +1,68 @@
package services
// Unit tests for the extractor-version (ev) mechanism (faceted plan
// Phase 10): the wire normalization (absent = v1-or-unknown = 1) and the
// per-scan catch-up budget. The full scan-state machine (ev mismatch →
// re-extract once → stable) lives in cmd/server/tests
// (TestExtractorVersionReextract) because the store paths need a real DB.
import (
"testing"
)
func TestManifestEvNormalization(t *testing.T) {
if got := manifestEv(nil); got != 1 {
t.Errorf("nil manifest rides v1: got %d, want 1", got)
}
cases := []struct {
raw string
want int
}{
{`{"v":1}`, 1}, // old shape: ev absent → v1-or-unknown
{`{"v":1,"ev":0}`, 1}, // explicitly unset → same
{`{"v":1,"ev":1}`, 1}, // baseline parser stamp
{`{"v":1,"ev":2}`, 2}, // post-bump stamp
{`{"v":1,"entries":[],"ev":3}`, 3}, // sidecar-less ev home
}
for _, tc := range cases {
m := decodeSidecarManifest([]byte(tc.raw))
if m == nil {
t.Fatalf("%s must decode", tc.raw)
}
if got := manifestEv(m); got != tc.want {
t.Errorf("manifestEv(%s) = %d, want %d", tc.raw, got, tc.want)
}
}
}
func TestEvBudgetTake(t *testing.T) {
scanner := &MediaScanner{}
scanner.evBudgetRemaining.Store(2)
if !scanner.evBudgetTake() {
t.Error("budget 2: first take must pass")
}
if !scanner.evBudgetTake() {
t.Error("budget 1: second take must pass")
}
if scanner.evBudgetTake() {
t.Error("budget 0: third take must fail")
}
if got := scanner.evBudgetRemaining.Load(); got != 0 {
t.Errorf("budget must land at exactly 0, got %d", got)
}
}
func TestEvBudgetTake_FailsFastAtZero(t *testing.T) {
scanner := &MediaScanner{}
scanner.evBudgetRemaining.Store(0)
// Repeated takes at 0 must fail WITHOUT driving the counter negative
// (the CAS loop only fires when cur > 0).
for i := 0; i < 5; i++ {
if scanner.evBudgetTake() {
t.Fatalf("take %d must fail at budget 0", i+1)
}
}
if got := scanner.evBudgetRemaining.Load(); got != 0 {
t.Errorf("budget must stay 0, got %d", got)
}
}
+348
View File
@@ -0,0 +1,348 @@
package services
// Sidecar manifest fingerprints (faceted plan Part 2 §3): size-stable
// books are changed by EDITING their metadata sidecars, which the old
// size-only detection never noticed. One manifest per book (JSONB in
// media_scan_state) lists every sidecar the extractors consume, so the
// manifest and the extractors can never disagree — the manifest stats
// exactly the names extractCalibreSidecar, extractAudiobookshelfSidecar,
// and findSidecarCover look for.
//
// Change evaluation ladder, in cost order:
// 1. stat pairs all equal → unchanged, ZERO bytes read (steady state);
// 2. a stat pair differs → read + hash that one sidecar:
// hash equal → refresh the stored stat pair (host repoint
// reconciliation: bounded reads, zero re-extraction);
// hash differs → the item changed → re-extract (caller);
// 3. stored manifest NULL → backfill only: hash + store, NO
// re-extraction (deploy-day rule — the first scan after release
// must not look like an mtime flood).
//
// mtime is a CACHE field only — it is never a change signal. Docker
// bind-mount repoints reset every mtime, which is exactly why the hash
// reconciliation ladder exists.
import (
"bookhoard/internal/database"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/jackc/pgx/v5/pgtype"
)
const sidecarManifestVersion = 1
type manifestSidecar struct {
Name string `json:"n"`
Size int64 `json:"s"`
MTime int64 `json:"m"`
// Sha is the content hash. Present for ALL entries — covers too:
// the repoint reconciliation needs a baseline hash for any stat-
// drifted sidecar, and a hashless cover would turn harmless mtime
// drift into a re-extraction (the exact flood this design kills).
Sha string `json:"h,omitempty"`
}
type sidecarManifest struct {
Version int `json:"v"`
Entries []manifestSidecar `json:"entries"`
// Ev is the extractor (parser) version that produced the item's
// CURRENT row. Absent (0) means "v1-or-unknown": backfill and
// extract-failure stores leave it unset so a row never claims a
// version its data can't prove; every store that FOLLOWS a
// successful extraction stamps the setting's current value
// (scan_extractor_version). A stored ev below the setting re-runs
// extraction on a later scan — parser fixes reach old rows without
// a force rescan (faceted plan Phase 10).
Ev int `json:"ev,omitempty"`
}
// manifestEv normalizes a stored manifest's extractor version: absent
// manifest or unset ev means version 1 (the shipped baseline parser).
// Current-parser versions only ever grow, so "unknown" safely rides the
// oldest live version.
func manifestEv(m *sidecarManifest) int {
if m == nil || m.Ev < 1 {
return 1
}
return m.Ev
}
// coverSidecarNames mirrors findSidecarCover's priority list — the
// manifest must stat the same names the extractor would consume.
var coverSidecarNames = []string{
"cover.jpg", "cover.jpeg", "cover.png", "cover.webp",
"folder.jpg", "folder.jpeg", "folder.png", "folder.webp",
".folder.jpg", ".folder.jpeg", ".folder.png",
}
// bookSidecarDir is the directory whose sidecars feed a book: the book
// file's directory (multifile book roots resolve to themselves because
// file_path IS the directory).
func bookSidecarDir(path string) string {
info, err := os.Stat(path)
if err == nil && info.IsDir() {
return path
}
return filepath.Dir(path)
}
// sidecarNamesForBook lists every sidecar filename the extractors
// consume for a book at path. The book basename covers the
// findSidecarCover "<basename>.<ext>" pattern.
func sidecarNamesForBook(path string) []string {
names := make([]string, 0, len(coverSidecarNames)+5)
names = append(names, "metadata.opf", "metadata.json")
names = append(names, coverSidecarNames...)
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
if base != "" && base != "." {
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
names = append(names, base+ext)
}
}
return names
}
// statSidecarManifest stats every candidate sidecar (ReadDir-free: one
// lstat per candidate name — the walk already visited the directory).
// Returns nil when the directory itself is gone (deleted book mid-scan).
func statSidecarManifest(path, dir string) *sidecarManifest {
manifest := &sidecarManifest{Version: sidecarManifestVersion}
for _, name := range sidecarNamesForBook(path) {
info, err := os.Stat(filepath.Join(dir, name))
if err != nil || info.IsDir() {
continue
}
manifest.Entries = append(manifest.Entries, manifestSidecar{
Name: name,
Size: info.Size(),
MTime: info.ModTime().UnixNano(),
})
}
return manifest
}
// hashSidecarManifest fills the content hashes of every entry — text
// sidecars (KBs) and covers (a one-time bounded read at backfill; the
// repoint ladder needs the baseline). Returns the same manifest for
// chaining.
func hashSidecarManifest(path, dir string, manifest *sidecarManifest) *sidecarManifest {
for i := range manifest.Entries {
entry := &manifest.Entries[i]
sha, err := sha256File(filepath.Join(dir, entry.Name))
if err != nil {
continue // unreadable: stays size-only this pass
}
entry.Sha = sha
}
return manifest
}
func sha256File(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}
// decodeSidecarManifest parses a stored manifest; nil when absent or
// corrupt (the caller then treats it as a backfill, self-healing any
// shape breakage).
func decodeSidecarManifest(raw []byte) *sidecarManifest {
if len(raw) == 0 {
return nil
}
var m sidecarManifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil
}
return &m
}
// reconcileSidecarManifest runs the evaluation ladder against the
// freshly-statted manifest. Returns the manifest to store and whether
// the item's metadata must be re-extracted:
// - stat pairs all equal → fresh=nil, changed=false (zero reads);
// - stat drift + equal hash → fresh with the refreshed pair
// (repoint reconciliation);
// - hash differs, a sidecar appeared/vanished, or a differing text
// sidecar has no stored hash → changed.
func reconcileSidecarManifest(dir string, stored, computed *sidecarManifest) (fresh *sidecarManifest, changed bool) {
if stored == nil {
return nil, true
}
if len(stored.Entries) != len(computed.Entries) {
return nil, true // sidecar appeared or vanished
}
storedByName := make(map[string]manifestSidecar, len(stored.Entries))
for _, e := range stored.Entries {
storedByName[e.Name] = e
}
// Fast path: stat pairs all equal → unchanged, zero bytes read,
// nothing to store.
drifted := false
for _, cur := range computed.Entries {
old, ok := storedByName[cur.Name]
if !ok || cur.Size != old.Size || cur.MTime != old.MTime {
drifted = true
break
}
}
if !drifted {
return nil, false
}
fresh = &sidecarManifest{Version: sidecarManifestVersion, Ev: stored.Ev} // provenance preserved: stat-only reconciliation never re-extracted anything
for _, cur := range computed.Entries {
old, ok := storedByName[cur.Name]
if !ok {
return nil, true
}
if cur.Size == old.Size && cur.MTime == old.MTime {
fresh.Entries = append(fresh.Entries, old) // untouched: keep the stored hash
continue
}
if old.Sha == "" {
return nil, true // unhashable drift: fall back to re-extract
}
sha, err := sha256File(filepath.Join(dir, cur.Name))
if err != nil || sha != old.Sha {
return nil, true
}
cur.Sha = old.Sha // repoint: content identical, stat pair refreshed
fresh.Entries = append(fresh.Entries, cur)
}
return fresh, false
}
// evReextractBudgetPerScan bounds the parser-version catch-up: at most
// this many ev-triggered re-extractions per scan pass. Each one stamps
// the fresh manifest with the new ev, so progress is resumable — later
// polls pick up where this one stopped, and no single scan floods the
// disk with a full-library re-extraction (faceted plan Phase 10).
const evReextractBudgetPerScan = 100
// storeManifest persists the manifest and write-throughs the map entry.
// The stored manifest is STAMPED with the setting's current extractor
// version: every caller here follows (or is) a full extraction under
// the current parser, so the stamp is truthful. Backfill and
// extract-failure stores must use storeManifestBaseline instead.
func (s *MediaScanner) storeManifest(ctx context.Context, libraryID pgtype.UUID, entry *scanEntry, manifest *sidecarManifest) {
manifest.Ev = s.GetExtractorVersion()
s.storeManifestBaseline(ctx, libraryID, entry, manifest)
}
// storeManifestBaseline persists a manifest WITHOUT stamping the
// extractor version — for stores that follow NO successful extraction:
// the deploy-day backfill (the row's provenance is unknown; leaving ev
// unset keeps it eligible for a later version bump) and the
// extract-failure guard (stamping would mark parser work as done that
// never ran; leaving it stale retries on the next poll).
func (s *MediaScanner) storeManifestBaseline(ctx context.Context, libraryID pgtype.UUID, entry *scanEntry, manifest *sidecarManifest) {
b, err := json.Marshal(manifest)
if err != nil {
fmt.Printf("Warning: failed to encode sidecar manifest for %s: %v\n", entry.FilePath, err)
return
}
if err := s.db.UpsertMediaScanState(ctx, database.UpsertMediaScanStateParams{
MediaItemID: entry.ID,
Manifest: b,
}); err != nil {
fmt.Printf("Warning: failed to store sidecar manifest for %s: %v\n", entry.FilePath, err)
return
}
entry.Manifest = b
s.scanStateUpsert(libraryID, entry)
}
// evBudgetTake consumes one slot of the per-scan ev catch-up budget.
// Atomic: the poll scan walks sequentially, but the watcher's
// scanDirectory pass can overlap a poll scan (only scanDirectory holds
// scanMutex), so the budget is shared state.
func (s *MediaScanner) evBudgetTake() bool {
for {
cur := s.evBudgetRemaining.Load()
if cur <= 0 {
return false
}
if s.evBudgetRemaining.CompareAndSwap(cur, cur-1) {
return true
}
}
}
// detectSidecarChange fingerprints the book's sidecar directory and
// reconciles against the stored manifest. Returns true when the item's
// metadata must be re-extracted (a sidecar's CONTENT changed, or
// sidecars appeared/vanished). Backfill and repoint reconciliation are
// handled here without re-extraction; the caller stores a fresh full
// manifest after a change-path update.
func (s *MediaScanner) detectSidecarChange(ctx context.Context, entry *scanEntry, libraryID pgtype.UUID, path string) bool {
dir := bookSidecarDir(path)
return s.detectSidecarChangeWithRepresentative(ctx, entry, libraryID, path, dir)
}
// detectSidecarChangeWithRepresentative is detectSidecarChange with the
// sidecar-name derivation decoupled from the map key. Multi-file books
// (faceted plan Phase 6) are keyed by the item's file_path (the folder
// root / zip), but the extractor reads a folder book's sidecars through
// the sentinel INSIDE the root — whose basename yields no "<base>.<ext>"
// cover candidates — so the manifest must stat the representative's name
// list to never disagree with what extraction consumes.
func (s *MediaScanner) detectSidecarChangeWithRepresentative(ctx context.Context, entry *scanEntry, libraryID pgtype.UUID, representative, dir string) bool {
computed := statSidecarManifest(representative, dir)
stored := decodeSidecarManifest(entry.Manifest)
// Extractor-version gate (faceted plan Phase 10): a manifest stamped
// by an older parser re-extracts even when every sidecar byte is
// unchanged — the fingerprint says "disk same", ev says "parser
// newer". Budget-bounded: over budget → fall through, the stale ev
// stays stored, a later poll resumes. Checked FIRST so books with
// no sidecars (nothing to reconcile) are still caught up.
if stored != nil && manifestEv(stored) != s.GetExtractorVersion() && s.evBudgetTake() {
return true
}
if len(computed.Entries) == 0 {
if stored != nil && len(stored.Entries) > 0 {
return true // every sidecar was removed — fall back to embedded
}
if stored == nil {
// Never had sidecars: nothing to fingerprint, but the book
// still needs an ev home so future parser bumps see it. A
// first-sighting store follows no extraction → baseline
// (ev unset → eligible for the next bump).
s.storeManifestBaseline(ctx, libraryID, entry, &sidecarManifest{Version: sidecarManifestVersion})
}
return false
}
if stored == nil {
// Deploy-day backfill: hash + store WITHOUT re-extraction. The
// row was extracted by an unknown parser version — the stamp is
// deliberately OMITTED (ev unset → "v1-or-unknown") so a later
// scan_extractor_version bump still catches it.
s.storeManifestBaseline(ctx, libraryID, entry, hashSidecarManifest(representative, dir, computed))
return false
}
fresh, changed := reconcileSidecarManifest(dir, stored, computed)
if changed {
return true
}
if fresh != nil {
// Repoint reconciliation refreshed stat pairs only — the row was
// NOT re-extracted, so the manifest must keep the row's stored
// ev (a stamp here would claim parser work that never ran;
// possible when the ev budget was exhausted this pass).
s.storeManifestBaseline(ctx, libraryID, entry, fresh)
}
return false
}
@@ -0,0 +1,176 @@
package services
// Unit tests for the sidecar manifest ladder (faceted plan Part 2 §3):
// stat-pair fast compare, hash-based repoint reconciliation, and the
// change verdicts. Real files only where the hash compare needs them.
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestDecodeSidecarManifest(t *testing.T) {
if decodeSidecarManifest(nil) != nil {
t.Error("NULL manifest decodes to nil (backfill territory)")
}
if decodeSidecarManifest([]byte("{not json")) != nil {
t.Error("corrupt manifest decodes to nil (self-healing backfill)")
}
m := decodeSidecarManifest([]byte(`{"v":1,"entries":[{"n":"metadata.opf","s":10,"m":100,"h":"abc"}]}`))
if m == nil || len(m.Entries) != 1 || m.Entries[0].Sha != "abc" {
t.Errorf("valid manifest round-trip failed: %+v", m)
}
}
// TestStatSidecarManifestSentinelNames pins the Phase 6 name derivation
// for folder books: the manifest must stat exactly the names the
// extractor consumes through the sentinel (metadata sidecars + the
// standard cover set) — never the directory's own basename (the
// extractor's basename-pattern covers resolve against the sentinel's
// basename, which generates no candidates).
func TestStatSidecarManifestSentinelNames(t *testing.T) {
dir := t.TempDir()
write := func(name string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
write("metadata.json")
write("cover.jpg")
write(filepath.Base(dir) + ".jpg") // extractor never reads this
sentinel := filepath.Join(dir, ".audiobook-root")
m := statSidecarManifest(sentinel, dir)
if m == nil {
t.Fatal("manifest must stat, not nil")
}
seen := map[string]bool{}
for _, e := range m.Entries {
seen[e.Name] = true
}
if !seen["metadata.json"] || !seen["cover.jpg"] {
t.Errorf("extractor-consumed sidecars missing from manifest: %v", seen)
}
if seen[filepath.Base(dir)+".jpg"] {
t.Errorf("directory-basename cover must not be tracked (extractor ignores it): %v", seen)
}
for name := range seen {
if name == ".audiobook-root.jpg" {
t.Errorf("sentinel-basename candidates must not appear (they cannot exist): %v", seen)
}
}
}
func TestReconcileSidecarManifest(t *testing.T) {
dir := t.TempDir()
write := func(name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
// The book: one hashed sidecar + one size-only cover.
write("metadata.opf", `<opf>original content</opf>`)
originalSha, err := sha256File(filepath.Join(dir, "metadata.opf"))
if err != nil {
t.Fatal(err)
}
write("cover.jpg", "coverbytes")
// stored is seeded exactly the way production backfill does: a real
// stat pass + hash fill. Hand-built fake mtimes would drift on the
// first compare (real files' mtimes never equal a constant).
stored := hashSidecarManifest("book.epub", dir, statSidecarManifest(filepath.Join(dir, "book.epub"), dir))
stat := func() *sidecarManifest {
m := statSidecarManifest(filepath.Join(dir, "book.epub"), dir)
if m == nil || len(m.Entries) < 2 {
t.Fatalf("stat manifest should find both sidecars, got %+v", m)
}
return m
}
// Stat pairs identical → unchanged, zero reads, nothing to store.
fresh, changed := reconcileSidecarManifest(dir, stored, stat())
if changed || fresh != nil {
t.Errorf("identical stats must be unchanged, got changed=%v fresh=%v", changed, fresh)
}
// Repoint: mtime drifts, content identical → reconciled fresh
// manifest with the refreshed stat pair and the KEPT hash, no change.
if err := os.Chtimes(filepath.Join(dir, "metadata.opf"), time.Now(), time.Now().Add(time.Minute)); err != nil {
t.Fatal(err)
}
fresh, changed = reconcileSidecarManifest(dir, stored, stat())
if changed {
t.Error("mtime drift with identical content is repoint reconciliation, not a change")
}
if fresh == nil || len(fresh.Entries) != 2 {
t.Fatalf("repoint must produce a refreshed manifest, got %+v", fresh)
}
if fresh.Entries[0].Sha != originalSha {
t.Error("repointed entry keeps its stored hash")
}
if fresh.Entries[0].MTime == stored.Entries[0].MTime {
t.Error("repointed entry refreshes its stat pair")
}
// Same-size edit: drift + hash differs → changed.
write("metadata.opf", `<opf>mutated XYY content</opf>`) // +1 byte, and hash differs
_, changed = reconcileSidecarManifest(dir, stored, stat())
if !changed {
t.Error("content drift must be a change")
}
// Same-LENGTH edit (the size-only detection blind spot) → changed.
write("metadata.opf", `<opf>original Xontent</opf>`)
_, changed = reconcileSidecarManifest(dir, stored, stat())
if !changed {
t.Error("same-size content drift must be a change (hash compare)")
}
// New sidecar appears → changed.
write("metadata.json", `{"title":"X"}`)
_, changed = reconcileSidecarManifest(dir, stored, stat())
if !changed {
t.Error("an appearing sidecar must be a change")
}
// Sidecar vanishes AND everything else returns to the stored state
// (opf restored; its mtime drifts but the hash proves equality) →
// the manifest reconciles, not a change.
if err := os.Remove(filepath.Join(dir, "metadata.json")); err != nil {
t.Fatal(err)
}
write("metadata.opf", `<opf>original content</opf>`)
fresh, changed = reconcileSidecarManifest(dir, stored, stat())
if changed {
t.Errorf("returning to the stored state is not a change: %+v", fresh)
}
if fresh == nil || len(fresh.Entries) != 2 {
t.Errorf("reconciled manifest covers both sidecars: %+v", fresh)
}
}
// An unhashable drift (a stored entry with no baseline hash — legacy
// or partially-written manifest) is a change: the manifest cannot prove
// content equality for it.
func TestReconcileUnhashableDrift(t *testing.T) {
dir := t.TempDir()
stored := &sidecarManifest{Version: 1, Entries: []manifestSidecar{
{Name: "cover.jpg", Size: 10, MTime: 1000},
}}
computed := &sidecarManifest{Version: 1, Entries: []manifestSidecar{
{Name: "cover.jpg", Size: 11, MTime: 1001},
}}
fresh, changed := reconcileSidecarManifest(dir, stored, computed)
if !changed {
t.Error("stat drift without a stored baseline hash must be a change")
}
if fresh != nil {
t.Error("a change never returns a fresh manifest (caller builds one)")
}
}
@@ -139,8 +139,8 @@ func TestParseOPFContentCalibreSeries(t *testing.T) {
if err != nil {
t.Fatalf("parseOPFContent() error: %v", err)
}
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2 {
t.Errorf("Series = %q/%d, want Great Series/2", metadata.Series, metadata.SeriesNumber)
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2.5 {
t.Errorf("Series = %q/%v, want Great Series/2.5", metadata.Series, metadata.SeriesNumber)
}
if metadata.ISBN == "" {
t.Error("schemed ISBN not extracted")
@@ -179,8 +179,8 @@ func TestExtractAudiobookshelfSidecar(t *testing.T) {
if m.Title != "An Book" || m.Author != "Author One" {
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
}
if m.Series != "The Series" || m.SeriesNumber != 4 {
t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber)
if m.Series != "The Series" || m.SeriesNumber != 4.5 {
t.Errorf("Series = %q/%v, want The Series/4.5", m.Series, m.SeriesNumber)
}
if len(m.Tags) != 2 {
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
+2 -2
View File
@@ -171,8 +171,8 @@ func TestParseOPFContentTitleTypeAndSeries(t *testing.T) {
if want := "The Main Title: The Subtitle"; metadata.Title != want {
t.Errorf("Title = %q, want %q", metadata.Title, want)
}
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4 {
t.Errorf("Series = %q/%d, want Great Series/4", metadata.Series, metadata.SeriesNumber)
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4.5 {
t.Errorf("Series = %q/%v, want Great Series/4.5", metadata.Series, metadata.SeriesNumber)
}
if metadata.ISBN == "" {
t.Error("urn:isbn: identifier not extracted")
@@ -30,3 +30,37 @@ func TestMediaScanner_GetAutoScanEnabled(t *testing.T) {
}
})
}
func TestMediaScanner_GetExtractorVersion(t *testing.T) {
t.Run("nil db returns default 1", func(t *testing.T) {
scanner := &MediaScanner{
db: nil,
settingsCache: NewSettingsCache(30 * time.Second),
}
if v := scanner.GetExtractorVersion(); v != 1 {
t.Errorf("expected 1, got %d", v)
}
})
t.Run("cached value wins (scanner read-through path)", func(t *testing.T) {
scanner := &MediaScanner{
db: nil,
settingsCache: NewSettingsCache(30 * time.Second),
}
scanner.settingsCache.Set("scan_extractor_version", "3")
if v := scanner.GetExtractorVersion(); v != 3 {
t.Errorf("expected 3, got %d", v)
}
})
t.Run("cached garbage rides the default", func(t *testing.T) {
scanner := &MediaScanner{
db: nil,
settingsCache: NewSettingsCache(30 * time.Second),
}
scanner.settingsCache.Set("scan_extractor_version", "zero")
if v := scanner.GetExtractorVersion(); v != 1 {
t.Errorf("expected 1, got %d", v)
}
})
}
+211 -15
View File
@@ -2,8 +2,10 @@ package services
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"errors"
"fmt"
"strings"
"github.com/jackc/pgx/v5"
@@ -12,12 +14,16 @@ import (
// SearchService handles all search and filter operations
type SearchService struct {
db *database.Queries
db *database.Queries
raw database.DBTX // the raw pgx handle — the composed filter engines' path
}
// NewSearchService creates a new search service instance
func NewSearchService(db *database.Queries) *SearchService {
return &SearchService{db: db}
// NewSearchService creates a new search service instance. raw is the
// same pool db wraps (database.DBTX) — the composed filter engines run
// hand-built, allow-listed SQL with bound parameters, which static sqlc
// queries cannot express.
func NewSearchService(db *database.Queries, raw database.DBTX) *SearchService {
return &SearchService{db: db, raw: raw}
}
// SearchParams contains parameters for unified search
@@ -38,6 +44,26 @@ type SearchParams struct {
Sort string
Limit int
Offset int
// Facet registry Batch A additions (services/facet_registry.go).
// Empty strings / invalid Bools mean "facet not active".
StoryArcFilter string
ImprintFilter string
EditionFilter string
AgeRatingFilter string
FormatGroupFilter string
MangaTypeFilter string
ReadingDirectionFilter string
ExplicitFilter pgtype.Bool
AbridgedFilter pgtype.Bool
// Filter rule builder (faceted plan Phase 9). When FilterTree is
// set the composed engine runs INSTEAD of the flat facet params
// (qf wins over flat on a hand-crafted URL — precedence pinned by
// test); FilterParseErr carries a qf parse/validation failure for
// the endpoints to answer 400.
FilterTree *FilterTree
FilterParseErr string
}
// parseSearchQuery detects quoted strings for exact match search
@@ -63,6 +89,14 @@ func (s *SearchService) parseSearchQuery(query string) (bool, string) {
func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) {
// Parse search query for exact match detection
isExact, searchQuery := s.parseSearchQuery(params.SearchQuery)
// Language filter UX: the UI sends display names ("English"); the
// column stores ISO codes ("en"). Normalize each comma element the
// SAME way ingest does (normalizeLanguageCode), so a user-typed or
// deep-linked display name matches the stored code — and unknown
// values that fit still match verbatim rows. (Facet matching and
// ingest can never disagree because both run the same map.)
params.LanguageFilter = normalizeLanguageFilter(params.LanguageFilter)
// For now implementing real exact search. If you want to reimplement wildcard patterns uncomment this and line 81 and check queries.sql line 475 and frontend.go line 211
// searchPattern := ""
// if isExact {
@@ -79,11 +113,24 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
StoryArcFilter: pgtype.Text{String: params.StoryArcFilter, Valid: true},
ImprintFilter: pgtype.Text{String: params.ImprintFilter, Valid: true},
EditionFilter: pgtype.Text{String: params.EditionFilter, Valid: true},
AgeRatingFilter: pgtype.Text{String: params.AgeRatingFilter, Valid: true},
FormatGroupFilter: pgtype.Text{
String: params.FormatGroupFilter, Valid: true,
},
MangaTypeFilter: pgtype.Text{String: params.MangaTypeFilter, Valid: true},
ReadingDirectionFilter: pgtype.Text{
String: params.ReadingDirectionFilter, Valid: true,
},
ExplicitFilter: params.ExplicitFilter,
AbridgedFilter: params.AbridgedFilter,
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
// SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact},
Sort: pgtype.Text{String: params.Sort, Valid: true},
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
@@ -103,6 +150,130 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
return results, nil
}
// normalizeLanguageFilter normalizes every comma element of a language
// filter value through the ingest-side language map (display name →
// ISO code), so filtering and ingest share one source of truth.
func normalizeLanguageFilter(wireValue string) string {
if wireValue == "" {
return ""
}
parts := strings.Split(wireValue, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
out = append(out, normalizeLanguageCode(t))
}
}
return strings.Join(out, ",")
}
// NormalizeLanguageFilter is the exported form of the search-path
// normalization for callers that must compare RAW filter values
// against stored codes without issuing a query (the facet panel marks
// a value Active by matching the bound filter set).
func NormalizeLanguageFilter(wireValue string) string {
return normalizeLanguageFilter(wireValue)
}
// FacetValue is one facet value + count for the filter panel.
type FacetValue struct {
Value string `json:"value"` // the raw value (rides the filter param)
// Display is the prettified form when it differs from Value
// (language codes → names); empty → the client shows Value.
Display string `json:"display,omitempty"`
Count int64 `json:"count"`
}
// facetCountsLimit is the per-facet top-N bound the panel renders.
const facetCountsLimit = 50
// FacetCounts computes every listable facet's top values + counts under
// the active filters (faceted plan Phase 7): ONE UNION-ALL round trip
// (FacetCountsUnderFilters) in which each facet is counted under the
// OTHER facets' filters — the SQL omits each arm's own block, classic
// faceted-search semantics. With no filters bound every block is
// neutral and the counts are the v1 global-per-library ones, so one
// query serves both cases. libraryID may be NULL (the All-Libraries
// view). Display enrichment matches the wire contract: language codes
// → display names, genre values prettified — both only when they
// differ from the raw value (Display is "the prettified form when it
// differs", per FacetValue).
func (s *SearchService) FacetCounts(ctx context.Context, userID, libraryID pgtype.UUID, params SearchParams, limit int) (map[string][]FacetValue, error) {
if limit <= 0 {
limit = facetCountsLimit
}
// Language filter UX (same normalization the search path runs —
// see SearchMediaItemsUnified): display names in, ISO codes bound.
langFilter := normalizeLanguageFilter(params.LanguageFilter)
rows, err := s.db.FacetCountsUnderFilters(ctx, database.FacetCountsUnderFiltersParams{
UserID: userID,
LibraryID: libraryID,
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
LanguageFilter: pgtype.Text{String: langFilter, Valid: true},
StoryArcFilter: pgtype.Text{String: params.StoryArcFilter, Valid: true},
ImprintFilter: pgtype.Text{String: params.ImprintFilter, Valid: true},
EditionFilter: pgtype.Text{String: params.EditionFilter, Valid: true},
AgeRatingFilter: pgtype.Text{String: params.AgeRatingFilter, Valid: true},
FormatGroupFilter: pgtype.Text{String: params.FormatGroupFilter, Valid: true},
MangaTypeFilter: pgtype.Text{String: params.MangaTypeFilter, Valid: true},
ReadingDirectionFilter: pgtype.Text{String: params.ReadingDirectionFilter, Valid: true},
ExplicitFilter: params.ExplicitFilter,
AbridgedFilter: params.AbridgedFilter,
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
HasCover: params.HasCover,
})
if err != nil {
return nil, err
}
out := make(map[string][]FacetValue, 14)
for _, r := range rows {
v := FacetValue{Value: r.Value, Count: r.Count}
switch r.Facet {
case "language":
if name := LanguageDisplayName(r.Value); name != "" && name != r.Value {
v.Display = name
}
case "genre":
if d := utils.PrettifyGenre(r.Value); d != r.Value {
v.Display = d
}
}
out[r.Facet] = append(out[r.Facet], v)
}
return out, nil
}
// FacetValues lists ONE facet's top values + counts — the pinned
// single-facet shape behind GET /api/media-items/facets?facet=X. It is
// the all-facets query with every filter neutral (v1 counts:
// global-per-library, not recomputed under other filters).
// Registry-driven: only FacetArray and FacetScalar facets list values —
// bool/range facets render as toggles/ranges in the panel, not value
// lists.
func (s *SearchService) FacetValues(ctx context.Context, userID pgtype.UUID, facet Facet, libraryID pgtype.UUID, limit int) ([]FacetValue, error) {
if facet.Kind != FacetArray && facet.Kind != FacetScalar {
return nil, fmt.Errorf("facet %q does not list values", facet.Param)
}
counts, err := s.FacetCounts(ctx, userID, libraryID, SearchParams{}, limit)
if err != nil {
return nil, err
}
values := counts[facet.Param]
if values == nil {
return []FacetValue{}, nil
}
return values, nil
}
// FieldSearchParams contains parameters for field-specific search (autocomplete)
type FieldSearchParams struct {
UserID pgtype.UUID
@@ -113,11 +284,15 @@ type FieldSearchParams struct {
Offset int
}
// FieldValue represents a single field value with metadata
// FieldValue is a single field value with metadata. Display carries a
// human label when the wire value is an ID (library/collection/added_by
// suggestions) — omitted everywhere else, so the classic static shape
// {value, count, score} is unchanged.
type FieldValue struct {
Value string `json:"value"`
Count int64 `json:"count"`
Score float64 `json:"score"`
Value string `json:"value"`
Count int64 `json:"count"`
Score float64 `json:"score"`
Display string `json:"display,omitempty"`
}
// SearchFieldValues handles field-specific search for autocomplete dropdowns
@@ -223,7 +398,14 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
}
fieldValues := make([]FieldValue, len(results))
for i, r := range results {
fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score}
// Serve display names ("English") — the user clicks a name,
// the search service normalizes it back to the stored ISO
// code ("en"). Unknown codes pass through verbatim.
shown := r.Value.String
if name := LanguageDisplayName(shown); name != "" {
shown = name
}
fieldValues[i] = FieldValue{Value: shown, Count: r.Count, Score: r.Score}
}
return fieldValues, nil
@@ -233,8 +415,22 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
}
// ExecuteSearch performs search and returns results with count
// Shared between JSON endpoint and HTML rendering
// Shared between JSON endpoint and HTML rendering.
// Routing: a qf filter tree → the composed engine (flat params ignored
// — qf wins); else the static unified query. A parse/validation failure
// surfaces as ErrInvalidFilterTree (→ 400 at the endpoints).
func (s *SearchService) ExecuteSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
if params.FilterParseErr != "" {
return nil, 0, fmt.Errorf("%w: %s", ErrInvalidFilterTree, params.FilterParseErr)
}
if params.FilterTree != nil {
rows, err := s.ComposedSearch(ctx, params)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, err
}
count := len(rows)
return rows, count, nil
}
results, err := s.SearchMediaItemsUnified(ctx, params)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, 0, err
+265
View File
@@ -0,0 +1,265 @@
package services
import (
"encoding/json"
"regexp"
"strconv"
"strings"
)
// Series-metadata parsing helpers for the scanner (A2c overhaul).
//
// Sources disagree wildly on how a series and its ordinal are spelled:
// ABS sidecars write ["Name #3"] string arrays or [{series, sequence}]
// objects, Libation writes a bare string, Audible prefixes ordinals with
// "Book ", Calibre uses decimal indices. These helpers normalize all of
// them into candidates the scanner can rank and assign.
// SeriesCandidate is one parsed series membership from any source.
type SeriesCandidate struct {
Name string
Sequence string // raw sequence text ("3", "1.5", "1a", "Book 2")
Number float64 // leading number of Sequence (0 = none parsed)
HasNum bool
FromSidecar bool
}
// Playlist markers that identify ordering constructs rather than
// bibliographic series. Deliberately explicit: this classification routes
// entries away from the series column into curated collections, so a false
// positive creates user-visible objects. Prefer misses over false hits.
var playlistMarkers = []string{
"reading order",
"chronological order",
"publication order",
"in order",
"timeline",
}
var (
seriesSeqSuffixRe = regexp.MustCompile(`\s+#([^#\s]+)$`)
// Leading ordinal inside a sequence string: "3", "1.5", "Book 2",
// "2, Dramatized Adaptation" → 2. Mirrors ABS cleanSeriesSequence.
seriesLeadingNumRe = regexp.MustCompile(`\d+(?:\.\d+)?`)
// "<Series Name>, Book N" (also ; – : separators), Audible/Amazon style.
deriveSeriesRe = regexp.MustCompile(`(?i)^\s*(.+?)\s*[,;:–-]\s*book\s+(\d+(?:\.\d+)?)\s*$`)
// Conservative "Title: <name>, Book N" split — only when the post-colon
// text is itself a series-and-number tail.
titleSubtitleSplitRe = regexp.MustCompile(`(?i)^(.{1,200}?)\s*:\s*(.+,\s*book\s+\d+(?:\.\d+)?)$`)
whitespaceRe = regexp.MustCompile(`\s+`)
)
// splitSeriesString splits "Name #1a" into name + sequence, ABS
// parseSeriesString-compatible: the sequence is anything after the last
// " #" up to end-of-string, containing no whitespace or further '#'.
func splitSeriesString(s string) (name, sequence string) {
s = strings.TrimSpace(s)
loc := seriesSeqSuffixRe.FindStringSubmatchIndex(s)
if loc == nil {
return s, ""
}
return strings.TrimSpace(s[:loc[0]]), s[loc[2]:loc[3]]
}
// seriesSequenceNumber extracts the ordinal from a raw sequence string.
// Returns the number, whether one was found, and whether information was
// lost in the extraction (caller should preserve the raw text, e.g. "1a").
func seriesSequenceNumber(sequence string) (num float64, found, lossy bool) {
trimmed := strings.TrimSpace(sequence)
if trimmed == "" {
return 0, false, false
}
token := seriesLeadingNumRe.FindString(trimmed)
if token == "" {
return 0, false, false
}
num, err := strconv.ParseFloat(token, 64)
if err != nil {
return 0, false, false
}
// "1.5" tokenizes exactly (not lossy); "1a"/"Book 2" keep a raw tail.
return num, true, trimmed != token
}
// isPlaylistSeriesName reports whether the series name is an ordering
// construct ("The Witcher (Reading Order)") rather than a bibliography.
// Match discipline: the marker must be a parenthetical qualifier or a
// trailing phrase — a series merely CONTAINING the word "timeline" mid-name
// ("The Timeline of...") is a real series, not a playlist.
func isPlaylistSeriesName(name string) bool {
lower := strings.ToLower(strings.TrimSpace(name))
for _, m := range playlistMarkers {
if strings.HasSuffix(lower, m) {
return true
}
if open := strings.Index(lower, "("); open >= 0 {
if strings.Contains(lower[open:], m) {
return true
}
}
}
return false
}
// parseSeriesCandidates normalizes the sidecar `series` field, which is
// written as an array of STRINGS ("Name #3" — ABS abmetadata), an array of
// OBJECTS ({series, sequence} — some exports), or a plain JSON string
// (Libation), into candidates.
func parseSeriesCandidates(raw json.RawMessage) []SeriesCandidate {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return nil
}
var out []SeriesCandidate
add := func(name, sequence string, fromSidecar bool) {
name = strings.TrimSpace(name)
if name == "" {
return
}
num, found, _ := seriesSequenceNumber(sequence)
out = append(out, SeriesCandidate{
Name: name, Sequence: sequence, Number: num, HasNum: found,
FromSidecar: fromSidecar,
})
}
if strings.HasPrefix(trimmed, "[") {
// Array of objects first ({"series": ..., "sequence": ...}).
var objs []struct {
Series string `json:"series"`
Name string `json:"name"`
Sequence string `json:"sequence"`
}
if err := json.Unmarshal(raw, &objs); err == nil && len(objs) > 0 {
for _, o := range objs {
name := o.Series
if name == "" {
name = o.Name
}
add(name, o.Sequence, true)
}
return out
}
// Array of strings: ABS abmetadata form "Name #3".
var strs []string
if err := json.Unmarshal(raw, &strs); err == nil {
for _, s := range strs {
name, seq := splitSeriesString(s)
add(name, seq, true)
}
return out
}
return nil
}
// Plain JSON string (Libation): "Name" or "Name #3".
var single string
if err := json.Unmarshal(raw, &single); err == nil {
name, seq := splitSeriesString(single)
add(name, seq, true)
}
return out
}
// deriveSeriesFromText extracts "<series>, Book N" from a title/subtitle
// string (Audible/Amazon convention). Returns ok=false when the text is not
// obviously a series tail — this fallback must not guess.
func deriveSeriesFromText(text string) (name string, num float64, hasNum bool, ok bool) {
if strings.TrimSpace(text) == "" {
return "", 0, false, false
}
m := deriveSeriesRe.FindStringSubmatch(text)
if m == nil {
return "", 0, false, false
}
name = whitespaceRe.ReplaceAllString(strings.TrimSpace(m[1]), " ")
if name == "" || len(name) > 200 || strings.HasPrefix(strings.ToLower(name), "book ") {
return "", 0, false, false
}
num, err := strconv.ParseFloat(m[2], 64)
if err != nil {
return "", 0, false, false
}
return name, num, true, true
}
// splitTitleOnSubtitle splits "Title: <name>, Book N" into title +
// subtitle, but ONLY when the post-colon text is itself a series-and-number
// tail — arbitrary colons in titles are never touched.
func splitTitleOnSubtitle(folderTitle string) (title, subtitle string, split bool) {
m := titleSubtitleSplitRe.FindStringSubmatch(strings.TrimSpace(folderTitle))
if m == nil {
return folderTitle, "", false
}
return strings.TrimSpace(m[1]), strings.TrimSpace(m[2]), true
}
// normalizeLanguageCode maps sidecar/display language names to ISO 639-1
// codes (the media_items.language convention). Values already in code form
// pass through; unknown display names are kept verbatim when they fit the
// VARCHAR(10) column, else dropped (empty) — never truncate mid-word.
func normalizeLanguageCode(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
if code, ok := languageNameToCode[strings.ToLower(v)]; ok {
return code
}
lower := strings.ToLower(v)
if len(lower) == 2 {
return lower // already a code (or close enough to be harmless)
}
if len(v) <= 10 {
return v
}
return ""
}
// languageCodeToName — canonical English display name per ISO code,
// mirroring languageNameToCode (server owns display; the column stores
// ISO 639-1).
var languageCodeToName = map[string]string{
"en": "English", "es": "Spanish", "fr": "French", "de": "German",
"it": "Italian", "pt": "Portuguese", "nl": "Dutch", "ru": "Russian",
"uk": "Ukrainian", "pl": "Polish", "cs": "Czech", "sk": "Slovak",
"sv": "Swedish", "no": "Norwegian", "da": "Danish", "fi": "Finnish",
"is": "Icelandic", "hu": "Hungarian", "ro": "Romanian", "bg": "Bulgarian",
"el": "Greek", "tr": "Turkish", "ar": "Arabic", "he": "Hebrew",
"fa": "Persian", "hi": "Hindi", "bn": "Bengali", "ur": "Urdu",
"th": "Thai", "vi": "Vietnamese", "id": "Indonesian", "ms": "Malay",
"ja": "Japanese", "ko": "Korean", "zh": "Chinese", "sw": "Swahili",
"af": "Afrikaans", "ca": "Catalan", "hr": "Croatian", "sr": "Serbian",
"sl": "Slovenian", "lt": "Lithuanian", "lv": "Latvian", "et": "Estonian",
"ka": "Georgian", "hy": "Armenian", "cy": "Welsh", "ga": "Irish",
"eu": "Basque", "gl": "Galician",
}
// LanguageDisplayName renders a stored language value for DISPLAY.
// Returns "" when the value is not a known code — callers keep the raw
// value then (legacy verbatim rows like "Pig Latin" display as
// themselves).
func LanguageDisplayName(code string) string {
if name, ok := languageCodeToName[strings.ToLower(strings.TrimSpace(code))]; ok {
return name
}
return ""
}
var languageNameToCode = map[string]string{
"english": "en", "spanish": "es", "español": "es", "french": "fr",
"français": "fr", "german": "de", "deutsch": "de", "italian": "it",
"italiano": "it", "portuguese": "pt", "dutch": "nl", "russian": "ru",
"ukrainian": "uk", "polish": "pl", "czech": "cs", "slovak": "sk",
"swedish": "sv", "norwegian": "no", "danish": "da", "finnish": "fi",
"icelandic": "is", "hungarian": "hu", "romanian": "ro", "bulgarian": "bg",
"greek": "el", "turkish": "tr", "arabic": "ar", "hebrew": "he",
"persian": "fa", "hindi": "hi", "bengali": "bn", "urdu": "ur",
"thai": "th", "vietnamese": "vi", "indonesian": "id", "malay": "ms",
"japanese": "ja", "korean": "ko", "chinese": "zh", "mandarin": "zh",
"cantonese": "zh", "swahili": "sw", "afrikaans": "af", "catalan": "ca",
"croatian": "hr", "serbian": "sr", "slovenian": "sl", "lithuanian": "lt",
"latvian": "lv", "estonian": "et", "georgian": "ka", "armenian": "hy",
"welsh": "cy", "irish": "ga", "basque": "eu", "galician": "gl",
}
+322
View File
@@ -0,0 +1,322 @@
package services
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
func TestSplitSeriesString(t *testing.T) {
cases := []struct{ in, name, seq string }{
{"The Witcher #3", "The Witcher", "3"},
{"Name #1a", "Name", "1a"},
{"No Sequence", "No Sequence", ""},
{"Name #1#a", "Name #1#a", ""}, // embedded # is not a sequence
}
for _, c := range cases {
name, seq := splitSeriesString(c.in)
if name != c.name || seq != c.seq {
t.Errorf("splitSeriesString(%q) = (%q, %q), want (%q, %q)", c.in, name, seq, c.name, c.seq)
}
}
}
func TestSeriesSequenceNumber(t *testing.T) {
cases := []struct {
in string
num float64
found bool
lossy bool
}{
{"3", 3, true, false},
{"1.5", 1.5, true, false},
{"1a", 1, true, true},
{"Book 2", 2, true, true},
{"2, Dramatized Adaptation", 2, true, true},
{"", 0, false, false},
{"novella", 0, false, false},
}
for _, c := range cases {
num, found, lossy := seriesSequenceNumber(c.in)
if num != c.num || found != c.found || lossy != c.lossy {
t.Errorf("seriesSequenceNumber(%q) = (%v,%v,%v), want (%v,%v,%v)",
c.in, num, found, lossy, c.num, c.found, c.lossy)
}
}
}
// The A2c headline bug: ABS abmetadata writes series as an array of
// "Name #seq" STRINGS. The old parser only accepted object arrays and
// silently dropped every ABS-matched series.
func TestParseSeriesCandidatesStringArray(t *testing.T) {
raw := []byte(`["The Witcher #3", "The Witcher (Reading Order) #5"]`)
candidates := parseSeriesCandidates(raw)
if len(candidates) != 2 {
t.Fatalf("got %d candidates, want 2", len(candidates))
}
if candidates[0].Name != "The Witcher" || candidates[0].Number != 3 || !candidates[0].HasNum {
t.Errorf("candidate[0] = %+v", candidates[0])
}
if candidates[1].Name != "The Witcher (Reading Order)" || candidates[1].Number != 5 {
t.Errorf("candidate[1] = %+v", candidates[1])
}
if !isPlaylistSeriesName(candidates[1].Name) {
t.Errorf("reading-order entry must classify as playlist-ish")
}
if isPlaylistSeriesName(candidates[0].Name) {
t.Errorf("publication series must not classify as playlist-ish")
}
}
func TestParseSeriesCandidatesOtherShapes(t *testing.T) {
objs := parseSeriesCandidates([]byte(`[{"series":"Great Series","sequence":"2.5"}]`))
if len(objs) != 1 || objs[0].Name != "Great Series" || objs[0].Number != 2.5 {
t.Errorf("object form: %+v", objs)
}
libation := parseSeriesCandidates([]byte(`"Libation Series #4"`))
if len(libation) != 1 || libation[0].Name != "Libation Series" || libation[0].Number != 4 {
t.Errorf("plain string form: %+v", libation)
}
if parseSeriesCandidates([]byte(`null`)) != nil {
t.Errorf("null must yield no candidates")
}
}
func TestIsPlaylistSeriesName(t *testing.T) {
positives := []string{
"The Witcher (Reading Order)",
"Discworld Reading Order",
"Narnia Chronological Order",
"Robin Hobb in order",
"Series Publication Order",
"Middle Earth Timeline",
}
for _, p := range positives {
if !isPlaylistSeriesName(p) {
t.Errorf("%q should be playlist-ish", p)
}
}
negatives := []string{"The Witcher", "Witcher Saga", "The Timeline of..."}
for _, n := range negatives {
if isPlaylistSeriesName(n) {
t.Errorf("%q must not be playlist-ish", n)
}
}
}
func TestDeriveSeriesFromText(t *testing.T) {
name, num, hasNum, ok := deriveSeriesFromText("The Witcher, Book 3")
if !ok || name != "The Witcher" || num != 3 || !hasNum {
t.Errorf("got (%q,%v,%v,%v)", name, num, hasNum, ok)
}
name, num, hasNum, ok = deriveSeriesFromText("The Witcher, Book 1.5")
if !ok || name != "The Witcher" || num != 1.5 || !hasNum {
t.Errorf("decimal: got (%q,%v,%v,%v)", name, num, hasNum, ok)
}
// Not obviously a series tail → must refuse rather than guess.
for _, negative := range []string{"1984", "The Fellowship", "Book Ends"} {
if _, _, _, ok := deriveSeriesFromText(negative); ok {
t.Errorf("%q must not derive a series", negative)
}
}
}
func TestSplitTitleOnSubtitle(t *testing.T) {
title, subtitle, split := splitTitleOnSubtitle("Baptism of Fire: The Witcher, Book 3")
if !split || title != "Baptism of Fire" || subtitle != "The Witcher, Book 3" {
t.Errorf("got (%q,%q,%v)", title, subtitle, split)
}
// Arbitrary colons never split.
_, _, split = splitTitleOnSubtitle("Star Wars: A New Hope")
if split {
t.Errorf("non-series colon must not split")
}
}
func TestApplyTitleDerivedSeries(t *testing.T) {
// The user's actual naming, two-level folder, no sidecar anywhere.
meta := &MediaMetadata{Title: "Baptism of Fire: The Witcher, Book 3"}
applyTitleDerivedSeries(meta)
if meta.Series != "The Witcher" || meta.SeriesNumber != 3 {
t.Errorf("series = %q #%v, want The Witcher #3", meta.Series, meta.SeriesNumber)
}
if meta.Title != "Baptism of Fire: The Witcher, Book 3" {
t.Errorf("fallback must never mutate the display title, got %q", meta.Title)
}
// Subtitle already split by the folder parser: number + name both land.
meta = &MediaMetadata{Title: "Blood of Elves", Subtitle: "The Witcher, Book 1"}
applyTitleDerivedSeries(meta)
if meta.Series != "The Witcher" || meta.SeriesNumber != 1 {
t.Errorf("series = %q #%v, want The Witcher #1", meta.Series, meta.SeriesNumber)
}
// Existing series data is never overwritten (blanks-only fallback)...
meta = &MediaMetadata{Title: "X", Subtitle: "Derived, Book 9", Series: "Real Series"}
applyTitleDerivedSeries(meta)
if meta.Series != "Real Series" || meta.SeriesNumber != 0 {
t.Errorf("a number from a different series name must not apply, got %q #%v", meta.Series, meta.SeriesNumber)
}
// ...but a number whose derived name MATCHES the existing series fills it.
meta = &MediaMetadata{Title: "X", Subtitle: "Real Series, Book 9", Series: "Real Series"}
applyTitleDerivedSeries(meta)
if meta.Series != "Real Series" || meta.SeriesNumber != 9 {
t.Errorf("matching-name number must fill, got %q #%v", meta.Series, meta.SeriesNumber)
}
}
func TestPickSmartGenre(t *testing.T) {
// The real Malazan Audible blob: one string, four genres.
genre, all := pickSmartGenre([]string{"Epic, Action & Adventure, Military, Sword & Sorcery"})
if genre != "action & adventure" {
t.Errorf("genre = %q, want action & adventure", genre)
}
if len(all) != 4 {
t.Errorf("candidates = %v, want the 4 split parts", all)
}
// Format noise never wins.
if genre, _ := pickSmartGenre([]string{"Audiobook"}); genre != "" {
t.Errorf("audiobook is not a genre, got %q", genre)
}
// Fiction is the floor: alone it resolves, against a real shelf it loses.
if genre, _ := pickSmartGenre([]string{"Fiction"}); genre != "fiction" {
t.Errorf("plain Fiction should still resolve, got %q", genre)
}
// Genre model v2: Epic Fantasy is a SUBGENRE — the genre column gets
// its parent shelf and the specific label lives in tags. (Intended
// change from the old longest-match behavior, which returned
// "epic fantasy" here.)
if genre, _ := pickSmartGenre([]string{"Fiction", "Epic Fantasy"}); genre != "fantasy" {
t.Errorf("subgenre must resolve to its shelf, got %q", genre)
}
// User-verified traces (Batch B decision):
if genre, _ := pickSmartGenre([]string{"Pirates", "Adventure"}); genre != "adventure" {
t.Errorf("direct shelf beats mapped subgenre, got %q", genre)
}
if genre, _ := pickSmartGenre([]string{"Pirates"}); genre != "adventure" {
t.Errorf("mapped subgenre alone resolves to its shelf, got %q", genre)
}
if genre, _ := pickSmartGenre([]string{"Sci-Fi", "Science Fiction", "Fiction"}); genre != "science fiction" {
t.Errorf("direct shelf beats floor, got %q", genre)
}
// Type-words describe the library type, not a shelf.
if genre, _ := pickSmartGenre([]string{"Manga"}); genre != "" {
t.Errorf("manga is a type-word, got %q", genre)
}
// Unknown-only input: no shelf is known — tags carry it, never guess.
if genre, all := pickSmartGenre([]string{"Completely Made-Up Genre"}); genre != "" {
t.Errorf("unknown-only input must leave genre empty, got %q (tags=%v)", genre, all)
}
}
func TestNormalizeLanguageCode(t *testing.T) {
cases := []struct{ in, want string }{
{"English", "en"},
{"en", "en"},
{"German", "de"},
{"", ""},
{"Norwegian (Bokmål)", ""}, // too long for VARCHAR(10); dropped, never truncated
{"Pig Latin", "Pig Latin"}, // unknown display name that fits: kept
}
for _, c := range cases {
if got := normalizeLanguageCode(c.in); got != c.want {
t.Errorf("normalizeLanguageCode(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// End-to-end: a REAL ABS-shaped sidecar (string-array series, the shape
// every ABS-matched book writes) must land series, language, explicit and
// the full author array.
func TestExtractAudiobookshelfSidecarStringArraySeries(t *testing.T) {
dir := t.TempDir()
sidecar := `{
"title": "Blood of Elves",
"authors": ["Andrzej Sapkowski"],
"narrators": ["Peter Kenny"],
"series": ["The Witcher #1", "The Witcher (Reading Order) #3"],
"genres": ["Fiction"],
"language": "English",
"explicit": true,
"publisher": "Orbit"
}`
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(sidecar), 0644); err != nil {
t.Fatal(err)
}
meta := extractAudiobookshelfSidecar(filepath.Join(dir, "book.m4b"))
if meta == nil {
t.Fatal("sidecar not extracted")
}
if meta.Series != "The Witcher" || meta.SeriesNumber != 1 {
t.Errorf("series = %q #%v", meta.Series, meta.SeriesNumber)
}
if len(meta.Authors) != 1 || meta.Authors[0] != "Andrzej Sapkowski" {
t.Errorf("authors array = %v", meta.Authors)
}
if meta.Language != "en" {
t.Errorf("language = %q, want en", meta.Language)
}
if !meta.Explicit {
t.Errorf("explicit flag lost")
}
if len(meta.PlaylistSeries) != 1 || meta.PlaylistSeries[0].Name != "The Witcher (Reading Order)" {
t.Errorf("playlist series = %+v", meta.PlaylistSeries)
}
if meta.AlternateInfo != "" {
t.Errorf("playlist entry must not leak into alternate_info, got %q", meta.AlternateInfo)
}
}
// Additional real series (the Drizzt case: Icewind Dale #1 = Legend of
// Drizzt #4) land in alternate_info with the comic-established shape.
func TestExtractAudiobookshelfSidecarExtraSeriesToAlternateInfo(t *testing.T) {
dir := t.TempDir()
sidecar := `{
"title": "The Crystal Shard",
"authors": ["R.A. Salvatore"],
"series": [
{"series": "The Icewind Dale Trilogy", "sequence": "1"},
{"series": "The Legend of Drizzt", "sequence": "4"}
]
}`
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(sidecar), 0644); err != nil {
t.Fatal(err)
}
meta := extractAudiobookshelfSidecar(filepath.Join(dir, "book.m4b"))
if meta.Series != "The Icewind Dale Trilogy" || meta.SeriesNumber != 1 {
t.Errorf("series = %q #%v", meta.Series, meta.SeriesNumber)
}
if meta.AlternateInfo == "" {
t.Fatal("second series must land in alternate_info")
}
// Lossy sequence text is preserved, never silently dropped.
meta2 := extractAudiobookshelfSidecarPath(t, dir, `{"title":"X","series":["Saga #1a"]}`)
if meta2 == nil || meta2.Series != "Saga" || meta2.SeriesNumber != 1 {
t.Fatalf("lossy series = %+v", meta2)
}
if meta2.AlternateInfo == "" || !strings.Contains(meta2.AlternateInfo, "series_sequence_raw") || !strings.Contains(meta2.AlternateInfo, "1a") {
t.Errorf("raw sequence not preserved: %q", meta2.AlternateInfo)
}
}
func extractAudiobookshelfSidecarPath(t *testing.T, dir, json string) *MediaMetadata {
t.Helper()
// extractAudiobookshelfSidecar looks for exactly "metadata.json"; use a
// fresh dir per call instead.
fresh := t.TempDir()
if err := os.WriteFile(filepath.Join(fresh, "metadata.json"), []byte(json), 0644); err != nil {
t.Fatal(err)
}
return extractAudiobookshelfSidecar(filepath.Join(fresh, "book.m4b"))
}
// The series page tiebreaker rides on the same ordering contract the
// scanner feeds: numbers sort numerically, NULLs last (§3.6).
func TestSeriesNumberFloatOrderingValues(t *testing.T) {
if !(pgtype.Float8{Float64: 1}.Float64 < pgtype.Float8{Float64: 1.5}.Float64) {
t.Fatal("1 must sort before 1.5")
}
}
+4 -4
View File
@@ -26,7 +26,7 @@ func TestContinueSeriesRowToMediaItems_FieldsMappedCorrectly(t *testing.T) {
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
CoverImagePath: pgtype.Text{String: "/covers/test.jpg", Valid: true},
Series: pgtype.Text{String: "Test Series", Valid: true},
SeriesNumber: pgtype.Int4{Int32: 3, Valid: true},
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
Tags: []string{"fantasy", "adventure"},
FormatGroup: "epub",
AddedByAdminID: pgtype.UUID{Bytes: adminUUID, Valid: true},
@@ -43,7 +43,7 @@ func TestContinueSeriesRowToMediaItems_FieldsMappedCorrectly(t *testing.T) {
assert.Equal(t, pgtype.Text{String: "application/epub+zip", Valid: true}, result.MimeType, "MimeType should match")
assert.Equal(t, pgtype.Text{String: "/covers/test.jpg", Valid: true}, result.CoverImagePath, "CoverImagePath should match")
assert.Equal(t, pgtype.Text{String: "Test Series", Valid: true}, result.Series, "Series should match")
assert.Equal(t, pgtype.Int4{Int32: 3, Valid: true}, result.SeriesNumber, "SeriesNumber should match")
assert.Equal(t, pgtype.Float8{Float64: 3, Valid: true}, result.SeriesNumber, "SeriesNumber should match")
assert.Equal(t, []string{"fantasy", "adventure"}, result.Tags, "Tags should match")
assert.Equal(t, "epub", result.FormatGroup, "FormatGroup should match")
}
@@ -64,7 +64,7 @@ func TestContinueSeriesRowToMediaItems_NullFieldsHandled(t *testing.T) {
MimeType: pgtype.Text{Valid: false},
CoverImagePath: pgtype.Text{Valid: false},
Series: pgtype.Text{Valid: false},
SeriesNumber: pgtype.Int4{Valid: false},
SeriesNumber: pgtype.Float8{Valid: false},
Tags: nil,
FormatGroup: "epub",
}
@@ -94,7 +94,7 @@ func TestContinueSeriesRowToMediaItems_AllFieldsMapped(t *testing.T) {
MimeType: pgtype.Text{String: "epub", Valid: true},
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
Series: pgtype.Text{String: "Series", Valid: true},
SeriesNumber: pgtype.Int4{Int32: 1, Valid: true},
SeriesNumber: pgtype.Float8{Float64: 1, Valid: true},
Tags: []string{"tag1"},
Asin: pgtype.Text{String: "ASIN", Valid: true},
Publisher: pgtype.Text{String: "Pub", Valid: true},
+35 -1
View File
@@ -72,6 +72,7 @@ func (w *Worker) recordPending(job *Job) {
JobID: job.ID,
Status: JobStatusPending,
LibraryID: libID,
JobType: job.Type,
}
}
}
@@ -104,7 +105,11 @@ type JobResult struct {
// LibraryID carries the scan job's library (from Params) so clients
// can label progress rows after a page refresh, when the original
// start-request context is gone.
LibraryID string `json:"library_id,omitempty"`
LibraryID string `json:"library_id,omitempty"`
// JobType distinguishes scan jobs in the active set — the enqueue
// coalescing check (HasActiveScanForLibrary) must only coalesce
// scans, never imports/converts sharing a library id.
JobType JobType `json:"job_type,omitempty"`
Error string `json:"error"`
Result interface{} `json:"result"`
Progress float64 `json:"progress"`
@@ -286,6 +291,7 @@ func (w *Worker) processJob(job *Job) {
JobID: job.ID,
Status: JobStatusRunning,
LibraryID: libID,
JobType: job.Type,
}
// Derive a per-job context so cancelling this job (CancelJob) stops
@@ -486,6 +492,12 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
scanner.SetAdminID(adminUUID)
scanner.SetForce(force)
// Trigger label (plan Part 2 §4): poll / watcher / admin / initial —
// surfaces in the per-scan summary line.
if trigger, ok := job.Params["trigger"].(string); ok {
scanner.SetTrigger(trigger)
}
var libraryUUID pgtype.UUID
if err := libraryUUID.Scan(libraryID); err != nil {
return nil, err
@@ -1073,6 +1085,28 @@ func (w *Worker) GetActiveScanJobs() []JobResult {
return active
}
// HasActiveScanForLibrary reports whether a pending or running SCAN job
// already targets the library (faceted plan Part 2 §5): coalescing
// stops poll ticks from stacking full scans when a scan outlives its
// interval, and enforces the one-scan-per-library invariant the bulk
// scan-state map leans on.
func (w *Worker) HasActiveScanForLibrary(libraryID string) bool {
if w == nil || libraryID == "" {
return false
}
w.mu.RLock()
defer w.mu.RUnlock()
for _, result := range w.results {
if result.JobType != JobTypeScan || result.LibraryID != libraryID {
continue
}
if result.Status == JobStatusRunning || result.Status == JobStatusPending {
return true
}
}
return false
}
// CancelJob cancels a pending or running job by id. Pending jobs are
// marked cancelled so processJob skips them at dequeue (no context
// exists yet); running jobs have their per-job context cancelled — the
+43
View File
@@ -58,3 +58,46 @@ func TestGetActiveScanJobsEmpty(t *testing.T) {
t.Fatalf("expected no active jobs, got %d", len(jobs))
}
}
// HasActiveScanForLibrary is the enqueue-coalescing predicate (plan
// Part 2 §5): a pending/running SCAN for the same library makes the
// next poll tick / watcher event a no-op. Only scans coalesce — an
// import or convert sharing the library id must not block one.
func TestHasActiveScanForLibrary(t *testing.T) {
w := NewWorker(1, nil)
seed := map[string]*JobResult{
"running-scan": {JobID: "running-scan", Status: JobStatusRunning, LibraryID: "lib-1", JobType: JobTypeScan},
"pending-scan": {JobID: "pending-scan", Status: JobStatusPending, LibraryID: "lib-2", JobType: JobTypeScan},
"done-scan": {JobID: "done-scan", Status: JobStatusCompleted, LibraryID: "lib-3", JobType: JobTypeScan},
"failed-scan": {JobID: "failed-scan", Status: JobStatusFailed, LibraryID: "lib-4", JobType: JobTypeScan},
"import-lib1": {JobID: "import-lib1", Status: JobStatusRunning, LibraryID: "lib-1", JobType: JobTypeImport},
"no-library": {JobID: "no-library", Status: JobStatusRunning, JobType: JobTypeScan},
}
w.mu.Lock()
w.results = seed
w.mu.Unlock()
cases := []struct {
library string
want bool
}{
{"lib-1", true}, // running scan
{"lib-2", true}, // pending scan
{"lib-3", false}, // completed = terminal
{"lib-4", false}, // failed = terminal
{"lib-9", false}, // unknown library
{"", false}, // jobs without a library never coalesce
}
for _, c := range cases {
if got := w.HasActiveScanForLibrary(c.library); got != c.want {
t.Errorf("HasActiveScanForLibrary(%q) = %v, want %v", c.library, got, c.want)
}
}
// A nil worker (tests, early startup) must not panic.
var nilWorker *Worker
if nilWorker.HasActiveScanForLibrary("lib-1") {
t.Error("nil worker must report no active scans")
}
}
+47
View File
@@ -0,0 +1,47 @@
package utils
// Genre display rendering (faceted plan Batch B / Phase 7): the
// canonically-lowercase genre value is title-cased at DISPLAY time
// only — never in the DB. Canonical home: the filter panel (services),
// templ (templates wraps this), and the TS port in bookshelf.ts all
// share these exact rules.
import (
"strings"
"unicode"
)
// genreSmallWords stay lowercase in title-case rendering (except the
// first word of the value).
var genreSmallWords = map[string]bool{
"a": true, "an": true, "and": true, "of": true, "the": true,
"in": true, "on": true, "for": true, "to": true, "or": true,
}
// PrettifyGenre renders the canonically-lowercase genre value in title
// case at display time (Batch B transform — never in the DB). Only
// values that are ENTIRELY lowercase get transformed; anything else is
// returned verbatim, protecting user-entered mixed case. Small words
// stay down except in the first position; "&" has no case and passes
// through. "science fiction" → "Science Fiction", "sword & sorcery" →
// "Sword & Sorcery".
func PrettifyGenre(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return ""
}
if trimmed != strings.ToLower(trimmed) {
return trimmed // mixed case = user-authored; display as written
}
words := strings.Fields(trimmed)
out := make([]string, len(words))
for i, w := range words {
if i > 0 && genreSmallWords[w] {
out[i] = w
continue
}
runes := []rune(w)
out[i] = string(append([]rune{unicode.ToUpper(runes[0])}, runes[1:]...))
}
return strings.Join(out, " ")
}
+16 -7
View File
@@ -18,7 +18,7 @@ import (
const (
OverrideTitle = "title"
OverrideAuthor = "author"
OverrideAuthor = "author" // protects the authors[] array (legacy key preserved)
OverrideISBN = "isbn"
OverrideDescription = "description"
OverrideCoverImagePath = "cover_image_path"
@@ -29,7 +29,9 @@ const (
OverrideTags = "tags"
OverrideAsin = "asin"
OverrideDatePublished = "date_published"
OverridePublisher = "publisher"
OverrideSubtitle = "subtitle"
OverrideExplicit = "explicit"
OverridePublisher = "publisher" // protects the publishers[] array (legacy key preserved)
OverrideContributors = "contributors"
OverrideLanguage = "language"
OverrideEdition = "edition"
@@ -150,17 +152,19 @@ func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing
}
add(OverrideTitle, params.Title != existing.Title)
add(OverrideAuthor, normText(params.Author) != normText(existing.Author))
add(OverrideSubtitle, normText(params.Subtitle) != normText(existing.Subtitle))
add(OverrideAuthor, normStringSlice(params.Authors) != normStringSlice(existing.Authors))
add(OverrideISBN, normText(params.Isbn) != normText(existing.Isbn))
add(OverrideDescription, normText(params.Description) != normText(existing.Description))
add(OverrideSeries, normText(params.Series) != normText(existing.Series))
add(OverrideSeriesNumber, normInt(params.SeriesNumber) != normInt(existing.SeriesNumber))
add(OverrideSeriesNumber, normFloat(params.SeriesNumber) != normFloat(existing.SeriesNumber))
add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
add(OverrideNarrators, normStringSlice(params.Narrators) != normStringSlice(existing.Narrators))
add(OverrideAbridged, params.Abridged != existing.Abridged)
add(OverrideExplicit, params.Explicit != existing.Explicit)
add(OverrideAsin, normText(params.Asin) != normText(existing.Asin))
add(OverrideDatePublished, normDate(params.DatePublished) != normDate(existing.DatePublished))
add(OverridePublisher, normText(params.Publisher) != normText(existing.Publisher))
add(OverridePublisher, normStringSlice(params.Publishers) != normStringSlice(existing.Publishers))
add(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors))
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
@@ -205,8 +209,12 @@ func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing dat
if hasOverride(o, OverrideTitle) {
params.Title = existing.Title
}
if hasOverride(o, OverrideSubtitle) {
params.Subtitle = existing.Subtitle
}
if hasOverride(o, OverrideAuthor) {
params.Author = existing.Author
params.Authors = existing.Authors
params.AuthorsSearch = existing.AuthorsSearch
}
if hasOverride(o, OverrideISBN) {
params.Isbn = existing.Isbn
@@ -241,7 +249,8 @@ func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing dat
params.DatePublished = existing.DatePublished
}
if hasOverride(o, OverridePublisher) {
params.Publisher = existing.Publisher
params.Publishers = existing.Publishers
params.PublishersSearch = existing.PublishersSearch
}
if hasOverride(o, OverrideContributors) {
params.Contributors = existing.Contributors
+2 -2
View File
@@ -63,12 +63,12 @@ func TestDetectMetadataOverridesUnsetFormsEqual(t *testing.T) {
// Zero-value params must not look "changed" against NULL-ish columns.
existing := database.MediaItems{
PageCount: pgtype.Int4{Int32: 0, Valid: false},
SeriesNumber: pgtype.Int4{Int32: 5, Valid: true},
SeriesNumber: pgtype.Float8{Float64: 5, Valid: true},
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
}
params := database.UpdateMediaItemParams{
PageCount: pgtype.Int4{Int32: 0, Valid: false},
SeriesNumber: pgtype.Int4{Int32: 0, Valid: false}, // cleared by user -> changed
SeriesNumber: pgtype.Float8{Float64: 0, Valid: false}, // cleared by user -> changed
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
}
got := DetectMetadataOverrides(params, existing)
+15
View File
@@ -183,6 +183,21 @@ func NormalizeNarratorsSearch(narrators []string) []string {
return NormalizeTagsSearch(narrators)
}
// SingleOrSlice returns the write-truth array for a field that has both a
// legacy single-value form and an array form: the explicit array when
// populated, else the single string as a one-element slice. Used by the
// authors/publishers arrays (`author`/`publisher` varchars regenerate from
// them via the sync_author_publisher_display trigger).
func SingleOrSlice(single string, arr []string) []string {
if len(arr) > 0 {
return arr
}
if single == "" {
return []string{}
}
return []string{single}
}
// NormalizeContributors normalizes an array of contributors for display:
// 1. Trim whitespace
// 2. Preserve original casing (including CAPSLOCK companies)
+12 -5
View File
@@ -5,11 +5,12 @@
"scripts": {
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build",
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --mode development",
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --watch",
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build",
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --mode development",
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --watch",
"build": "npm run build:ts && npm run build:css:prod",
"dev": "npm run build:ts:dev && npm run build:css"
"dev": "npm run build:ts:dev && npm run build:css",
"test": "vp test web/src"
},
"dependencies": {
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#f622841",
@@ -31,8 +32,14 @@
"@types/markdown-it": "^14.2.0",
"@types/turndown": "^5.0.6",
"autoprefixer": "^10.4.27",
"jsdom": "^30.1.3",
"playwright": "^1.64.1",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^7.3.1"
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
"vite-plus": "1.3.0"
},
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0"
}
}
+96
View File
@@ -0,0 +1,96 @@
// Filter-builder UI regression harness (faceted plan Phase 9 follow-up).
//
// Drives the real bookshelf in headless Chromium: opens the filter
// drawer, types an author value, and sweeps every author operator,
// asserting the live count fetch behaves (200s, correct numbers, no
// console errors). Born from the mis-keyed operator table that made
// every non-self-named operator (doesn't contain, is empty, at least…)
// a silent 400 — the class of bug only a real browser catches.
//
// Prerequisites: dev stack up (make dev / docker compose), playwright
// installed (cd web && npm i -D playwright && npx playwright install
// chromium — the repo root's devDependency already carries it), and a
// reachable server.
//
// Usage: NODE_PATH=<repo>/node_modules node scripts/filter_builder_harness.js
// Env: HARNESS_BASE (default http://localhost:8765), HARNESS_LOGIN,
// HARNESS_PASSWORD.
const { chromium } = require("playwright");
const BASE = process.env.HARNESS_BASE || "http://localhost:8765";
const LOGIN = process.env.HARNESS_LOGIN || "testuser@tests.bookhoard.internal";
const PASSWORD = process.env.HARNESS_PASSWORD || "Test@Pass123!";
const AUTHOR = "Andrzej Sapkowski";
const OPS_SWEEP = ["doesn't contain", "is", "isn't", "is empty", "contains"];
(async () => {
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const events = [];
page.on("console", (m) => {
if (m.type() === "error" || m.type() === "warning") {
events.push(`[console.${m.type()}] ${m.text()}`);
}
});
page.on("pageerror", (e) => events.push(`[pageerror] ${e.message}`));
page.on("response", async (r) => {
if (r.url().includes("filter-count")) {
let body = "";
try { body = (await r.text()).slice(0, 120); } catch {}
events.push(`[net] filter-count ${r.status()} ${body}`);
}
});
const login = await context.request.post(`${BASE}/api/auth/login`, {
data: { login: LOGIN, password: PASSWORD },
});
const loginBody = await login.json();
await context.addInitScript((t) => localStorage.setItem("token", t), loginBody.access_token);
console.log("login:", login.status());
await page.goto(`${BASE}/bookshelf`);
await page.waitForLoadState("networkidle");
await page.evaluate(() => {
const el = document.getElementById("library-select");
const empty = [...el.options].find((o) => o.value === "");
if (empty) {
el.value = "";
el.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await page.waitForTimeout(800);
await page.click('button:has-text("Filters")');
await page.waitForTimeout(300);
const valueInput = page.locator(".fb-value").first();
await valueInput.click();
await valueInput.fill(AUTHOR);
await page.waitForTimeout(1200);
const countEl = page.locator('span[x-show*="matchCount"]').first();
console.log("contains:", JSON.stringify(await countEl.textContent()));
for (const label of OPS_SWEEP) {
await page.locator(".fb-picker-op").first().click();
await page.waitForTimeout(150);
await page
.locator(".fb-menu-item:visible")
.filter({ hasText: new RegExp(`^${label.replace(/[\W_]+/g, "\\$&")}$`) })
.first()
.click();
await page.waitForTimeout(1300);
console.log(`after "${label}":`, JSON.stringify(await countEl.textContent()));
}
// contains-with-empty-value is incomplete (counter hidden) — refill
// and confirm the count returns.
await valueInput.fill(AUTHOR);
await page.waitForTimeout(1300);
console.log("refilled contains:", JSON.stringify(await countEl.textContent()));
console.log("--- warnings/errors + count calls ---");
for (const l of events) console.log(l);
await browser.close();
})().catch((e) => { console.error("HARNESS ERROR:", e.message); process.exit(1); });
+12 -12
View File
@@ -45,7 +45,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -79,7 +79,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 31, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 31, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -135,7 +135,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 66, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 66, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -153,7 +153,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 68, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 68, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -171,7 +171,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 70, Col: 103}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 70, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -184,7 +184,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 72, Col: 86}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 72, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -197,7 +197,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 73, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 73, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -215,7 +215,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 78, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 78, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -228,7 +228,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MatchID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 79, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 79, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -241,7 +241,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 81, Col: 174}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 81, Col: 174}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
@@ -267,7 +267,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 89, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 89, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -288,7 +288,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 98, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 98, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
+16 -16
View File
@@ -59,7 +59,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("conflict-" + conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 27, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 27, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -80,7 +80,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d copies", len(conflict.Items)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 33, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 33, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -93,7 +93,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.LibraryName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 36, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 36, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -106,7 +106,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.SHAShort)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 38, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 38, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -119,7 +119,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflict.ID + "/resolve")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 46, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 46, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -132,7 +132,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 48, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 48, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -194,7 +194,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 71, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 71, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -212,7 +212,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 73, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 73, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -230,7 +230,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 75, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 75, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -243,7 +243,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 77, Col: 64}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 77, Col: 64}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -261,7 +261,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 79, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 79, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -279,7 +279,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 81, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 81, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -297,7 +297,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflictID + "/resolve")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 87, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 87, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -310,7 +310,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 88, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 88, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -323,7 +323,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflictID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 89, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 89, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -383,7 +383,7 @@ func AdminHashConflicts(user User, conflicts []HashConflictData) templ.Component
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
+38 -38
View File
@@ -47,7 +47,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -89,7 +89,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(archivedCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 45, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 45, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -220,7 +220,7 @@ func LibraryTypeOptions(types []database.LibraryTypes) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 186, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 186, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -233,7 +233,7 @@ func LibraryTypeOptions(types []database.LibraryTypes) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(LibraryTypeDisplayName(t.Name))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 186, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 186, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -303,7 +303,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 210, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 210, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -321,7 +321,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 212, Col: 95}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 212, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -343,7 +343,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 217, Col: 26}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 217, Col: 26}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -365,7 +365,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 222, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 222, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -383,7 +383,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 228, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 228, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -404,7 +404,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 238, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 238, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -425,7 +425,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 247, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 247, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -500,7 +500,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 269, Col: 99}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 269, Col: 99}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -513,7 +513,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 272, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 272, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
@@ -526,7 +526,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 273, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 273, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
@@ -539,7 +539,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 274, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 274, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
@@ -570,7 +570,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 284, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -583,7 +583,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 284, Col: 124}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 124}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
@@ -596,7 +596,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 288, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 288, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
@@ -609,7 +609,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 295, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil {
@@ -622,7 +622,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 296, Col: 52}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 296, Col: 52}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
@@ -679,7 +679,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 325, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 325, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
@@ -692,7 +692,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 326, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 326, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
@@ -705,7 +705,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 328, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 328, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
@@ -718,7 +718,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 331, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 331, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
@@ -731,7 +731,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 332, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 332, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
if templ_7745c5c3_Err != nil {
@@ -759,7 +759,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var31 templ.SafeURL
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 341, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 341, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -786,7 +786,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 346, Col: 24}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 346, Col: 24}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
if templ_7745c5c3_Err != nil {
@@ -813,7 +813,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 355, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 355, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
@@ -826,7 +826,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 356, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 356, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil {
@@ -839,7 +839,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 357, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 357, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil {
@@ -860,7 +860,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 365, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 365, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
if templ_7745c5c3_Err != nil {
@@ -873,7 +873,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 366, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 366, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
if templ_7745c5c3_Err != nil {
@@ -931,7 +931,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 381, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 381, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
if templ_7745c5c3_Err != nil {
@@ -949,7 +949,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 387, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 387, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
if templ_7745c5c3_Err != nil {
@@ -980,7 +980,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 400, Col: 118}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 400, Col: 118}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
if templ_7745c5c3_Err != nil {
@@ -1001,7 +1001,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 405, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 405, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
if templ_7745c5c3_Err != nil {
@@ -1019,7 +1019,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 412, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 412, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
if templ_7745c5c3_Err != nil {
@@ -1032,7 +1032,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 413, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 413, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
if templ_7745c5c3_Err != nil {
+16 -16
View File
@@ -45,7 +45,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -92,7 +92,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 37, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 37, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -119,7 +119,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 46, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 46, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -146,7 +146,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 55, Col: 91}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 55, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -188,7 +188,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 71, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 71, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -201,7 +201,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 74, Col: 98}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -214,7 +214,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 75, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 75, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -227,7 +227,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 77, Col: 140}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 140}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -240,7 +240,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 78, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -253,7 +253,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 79, Col: 139}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 139}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -266,7 +266,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 80, Col: 149}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 80, Col: 149}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -284,7 +284,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 85, Col: 62}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 85, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -302,7 +302,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 87, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 87, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -320,7 +320,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 89, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -343,7 +343,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 96, Col: 113}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -356,7 +356,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 97, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 97, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
+19 -19
View File
@@ -50,7 +50,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -92,7 +92,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 35, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 35, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -118,7 +118,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 49, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 49, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -395,7 +395,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 110, Col: 145}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 110, Col: 145}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -408,7 +408,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 111, Col: 144}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 144}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -421,7 +421,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 112, Col: 152}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 112, Col: 152}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -501,7 +501,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 159, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 159, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -593,7 +593,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 199, Col: 112}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 199, Col: 112}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -651,7 +651,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 214, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 214, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -669,7 +669,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 216, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 216, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -687,7 +687,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 218, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 218, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -705,7 +705,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 224, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 224, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -718,7 +718,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 228, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 228, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil {
@@ -761,7 +761,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 238, Col: 20}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 238, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
@@ -779,7 +779,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 240, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 240, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
@@ -798,7 +798,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 243, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 243, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -821,7 +821,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 251, Col: 20}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 251, Col: 20}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
@@ -847,7 +847,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 260, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 260, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
@@ -900,7 +900,7 @@ func AboutSection() templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 274, Col: 134}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 274, Col: 134}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil {
+5 -5
View File
@@ -45,7 +45,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -82,7 +82,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 33, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 33, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -103,7 +103,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 40, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 40, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -124,7 +124,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 47, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 47, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -145,7 +145,7 @@ func Admin(user User, stats AdminStats) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 54, Col: 91}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 54, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
+11 -11
View File
@@ -86,7 +86,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -128,7 +128,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 102, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 102, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -149,7 +149,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 111, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 111, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -172,7 +172,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 116, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 116, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -185,7 +185,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 121, Col: 73}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 121, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -208,7 +208,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 138, Col: 50}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 138, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
@@ -221,7 +221,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 139, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 139, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -259,7 +259,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 150, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 150, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -272,7 +272,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 154, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 154, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
@@ -311,7 +311,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 173, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 173, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -324,7 +324,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 174, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 174, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
+16 -16
View File
@@ -42,7 +42,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 8, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -60,7 +60,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 10, Col: 65}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
@@ -78,7 +78,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 12, Col: 120}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -96,7 +96,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 14, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 14, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -114,7 +114,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 16, Col: 88}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 16, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -127,7 +127,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 24, Col: 131}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 24, Col: 131}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -140,7 +140,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 28, Col: 128}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 28, Col: 128}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -153,7 +153,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(" + JSONArg(explorer.Endpoint.RequestBody) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 31, Col: 86}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 31, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
@@ -174,7 +174,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(" + JSONArg(explorer.Endpoint.Response) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 35, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 35, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -200,7 +200,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("initAPIExplorerDoc(" + JSONArg(explorer.Endpoint.Path) + ", " + JSONArg(explorer.Endpoint.RequestBody) + ", " + JSONArg(explorer.Endpoint.Response) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 42, Col: 226}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 42, Col: 226}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -218,7 +218,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 45, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 45, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -236,7 +236,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 47, Col: 65}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 47, Col: 65}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -254,7 +254,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 49, Col: 120}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 120}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -272,7 +272,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 51, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 51, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -290,7 +290,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 53, Col: 88}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 53, Col: 88}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -343,7 +343,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 70, Col: 136}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 70, Col: 136}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
+306 -19
View File
@@ -10,14 +10,26 @@ package templates
import "fmt"
templ AudioReader(user User, metadata ReaderMetadata) {
<!DOCTYPE html/>
<!DOCTYPE html>
<html lang="en">
<head>
<title>{ "Listening · " + metadata.Title } · Bookhoard</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<script type="module" src="/static/main.js"></script>
<style>
/* Layered scrubber (plan §1): the native range stays for
a11y (focus, arrow keys, aria) but its track is
transparent so the tick/buffer/fill layers beneath show
through. Static page CSS — templ owns the look. */
#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }
#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }
#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
#audio-scrub::-moz-range-track { background: transparent; border: none; }
#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
</style>
</head>
<body
x-data="audioReader"
@@ -32,6 +44,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress") }
>
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
black across a countdown's final 30s; pointer-events-none
keeps the page usable, stop/finish resets it. -->
<div class="fixed inset-0 pointer-events-none z-40"
:style="'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'"
></div>
<div class="h-full flex flex-col items-center justify-center px-4 gap-6 select-none">
<!-- Header: back to the detail page (reader-style exit) and the
chapter launcher. Floating drawer, never in-flow (A2b). -->
@@ -75,6 +93,86 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<path d="m6 9 6 6 6-6"></path>
</svg>
</button>
<button
type="button"
@click="helpOpen = !helpOpen"
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
aria-label="Shortcuts &amp; interactions"
title="Shortcuts &amp; interactions (?)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8"></path>
</svg>
</button>
<div class="relative" @click.outside="settingsOpen = false">
<button
type="button"
@click="settingsOpen = !settingsOpen"
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
:class="settingsOpen ? 'opacity-100' : 'opacity-90'"
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
aria-label="Player settings"
title="Settings"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</button>
<div
x-show="settingsOpen"
x-cloak
x-transition
class="absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50"
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
>
<div class="flex items-center justify-between mb-2 px-1">
<span class="text-sm font-semibold" style="color: var(--text-primary);">Player settings</span>
<button type="button" @click="settingsOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
<!-- Option rows: autoplay first, panel structured for more -->
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);">Autoplay on open</span>
<button
type="button"
role="switch"
:aria-checked="autoplayOn ? 'true' : 'false'"
@click="toggleAutoplay()"
class="relative h-5 w-9 rounded-full transition-colors shrink-0"
:style="autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'"
aria-label="Autoplay on open"
>
<span class="absolute top-0.5 left-0.5 h-4 w-4 rounded-full transition-transform" :class="autoplayOn ? 'translate-x-4' : ''" style="background-color: var(--text-primary);"></span>
</button>
</div>
<!-- §12: default speed — always visible (unlike the
Speed ▾ footer, which hides when the current rate
IS the default), same setDefaultSpeed PUT -->
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);">Default playback speed</span>
<select
class="text-xs rounded-md px-1.5 py-1"
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
aria-label="Default playback speed"
@change="setDefaultSpeed(Number($event.target.value))"
>
for _, rate := range SpeedOptions {
<option
value={ fmt.Sprintf("%g", rate) }
:selected={ fmt.Sprintf("Number(defaultSpeed) === %g", rate) }
>{ fmt.Sprintf("%g×", rate) }</option>
}
</select>
</div>
</div>
</div>
</span>
</div>
@@ -101,21 +199,96 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<!-- Scrubber + transports -->
<div class="w-full max-w-xl flex flex-col gap-2">
<div class="flex items-center gap-2 text-xs tabular-nums" style="color: var(--text-secondary);">
<span x-text="fmtTime(currentTime)">0:00</span>
<input
id="audio-scrub"
type="range"
min="0"
step="0.1"
:max="chaptersDuration() || 1"
:value="scrubbing ? scrubValue : currentTime"
class="flex-1 accent-current"
@input="scrubbing = true; scrubValue = Number($event.target.value)"
@pointerup="seekTo(scrubValue); scrubbing = false"
@change="seekTo(scrubValue); scrubbing = false"
style="z-index: 20;"
/>
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
<span class="inline-block min-w-[4.75rem]" x-text="fmtTime(scrubbing ? scrubValue : currentTime)">0:00</span>
<div
id="scrub-area"
class="relative flex-1 h-6 flex items-center cursor-pointer"
style="touch-action: none;"
@pointerdown="scrubPointerDown($event)"
@pointermove="scrubPointerMove($event)"
@pointerup="scrubPointerUp($event)"
@pointercancel="scrubPointerUp($event)"
@pointerleave="scrubPointerLeave()"
@wheel.prevent="onScrubWheel($event)"
>
<!-- Base track -->
<div class="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" style="background-color: var(--surface-hover);"></div>
<!-- Elapsed fill -->
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'"></div>
<!-- Buffered span: the ACTIVE track's element buffer only -->
<template x-if="bufferSpan()">
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'"></div>
</template>
<!-- Chapter ticks: density-decimated, current chapter tinted -->
<div id="scrub-ticks" class="absolute inset-0 pointer-events-none">
<template x-for="idx in tickIndices" :key="'tick' + idx">
<div
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full"
:class="currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'"
:style="'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'"
></div>
</template>
</div>
<!-- §9 magnet: the line a click would land on (hover
inside the pixel radius locks the tooltip here) -->
<div
x-show="hoverSnapped && !scrubbing"
x-cloak
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none"
:style="'left:' + hoverPct + '%;background-color:var(--accent);'"
></div>
<input
id="audio-scrub"
type="range"
min="0"
step="0.1"
:max="chaptersDuration() || 1"
:value="scrubbing ? scrubValue : currentTime"
class="relative w-full"
@input="scrubbing = true; scrubValue = Number($event.target.value)"
@change="seekTo(scrubValue); scrubbing = false"
aria-label="Seek position"
/>
<!-- Hover tooltip: time + chapter under the pointer -->
<div
x-show="hoverSeconds !== null && !scrubbing"
x-cloak
class="absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none"
:style="'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'"
>
<span x-text="hoverSeconds !== null ? fmtTime(hoverSeconds) : ''"></span>
<span style="color: var(--text-secondary);" x-text="hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''"></span>
</div>
<!-- Precision rail (§9): Shift+drag on mouse, or a touch
drag pulled off the bar — ±5 min or tighter, free
mapping inside. Edge labels self-document the window
(floats over the transports, page never shifts) -->
<div
x-show="railVisible"
x-cloak
class="absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none"
:style="'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')"
>
<div class="relative h-full">
<div class="absolute inset-x-0 top-1/2 h-px" style="background-color: var(--border);"></div>
<template x-for="tick in railTickList" :key="'rtick' + tick.start">
<div class="absolute top-1 bottom-1 w-px" :style="'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'"></div>
</template>
<div class="absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full" :style="'left:' + railMarkerPct() + '%;background-color:var(--accent);'"></div>
<div class="absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap" style="color: var(--text-primary);" x-text="fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)"></div>
<div class="absolute left-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railFrom)"></div>
<div class="absolute right-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railTo)"></div>
</div>
</div>
</div>
<button
type="button"
class="inline-block min-w-[4.75rem] text-right cursor-pointer"
x-text="endTimeText()"
:aria-label="endTimeAria()"
:title="endTimeAria()"
@click="cycleEndTime()"
>0:00</button>
</div>
<div class="flex items-center justify-center gap-3 md:gap-5">
<button
@@ -219,7 +392,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs">
<template x-for="secs in skipChoices" :key="'skip' + secs">
<button
type="button"
@click="setSkip(secs); skipOpen = false"
@@ -249,16 +422,26 @@ templ AudioReader(user User, metadata ReaderMetadata) {
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="'speed' + rate">
<template x-for="rate in speeds" :key="'speed' + rate">
<button
type="button"
@click="setSpeed(rate); speedOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="Number(speed) === rate ? 'font-semibold' : ''"
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="rate + '×'"
x-text="rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')"
></button>
</template>
<!-- §8: pin the current rate as the user default —
books without their own speed pick it up -->
<button
type="button"
x-show="!isDefaultSpeed(speed)"
@click="setDefaultSpeed(speed); speedOpen = false"
class="w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t"
style="border-color: var(--border); color: var(--text-secondary);"
x-text="'Make ' + speed + '× my default'"
></button>
</div>
</div>
<button
@@ -275,6 +458,55 @@ templ AudioReader(user User, metadata ReaderMetadata) {
</svg>
<span>Bookmark</span>
</button>
<div class="relative" @click.outside="sleepOpen = false">
<button
type="button"
@click="sleepOpen = !sleepOpen"
class="text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
:aria-label="'Sleep timer, currently ' + sleepLabelStr"
style="color: var(--text-secondary);"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
</svg>
<span class="font-semibold tabular-nums" style="color: var(--text-primary);" x-text="sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr">Sleep</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>
</button>
<div
x-show="sleepOpen"
x-cloak
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[8.5rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="opt in sleepChoices" :key="opt.key">
<button
type="button"
@click="chooseSleep(opt); sleepOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="isSleepActive(opt.key) ? 'font-semibold' : ''"
:style="isSleepActive(opt.key) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="opt.label"
></button>
</template>
<!-- §17 clock mode: stop at a wall-clock time (its next
occurrence). The input drives chooseSleepClock(). -->
<div class="flex items-center gap-1.5 px-2 pt-1.5 mt-1 border-t" style="border-color: var(--border);">
<input
type="time"
x-model="sleepClockTime"
class="text-xs px-1.5 py-1 rounded-md flex-1 min-w-0"
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
aria-label="Stop at time"
/>
<button
type="button"
@click="chooseSleepClock(); sleepOpen = false"
class="text-xs px-2.5 py-1 rounded-md font-semibold"
:style="sleepArmedChoiceKey === 'clock' ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'background-color: var(--surface-hover); color: var(--text-secondary);'"
>Arm</button>
</div>
</div>
</div>
<div class="relative" @click.outside="volumeOpen = false">
<button
type="button"
@@ -453,10 +685,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
>
<p class="text-sm font-semibold mb-2" style="color: var(--text-primary);">Bookmark at <span class="tabular-nums" x-text="fmtTime(currentTime)">0:00</span></p>
<textarea
id="bookmark-note"
x-model="bookmarkNote"
rows="3"
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
class="input mb-2 resize-y"
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); saveBookmark() }"
></textarea>
<div class="flex justify-end gap-2">
<button type="button" @click="bookmarkCaptureOpen = false" class="btn btn-ghost text-sm px-3 py-1.5">Cancel</button>
@@ -464,6 +698,59 @@ templ AudioReader(user User, metadata ReaderMetadata) {
</div>
</div>
<!-- Volume/mute OSD: brief confirmation for keyboard-driven changes -->
<div
x-show="osdVisible"
x-cloak
x-transition.opacity.duration.200ms
class="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 text-xs px-3 py-1.5 rounded-full shadow-lg tabular-nums"
style="background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);"
x-text="osdText"
></div>
<!-- Shortcut help (?): rows come from the shared KEYMAP
(audio-keys.ts) — the same table drives the keydown handler. -->
<div x-show="helpOpen" x-cloak class="fixed inset-0 z-40" style="background-color: color-mix(in srgb, black 32%, transparent);" @click="helpOpen = false"></div>
<div x-show="helpOpen" x-cloak x-transition class="fixed inset-0 z-50 grid place-items-center p-4" @click.outside="helpOpen = false">
<div class="w-full max-w-md rounded-2xl shadow-2xl p-4 max-h-[80vh] overflow-y-auto" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
<div class="flex items-center justify-between mb-3 px-1">
<span class="text-sm font-semibold" style="color: var(--text-primary);">Shortcuts &amp; interactions</span>
<button type="button" @click="helpOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close shortcuts">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
<template x-for="spec in visibleKeymap()" :key="spec.action">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="spec.description"></span>
<span class="flex items-center gap-1 shrink-0">
<template x-for="k in spec.keys" :key="k.label">
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="k.label"></kbd>
</template>
</span>
</div>
</template>
<!-- Pointer gestures (audio-keys.ts MOUSE_GUIDE/TOUCH_GUIDE):
two sections — the bar has two scrub systems -->
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Mouse</div>
<template x-for="row in mouseGuide" :key="'m' + row.label">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="row.description"></span>
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
</div>
</template>
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Touch</div>
<template x-for="row in touchGuide" :key="'t' + row.label">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="row.description"></span>
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
</div>
</template>
</div>
</div>
<audio id="audio-element" preload="auto" class="hidden"></audio>
</div>
</body>
+74 -25
View File
@@ -38,20 +38,20 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html/><html lang=\"en\"><head><title>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 16, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 16, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"/static/main.js\"></script></head>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><script type=\"module\" src=\"/static/main.js\"></script><style>\n\t\t\t\t/* Layered scrubber (plan §1): the native range stays for\n\t\t\t\t a11y (focus, arrow keys, aria) but its track is\n\t\t\t\t transparent so the tick/buffer/fill layers beneath show\n\t\t\t\t through. Static page CSS — templ owns the look. */\n\t\t\t\t#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }\n\t\t\t\t#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t\t#audio-scrub::-moz-range-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t</style></head>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -67,7 +67,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -80,7 +80,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 27, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 39, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -93,7 +93,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 28, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 40, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -106,7 +106,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 29, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -119,7 +119,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 30, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 42, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -134,67 +134,116 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
"/api/media-items/"+metadata.MediaItemID+"/chapters",
"/api/media-items/"+metadata.MediaItemID+"/progress"))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 33, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 45, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts &amp; interactions\" title=\"Shortcuts &amp; interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<img src=\"")
for _, rate := range SpeedOptions {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 86, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 168, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" alt=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" :selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("Number(defaultSpeed) === %g", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 87, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if metadata.CoverImagePath != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 184, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" alt=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 96, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 194, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></Line truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button typeLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+81 -13
View File
@@ -5,10 +5,32 @@ import (
"fmt"
"net/url"
// NOTE: do not import "github.com/a-h/templ" here — templ's generator
// already emits that import into the generated file, and a second copy
// is a compile error (redeclared block).
"github.com/microcosm-cc/bluemonday"
"strings"
)
// cssURL neutralizes characters that would break an UNQUOTED CSS url()
// token (whitespace, quotes, parens, backslash). The stored cover paths
// are already percent-encoded URL paths, so this is defensive only — but
// it must exist: templ's codegen HTML-escapes style attribute values a
// second time, so ANY quote character (SafeCSS included) reaches the DOM
// as literal &#39; text and the browser never parses the url() at all
// (2026-10-21: every hero backdrop broken + /media/& 500 spam, because
// Firefox resolved the mangled token relative to the page URL).
func cssURL(s string) string {
return strings.NewReplacer(
" ", "%20",
"(", "%28",
")", "%29",
"'", "%27",
"\"", "%22",
"\\", "%5C",
).Replace(s)
}
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) {
<!DOCTYPE html>
<html lang="en">
@@ -30,10 +52,27 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
>
@Header(user, "/media/{ uuidToString(book.ID) }")
if book.ActiveConflict != nil {
<!-- Sync-conflict banner (user request): the progress
card's warning sits far down a long page — this
strip is unmissable at the very top, and clicking
it opens the same resolution modal. -->
<button
type="button"
@click="showProgressSyncModal()"
class="w-full flex items-center gap-3 px-4 sm:px-6 lg:px-8 py-3 text-left"
style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border-bottom: 1px solid var(--status-warning);"
>
@Icon("alert", "h-5 w-5 shrink-0")
<span class="text-sm" style="color: var(--text-primary);">
Progress sync conflict detected on this item — click to review and resolve.
</span>
</button>
}
<!-- Hero with blurred cover backdrop -->
<div class="relative overflow-hidden">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<div class="hero-backdrop" style={ "background-image: url('" + book.CoverImagePath.String + "')" }></div>
<div class="hero-backdrop" style={ templ.SafeCSS("background-image: url(" + cssURL(book.CoverImagePath.String) + ")") }></div>
}
<div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8">
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);">
@@ -63,14 +102,33 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
<!-- Details -->
<div class="flex-1 min-w-0">
<h1 class="text-3xl md:text-4xl font-bold tracking-tight mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
if book.Author.Valid && book.Author.String != "" {
if book.Subtitle.Valid && book.Subtitle.String != "" {
<p class="text-lg mb-2" style="color: var(--text-secondary)">{ book.Subtitle.String }</p>
}
if len(book.Authors) > 0 {
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
by
for i, authorName := range book.Authors {
if i > 0 {
<span>, </span>
}
<a href={ "/bookshelf?author_filter=" + url.QueryEscape(authorName) } class="hover:underline" style="color: var(--text-secondary);">{ authorName }</a>
}
</h2>
} else if book.Author.Valid && book.Author.String != "" {
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
by { book.Author.String }
</h2>
}
if book.IsAudio() && len(book.Narrators) > 0 {
<h3 class="text-sm md:text-base mb-5" style="color: var(--text-secondary)">
Narrated by { strings.Join(book.Narrators, ", ") }
Narrated by
for i, narratorName := range book.Narrators {
if i > 0 {
<span>, </span>
}
@FacetLink("narrator", narratorName, narratorName)
}
</h3>
}
<!-- Action buttons -->
@@ -184,6 +242,9 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
}
<!-- Badges: series, direction, tags -->
<div class="flex flex-wrap items-center gap-2 mb-3">
if book.Explicit {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);" title="Marked explicit in source metadata">Explicit</span>
}
if book.Series.Valid && book.Series.String != "" {
<a
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
@@ -191,8 +252,8 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
style="background-color: var(--accent); color: var(--bg-primary); text-decoration: none;"
>
{ book.Series.String }
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
#{ book.SeriesNumber.Int32 }
if book.SeriesNumber.Valid && book.SeriesNumber.Float64 > 0 {
#{ fmtNumber(book.SeriesNumber.Float64) }
}
</a>
}
@@ -200,13 +261,13 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">{ strings.ToUpper(book.ReadingDirection.String) }</span>
}
if book.AgeRating.Valid && book.AgeRating.String != "" {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.AgeRating.String }</span>
@FacetBadge("age_rating", book.AgeRating.String, book.AgeRating.String)
}
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">B&W</span>
}
if book.StoryArc.Valid && book.StoryArc.String != "" {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.StoryArc.String }</span>
@FacetBadge("story_arc", book.StoryArc.String, book.StoryArc.String)
}
</div>
if len(book.Tags) > 0 {
@@ -361,7 +422,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if book.Publisher.Valid && book.Publisher.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Publisher</p>
<p style="color: var(--text-primary)">{ book.Publisher.String }</p>
<p style="color: var(--text-primary)">@FacetLink("publisher", book.Publisher.String, book.Publisher.String)</p>
</div>
}
if book.DatePublished.Valid {
@@ -379,13 +440,20 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if book.Language.Valid && book.Language.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Language</p>
<p class="capitalize" style="color: var(--text-primary)">{ book.Language.String }</p>
<p style="color: var(--text-primary)">@FacetLink("language", book.Language.String, languageDisplay(book.Language.String))</p>
</div>
}
if book.IsAudio() && len(book.Narrators) > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Narrator</p>
<p style="color: var(--text-primary)">{ strings.Join(book.Narrators, ", ") }</p>
<p style="color: var(--text-primary)">
for i, narratorName := range book.Narrators {
if i > 0 {
<span>, </span>
}
@FacetLink("narrator", narratorName, narratorName)
}
</p>
</div>
}
if book.IsAudio() && book.AudioDuration() > 0 {
@@ -403,7 +471,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if book.Edition.Valid && book.Edition.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Edition</p>
<p style="color: var(--text-primary)">{ book.Edition.String }</p>
<p style="color: var(--text-primary)">@FacetLink("edition", book.Edition.String, book.Edition.String)</p>
</div>
}
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
@@ -415,7 +483,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if book.Genre.Valid && book.Genre.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Genre</p>
<p style="color: var(--text-primary)">{ book.Genre.String }</p>
<p style="color: var(--text-primary)">@FacetLink("genre", book.Genre.String, prettifyGenre(book.Genre.String))</p>
</div>
}
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
@@ -433,7 +501,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
if book.Imprint.Valid && book.Imprint.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Imprint</p>
<p style="color: var(--text-primary)">{ book.Imprint.String }</p>
<p style="color: var(--text-primary)">@FacetLink("imprint", book.Imprint.String, book.Imprint.String)</p>
</div>
}
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
+14 -4
View File
@@ -323,9 +323,13 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Title</label>
<input type="text" name="title" value={ book.Title } class="input" />
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Subtitle</label>
<input type="text" name="subtitle" value={ textToString(book.Subtitle) } class="input" />
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Author</label>
<input type="text" name="author" value={ textToString(book.Author) } class="input" />
<input type="text" name="authors" value={ authorsFormValue(book.Authors, textToString(book.Author)) } class="input" placeholder="Comma-separated names"/>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Description</label>
@@ -400,7 +404,7 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<div x-show="openSections.publication" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Publisher</label>
<input type="text" name="publisher" value={ textToString(book.Publisher) } class="input" />
<input type="text" name="publishers" value={ authorsFormValue(book.Publishers, textToString(book.Publisher)) } class="input" placeholder="Comma-separated publishers"/>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Date Published</label>
@@ -446,8 +450,8 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Number</label>
<input type="number" name="series_number"
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
<input type="number" name="series_number" step="any"
value={ fmtNumber(book.SeriesNumber.Float64) }
class="input" />
</div>
<div>
@@ -524,6 +528,12 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<span>Abridged edition</span>
</label>
</div>
<div>
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
<input type="checkbox" name="explicit" checked={ book.Explicit } class="h-4 w-4 accent-current"/>
<span>Explicit content</span>
</label>
</div>
if book.AudioDuration() > 0 {
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Duration (scanned)</label>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+125 -107
View File
@@ -170,114 +170,107 @@ templ BookShelf(
}
</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="{
'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'"
/>
<!-- 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>
<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' : 'Group'"></span>
<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" @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`">
<button type="button" class="fb-connective fb-connective-btn" :class="row.chipClickable ? '' : 'fb-chip-static'" x-show="!row.isFirst" x-text="row.chip" @click="if (row.chipClickable) toggleRuleConnective(row.path)" :title="row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip"></button>
<!-- 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 fb-menu-fields" x-show="openMenu === row.key + ':field'" @click.outside="openMenu = ''">
<div class="fb-menu-filter-wrap">
<input type="text" class="fb-menu-filter" placeholder="Filter fields…" x-model="fieldFilter" @keydown.enter.prevent @click.stop/>
</div>
<div class="fb-menu-scroll">
<template x-for="grp in visibleFieldGroups" :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 class="fb-menu-empty" x-show="visibleFieldGroups.length === 0">No matching fields</div>
</div>
</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="fb-value-cell 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)" :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="'days'"></span>
</template>
<!-- field-aware value menu: server JSON (the other
rules bound), or the field's fixed options.
Clicks anywhere inside the value cell never close
it — the opening focus-click must not race the
outside handler. -->
<div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="if (!$event.target.closest('.fb-value-cell')) 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>
</template>
<div class="fb-menu-empty" x-show="suggestionsLoading === row.key">Loading…</div>
<div class="fb-menu-empty" x-show="suggestionsLoading !== row.key && !(suggestions[row.key] || []).length">No matching values</div>
</div>
</span>
</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 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);">
@@ -286,6 +279,31 @@ templ BookShelf(
</div>
</div>
</form>
<!-- Active-filter chips: a pure function of the client-side
form state (Alpine renders them, templ owns the look —
sanctioned TS island). Removing a chip clears that facet
and 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"
: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)"
>
<span x-text="chip.label"></span>
<span aria-hidden="true">×</span>
</button>
</template>
</div>
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
@BooksGrid(books, limit, offset, count, currentLibraryID)
if errorMessage != "" {
+22 -22
View File
@@ -61,7 +61,7 @@ func BookShelf(
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -119,7 +119,7 @@ func BookShelf(
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 93, Col: 135}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -132,7 +132,7 @@ func BookShelf(
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 95, Col: 25}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -195,7 +195,7 @@ func BookShelf(
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 153, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -213,7 +213,7 @@ func BookShelf(
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 155, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -234,7 +234,7 @@ func BookShelf(
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -247,7 +247,7 @@ func BookShelf(
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -260,7 +260,7 @@ func BookShelf(
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -278,7 +278,7 @@ func BookShelf(
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -291,7 +291,7 @@ func BookShelf(
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 48}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 48}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -304,7 +304,7 @@ func BookShelf(
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -323,7 +323,7 @@ func BookShelf(
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -336,7 +336,7 @@ func BookShelf(
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -349,7 +349,7 @@ func BookShelf(
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -367,7 +367,7 @@ func BookShelf(
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 34}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
@@ -380,7 +380,7 @@ func BookShelf(
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -393,7 +393,7 @@ func BookShelf(
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -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><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
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' : 'Group'\"></span> <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\" @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`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- 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 fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :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 class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></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=\"fb-value-cell 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)\" :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=\"'days'\"></span></template><!-- field-awarLine truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -422,7 +422,7 @@ func BookShelf(
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 293, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 311, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
@@ -445,7 +445,7 @@ func BookShelf(
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))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 302, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 320, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -476,7 +476,7 @@ func BookShelf(
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 311, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 329, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -489,7 +489,7 @@ func BookShelf(
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))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 316, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 334, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
+10 -10
View File
@@ -41,7 +41,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(pageTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 14, Col: 21}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 14, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -63,7 +63,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -84,7 +84,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 22, Col: 22}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -105,7 +105,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 24, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -118,7 +118,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -131,7 +131,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 32}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -144,7 +144,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 33, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -157,7 +157,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 34, Col: 125}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -190,7 +190,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 44, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -203,7 +203,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 46, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 46, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
+12
View File
@@ -72,6 +72,18 @@ templ CollectionModal(collection CollectionData) {
<button type="button" @click="selectColor('purple')" data-color="purple" class="w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400" aria-label="Purple"></button>
</div>
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
<div class="mb-4 space-y-2">
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
<input type="hidden" name="hide_finished" value="false"/>
<input type="checkbox" name="hide_finished" value="true" checked={ collection.HideFinished } class="h-4 w-4 accent-current"/>
<span>Hide finished books (reading-queue mode)</span>
</label>
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
<input type="hidden" name="show_up_next" value="false"/>
<input type="checkbox" name="show_up_next" value="true" checked={ collection.ShowUpNext } class="h-4 w-4 accent-current"/>
<span>Show Up Next strip</span>
</label>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
+42 -16
View File
@@ -72,7 +72,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 23, Col: 49}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 23, Col: 49}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -85,7 +85,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 26, Col: 57}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 26, Col: 57}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -98,7 +98,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 32, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 32, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -111,7 +111,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 45, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 45, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -124,7 +124,7 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 58, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 58, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
@@ -137,36 +137,62 @@ func CollectionModal(collection CollectionData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 74, Col: 86}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 74, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><div class=\"mb-4 space-y-2\"><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"hide_finished\" value=\"false\"> <input type=\"checkbox\" name=\"hide_finished\" value=\"true\" checked=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.HideFinished)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 135, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 78, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"h-4 w-4 accent-current\"> <span>Hide finished books (reading-queue mode)</span></label> <label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"show_up_next\" value=\"false\"> <input type=\"checkbox\" name=\"show_up_next\" value=\"true\" checked=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ShowUpNext)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 83, Col: 95}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\" class=\"h-4 w-4 accent-current\"> <span>Show Up Next strip</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 147, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+7 -7
View File
@@ -36,7 +36,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 9, Col: 46}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 9, Col: 46}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -58,7 +58,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -71,7 +71,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 14, Col: 120}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 14, Col: 120}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -100,7 +100,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 159}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 159}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -113,7 +113,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 179}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 179}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -126,7 +126,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 25, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 25, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -147,7 +147,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 49, Col: 67}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 49, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
+6
View File
@@ -36,6 +36,12 @@ templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo)
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
/>
</label>
<div class="flex flex-col items-center gap-0.5 mt-1">
<button type="button" class="icon-btn text-xs" title="Move earlier (playlist order)"
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", -1)" }>↑</button>
<button type="button" class="icon-btn text-xs" title="Move later (playlist order)"
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", 1)" }>↓</button>
</div>
</div>
}
<div class="flex-1 min-w-0">
File diff suppressed because it is too large. Load diff
+12 -12
View File
@@ -60,7 +60,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 25, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
@@ -73,7 +73,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ResolutionStatus)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 114}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 25, Col: 114}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -86,7 +86,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 31, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 31, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -107,7 +107,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 39, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 39, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -120,7 +120,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 40, Col: 96}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 40, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -133,7 +133,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 44, Col: 73}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 44, Col: 73}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -146,7 +146,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 47, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 47, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -164,7 +164,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 45}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 49, Col: 45}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -213,7 +213,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(total)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 62, Col: 82}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 62, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -226,7 +226,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 66, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 66, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -239,7 +239,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 70, Col: 97}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 70, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -339,7 +339,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
+3 -3
View File
@@ -45,7 +45,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -87,7 +87,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 74, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -100,7 +100,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 74, Col: 44}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
+52 -52
View File
@@ -50,7 +50,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -88,7 +88,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", unlinkedCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 37, Col: 69}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 37, Col: 69}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
@@ -101,7 +101,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(pluralizeBooks(unlinkedCount))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 38, Col: 38}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -184,7 +184,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 63, Col: 33}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 63, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil {
@@ -197,7 +197,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 64, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 64, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
@@ -210,7 +210,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 68, Col: 130}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 68, Col: 130}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -223,7 +223,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 70, Col: 100}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 70, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -241,7 +241,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 72, Col: 83}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 72, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -264,7 +264,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 78, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 78, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -290,7 +290,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 91, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 91, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -311,7 +311,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 99, Col: 39}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 99, Col: 39}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -329,7 +329,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 104, Col: 92}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 104, Col: 92}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -369,7 +369,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 118, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 118, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -419,7 +419,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var18 templ.SafeURL
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 131, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -437,7 +437,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 138, Col: 31}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 138, Col: 31}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
@@ -450,7 +450,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 139, Col: 22}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 139, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
@@ -468,7 +468,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 147, Col: 22}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 147, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -491,7 +491,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(badgeLabel(item.Badge))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 157, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 157, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil {
@@ -517,7 +517,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 164, Col: 117}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 164, Col: 117}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
@@ -530,7 +530,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 165, Col: 17}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 165, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil {
@@ -548,7 +548,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 168, Col: 100}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 168, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil {
@@ -561,7 +561,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 169, Col: 19}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 169, Col: 19}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil {
@@ -584,7 +584,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var27 templ.SafeURL
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 177, Col: 40}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 177, Col: 40}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil {
@@ -597,7 +597,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 179, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 179, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
@@ -623,7 +623,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var29 templ.SafeURL
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 186, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 186, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
if templ_7745c5c3_Err != nil {
@@ -636,7 +636,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("Listen to " + item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 188, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 188, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil {
@@ -662,7 +662,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var31 templ.SafeURL
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 195, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 195, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
if templ_7745c5c3_Err != nil {
@@ -675,7 +675,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 197, Col: 38}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 197, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
@@ -743,7 +743,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 228, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil {
@@ -756,7 +756,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 229, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 229, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil {
@@ -777,7 +777,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 234, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 234, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
@@ -790,7 +790,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 236, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 236, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
@@ -818,7 +818,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 246, Col: 42}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 246, Col: 42}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil {
@@ -831,7 +831,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue("Restore " + section.Title + " to defaults")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 248, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 248, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil {
@@ -864,7 +864,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 272, Col: 128}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 272, Col: 128}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
if templ_7745c5c3_Err != nil {
@@ -877,7 +877,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 279, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 279, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
if templ_7745c5c3_Err != nil {
@@ -959,7 +959,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 342, Col: 111}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 342, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
@@ -972,7 +972,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d pages", week.WeekPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 343, Col: 103}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 343, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
if templ_7745c5c3_Err != nil {
@@ -985,7 +985,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d sessions", week.WeekSessions))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 344, Col: 109}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 344, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
if templ_7745c5c3_Err != nil {
@@ -998,7 +998,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d books", week.WeekBooks))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 345, Col: 103}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 345, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
if templ_7745c5c3_Err != nil {
@@ -1016,7 +1016,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.CurrentStreak))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 347, Col: 111}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 347, Col: 111}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
if templ_7745c5c3_Err != nil {
@@ -1047,7 +1047,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 359, Col: 108}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 359, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
if templ_7745c5c3_Err != nil {
@@ -1060,7 +1060,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 360, Col: 122}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 360, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
if templ_7745c5c3_Err != nil {
@@ -1073,7 +1073,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var50 string
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekSessions))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 364, Col: 109}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 364, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
if templ_7745c5c3_Err != nil {
@@ -1086,7 +1086,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var51 string
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekBooks))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 368, Col: 106}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 368, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
if templ_7745c5c3_Err != nil {
@@ -1104,7 +1104,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var52 string
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 373, Col: 108}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 373, Col: 108}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
if templ_7745c5c3_Err != nil {
@@ -1117,7 +1117,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var53 string
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 374, Col: 122}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 374, Col: 122}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
if templ_7745c5c3_Err != nil {
@@ -1130,7 +1130,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var54 string
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekPages))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 378, Col: 106}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 378, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
if templ_7745c5c3_Err != nil {
@@ -1143,7 +1143,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var55 string
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekSessions))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 382, Col: 109}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 382, Col: 109}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
if templ_7745c5c3_Err != nil {
@@ -1156,7 +1156,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var56 string
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekBooks))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 386, Col: 106}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 386, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
if templ_7745c5c3_Err != nil {
@@ -1176,7 +1176,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var57 string
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 394, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 394, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
if templ_7745c5c3_Err != nil {
@@ -1186,7 +1186,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
var templ_7745c5c3_Var58 string
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 396, Col: 91}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 396, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
if templ_7745c5c3_Err != nil {
+21 -21
View File
@@ -47,7 +47,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil {
@@ -133,7 +133,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 64, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 64, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -154,7 +154,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 67, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 67, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil {
@@ -175,7 +175,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 72, Col: 102}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 102}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -188,7 +188,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 73, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 73, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -221,7 +221,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 86, Col: 104}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 86, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -249,7 +249,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 94, Col: 104}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 94, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -277,7 +277,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("sync-url-" + device.ID.String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 108, Col: 51}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 108, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil {
@@ -290,7 +290,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 110, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 110, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil {
@@ -303,7 +303,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(document.getElementById('sync-url-" + device.ID.String() + "').value, 'Kobo sync URL')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 114, Col: 127}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 114, Col: 127}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
@@ -330,7 +330,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("auth-token-" + device.ID.String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 130, Col: 53}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 130, Col: 53}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil {
@@ -343,7 +343,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 132, Col: 38}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 132, Col: 38}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil {
@@ -356,7 +356,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(document.getElementById('auth-token-" + device.ID.String() + "').value, 'Auth token')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 136, Col: 126}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 136, Col: 126}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
@@ -382,7 +382,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 147, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 147, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
@@ -423,7 +423,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 168, Col: 87}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 168, Col: 87}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
@@ -436,7 +436,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 170, Col: 27}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
@@ -449,7 +449,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 170, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
@@ -462,7 +462,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 174, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 174, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil {
@@ -475,7 +475,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 177, Col: 71}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 177, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil {
@@ -514,7 +514,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 242, Col: 148}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 242, Col: 148}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
if templ_7745c5c3_Err != nil {
@@ -527,7 +527,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 244, Col: 68}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 244, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil {
+15 -15
View File
@@ -38,7 +38,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 11, Col: 21}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 11, Col: 21}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -60,7 +60,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 1, Col: 0}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil {
@@ -102,7 +102,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 50, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 50, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
@@ -133,7 +133,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var6 templ.SafeURL
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 56, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
@@ -146,7 +146,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 57, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
@@ -159,7 +159,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 58, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 58, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
@@ -187,7 +187,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var9 templ.SafeURL
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 65, Col: 29}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil {
@@ -200,7 +200,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 66, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
@@ -213,7 +213,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 67, Col: 30}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 67, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
@@ -257,7 +257,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var12 templ.SafeURL
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 82, Col: 28}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil {
@@ -270,7 +270,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 82, Col: 129}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 129}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
@@ -293,7 +293,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 86, Col: 104}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 104}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
@@ -324,7 +324,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var15 templ.SafeURL
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 96, Col: 35}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 35}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil {
@@ -337,7 +337,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 98, Col: 105}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 105}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
@@ -350,7 +350,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 100, Col: 23}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 23}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
+2 -2
View File
@@ -36,7 +36,7 @@ func ErrorPage(message string, errorType string) templ.Component {
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `error.templ`, Line: 84, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 84, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
@@ -49,7 +49,7 @@ func ErrorPage(message string, errorType string) templ.Component {
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `error.templ`, Line: 90, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 90, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff. Show more