Compare commits

21 Commits
Author SHA1 Message Date
John O'Keefe 7a9a66fcc5 feat(ui): show file path and Reset to Scanned in the metadata editor
Release / build-and-push (push) Successful in 2m24s
Complete the metadata editor modal to match the backend override work:

- File Path: read-only, monospace field showing the resolved on-disk
  location (MediaDetail.FileLocation), next to Format and File Size, so
  admins can see exactly which file backs the record without leaving the
  editor. Renders empty for non-admins, who never receive the path.
- Reset to Scanned: new button beside Rescan. It confirms, then calls
  POST /api/media-items/:id/rescan?reset_overrides=true to drop all
  per-field user overrides and re-extract scanned defaults. Rescan alone
  keeps customizations; Reset discards them.
2026-09-12 14:22:38 -04:00
John O'Keefe 2df3ecbf36 fix(ui): remove the broken Generate Cover button from the metadata editor
Generate Cover has never worked: it fetched the book file using a URL
scraped from the cover preview <img> tag (so it downloaded either the
existing cover JPEG or, when no cover existed, the detail page HTML),
then handed it to foliate-js, which rejects both. Its fixed-layout path
also called view.renderer.renderPage(), a method that does not exist in
the pinned foliate fork. Every click ended in the same generic 'Cover
generation failed' toast.

The working alternative already exists server-side: the scanner's
PDF/EPUB cover extraction plus the per-book Rescan button, now that the
rasterizer renders the CropBox. Users who want a specific image can
upload one.

Delete web/src/cover-generator.ts, the modal buttons, and the dead
generateCover()/coverGenerating/fileUrl plumbing in book-detail.ts.
2026-09-12 14:22:29 -04:00
John O'Keefe 9da193e718 feat(ui): admin-only file location and metadata editing on book detail
The book detail page exposed server internals and unusable controls to
everyday users: it now computes and renders the book's absolute on-disk
location, and gates all of it behind the admin role.

- The page handler resolves library folder + relative path (verified
  with os.Stat; falls back to the relative path when the file is not
  found on disk) into the new MediaDetail.FileLocation field - only for
  admins, so the absolute path never leaves the server for regular
  users. This also makes it possible to locate sparse entries whose
  metadata rows are largely blank.
- The Metadata grid gains a full-width, monospace, click-selectable
  Location row (admins only).
- The Edit button and the MetadataEditorModal markup render only for
  admins. The modal drives admin-only endpoints (metadata PUT, rescan,
  reset), so non-admins previously saw a button and a form that could
  only ever fail with 403s.
2026-09-12 14:21:42 -04:00
John O'Keefe 7f64b92b9d feat(metadata): per-field user overrides that survive library rescans
Previously both the metadata editor (PUT /api/media-items/:id) and the
scanner (library scans, force rescans, per-book rescans) wrote through
the same unconditional UPDATE media_items query, so any rescan wiped
user-written descriptions, tags, and uploaded covers. Custom and scanned
values were indistinguishable, and custom cover uploads even wrote to
the same {file}.cover.jpg sidecar path the scanner generates, so each
side silently clobbered the other.

Introduce metadata_overrides, a TEXT[] column on media_items listing the
column names the user has customized:

- Saving metadata records overrides per field by diffing the submitted
  values against the stored row (an untouched save records nothing);
  overrides accumulate until an explicit reset. Cover upload/removal
  always marks cover_image_path. Bulk updates mark each applied field.
- The scanner merges: updateMediaItem() now takes the existing row and
  restores every overridden column (including derived *_search arrays)
  before writing, and preserves the override set itself.
- Uploaded covers move to a dedicated {file}.custom_cover.{jpg|png|webp}
  sidecar so the scanner can never overwrite a user cover on disk.
- RescanMediaItem gains resetOverrides: POST /api/media-items/:id/rescan?
  reset_overrides=true clears the set first, returning the item to pure
  scanned defaults.

Shared detection/restore helpers live in internal/utils
(metadata_overrides.go) with unit tests covering detection, accumulation,
unset-form equality, and restore-with-derived-fields. Schema change is
an idempotent ADD COLUMN IF NOT EXISTS applied on startup. Also includes
incidental gofmt of NewMediaScanner literals in media_scanner.go.
2026-09-12 14:21:35 -04:00
John O'Keefe b9645752f5 fix(db): skip SQL comment lines when parsing schema table names
parseTableNames() regex-scanned every line of schema.sql, comments
included, with 'CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?(\w+)'. A doc
comment containing that phrase in prose - e.g. 'declared in CREATE TABLE
above' - registered a phantom table ('above'), and startup verification
then failed with 'missing tables: above', crash-looping the app
container on every restart.

Skip lines whose trimmed form starts with '--' so comments can never
contribute table names, and add a regression test asserting every parsed
table maps back to a real CREATE TABLE statement.
2026-09-12 14:21:28 -04:00
John O'Keefe 9c8337d0a8 fix(scanner): render the PDF CropBox, not the MediaBox, in the cover fallback
pdftoppm defaults to rasterizing the MediaBox, while PDF viewers (pdf.js
in the reader, and every other viewer) display the CropBox. For PDFs
whose page 1 is the full print cover wrap (back cover + spine + front
cover in one landscape page) with a CropBox covering only the front
cover - e.g. No Starch's XeTeX-built 'Algorithmic Thinking' - the
fallback stored the entire spread as a squashed landscape cover, while
the reader correctly showed just the front cover.

Pass -cropbox so the rendered cover always matches what the reader
displays. Poppler falls back to the MediaBox when a PDF defines no
CropBox, so PDFs with identical boxes (the common case) render exactly
as before.
2026-09-12 14:21:24 -04:00
john-okeefe da1f689263 feat(ui): add Rescan button to Edit Metadata dialog
Release / build-and-push (push) Successful in 2m50s
Adds a Rescan button to the MetadataEditorModal footer that POSTs to the
new per-book rescan endpoint, with rescanning state (disabled + spinner,
matching the existing Mark Read pattern), success/error toasts, and a
page reload to pick up the refreshed cover and metadata.
2026-09-11 23:08:20 -04:00
john-okeefe 298330a3a8 feat(api): add POST /api/media-items/:id/rescan endpoint
Admin-only endpoint that re-extracts metadata and cover art for a single
book from the file on disk via MediaScanner.RescanMediaItem and returns
the updated media item. Normal library scans skip unchanged files, so
this gives a targeted way to backfill covers for previously imported
books. Includes a Bruno request alongside the existing media-items
collection.
2026-09-11 23:08:20 -04:00
john-okeefe 0614795ca2 feat(scanner): render PDF first page as cover fallback via pdftoppm
extractPDFCover previously only saved embedded raster images from page 1,
so vector/text-first-page PDFs (e.g. InDesign exports like Data Structures
the Fun Way) ended up with no cover and a dashboard placeholder. When no
embedded image is found it now falls back to rendering page 1 with
pdftoppm (poppler-utils), saving the same {pdf}.cover.jpg sidecar.

Also adds MediaScanner.RescanMediaItem, which re-extracts metadata for a
single media item (resolving its on-disk path from library folders) so
previously imported books can backfill covers without a full force rescan.

Dockerfile installs poppler-utils in the final and test-runner stages.
2026-09-11 23:08:20 -04:00
john-okeefe 6aa958c78f fix(reader): save progress on real position change, not on detected intent
Release / build-and-push (push) Successful in 2m27s
The userMoved gate from ce3ae31 broke progress saving entirely for
EPUBs. The flag was set only in the app's navigation wrappers
(goLeft/goRight, keys, slider, search/TOC/bookmark/back-stack jumps),
but foliate-js's paginator handles the most common reading gestures
itself — touch-swipe paging, scrolled-mode reading, in-content links,
selection auto-advance — dispatching relocate directly without ever
calling those wrappers. Every one of those relocations hit the
"if (!this.userMoved) return" guard, so the position never saved at
all on EPUB; only tap-zone-paged formats (comics/fixed layout) kept
saving, which matched the intermittent reports.

Detecting intent was the wrong tool: the set of foliate-internal
navigation paths is open-ended and lives in a forked dependency.
Compare the position itself instead:

- The relocate handler records the latest CFI (lastCfi), and the
  baseline (lastSyncedCfi/lastSyncedFraction) is captured right after
  view.init() resolves — i.e. the restored position, or the start of
  the book on a fresh open.
- debouncedSaveProgress saves only when the position actually moved:
  CFI comparison for reflowable books, fraction comparison (1e-4
  epsilon) for CFI-less fixed layout and PDF.
- The baseline updates after each successful save, and a
  bfcache-resurrected page re-baselines to its frozen position, so the
  anti-clobber property survives: a displayed/restored position can
  still never overwrite a newer device push.

The twelve userMoved assignments in the wrapper methods are gone —
change detection covers deliberate jumps and internal gestures alike.
Backend untouched; SaveProgress was never the problem.
2026-09-11 21:50:03 -04:00
john-okeefe 3514b4dc1c fix(reader): close the highlight popover on outside clicks in the book
Release / build-and-push (push) Successful in 2m24s
Clicks on reader chrome already dismissed the selection popover via the
host-document outside-click handler, but clicks inside the book happen
in content iframes whose events never bubble to the host document — the
host handler never sees them. The only iframe-side dismissal ran through
the selection tracker's collapsed check, which hides the popover solely
in create mode: once the popover was open in EDIT mode (clicked a
highlight, writing a note), clicking anywhere in the book did nothing
and Esc was the only way out.

Each content iframe now gets a pointerdown listener that dismisses the
popover in any mode. Clicking a painted highlight still opens the edit
popover: this hides first, then foliate's show-annotation re-opens it.
2026-09-10 08:27:16 -04:00
john-okeefe ae87c0cd6a fix(sync): let content-equal echoes refresh drifted locators
The LWW skip path compared only annotation content (text, color, note,
percentages) — locator columns were not part of 'changed'. A device echo
with identical content therefore resolved to skip, and the freshly
re-derived canonical CFIs were discarded in the same request that
computed them: a highlight whose stored start anchor had been corrupted
by the old percentage-exact bug could never heal, because every
subsequent echo carried the same text and was skipped before the
locator columns were written. Observed live: a push converted the
cross-block highlight's anchors correctly (structural, heading to
paragraph) yet the row kept its garbage start CFI and the highlight
stayed unpaintable on the web.

Echo saves now treat a non-empty incoming locator that differs from the
stored one as a change (locatorRefresher): empty locators still coalesce
(no drift), and once healed the echo produces identical CFIs, so the
steady state remains skip — no write churn. Highlights check
epubcfi_start/end; bookmarks check cfi_position/position.

applyBookmarkLWW also gains the empty-locator coalescing the highlight
path already had: web bookmark edits carry no device locators, and a
title/note edit must not wipe the stored device-native position.
2026-09-10 08:10:42 -04:00
john-okeefe b6f507b9e5 fix(sync): understand cross-block text; never store a guessed locator
Tonight's failures all traced to one blind spot: the converter could
only reason about text within a single block. A position at a chapter
heading sends walk-up context (heading + the paragraphs below, joined by
the plugin's block capture); a selection can span several paragraphs.
Neither shape could be verified (containment compared one block against
a multi-block quote, so the CORRECT structural landing at the heading
was rejected) nor matched by text search (it never crossed block
boundaries). The ladder then fell to the percentage rung — which labeled
its char-count guess Precision "exact" — and that confidently-wrong CFI
was stored: reading positions reopened paragraphs away from the true
spot, and a highlight echo overwrote the row's good web CFIs with a
garbage start anchor that made the highlight unpaintable ("disappeared").

Four changes, all in the forward converter and its consumers:

- Quote verification: after the structural walk lands, read the
  whitespace-normalized document text forward from the landing point
  (crossing block boundaries; inline spans join directly so drop-cap
  splits still read as one word). A usable context must be a prefix of
  that stream — which is exactly what device captures are: the text from
  the position onward, or the selection between two anchors. The old
  single-block containment checks remain as secondary acceptance.
- Cross-block text search: the search rung matches against the whole
  document flattened in reading order, with every rune mapped back to
  its source node and offset. A context spanning blocks now matches, and
  the matched extent yields a true range end (EndEPUBCFI) that
  highlights use as their end anchor, threaded through the facade as
  CanonicalLocator.EndCFI.
- Honest labels: the percentage rung returns Precision "percentage" —
  a char-count estimate must never masquerade as an exact anchor.
- Confident-only storage: progress adopts a converted locator solely at
  structural/exact precision (section hrefs keep their legacy handling;
  anything lower stores percentage only), and highlight conversion
  returns CFIs only at structural/exact precision — a low-confidence
  echo yields empty, which applyLWW coalescing turns into preservation
  of the row's existing web CFIs instead of clobbering them.

Tests: walk-up context at a heading verifies structurally and lands in
the heading; a block-spanning context is found by search with a range
end landing in the following paragraph; the percentage rung is honestly
labeled; all drop-cap guards stay green.
2026-09-09 20:15:12 -04:00
john-okeefe ce3ae31ced fix(reader): read position from the API at open; never write a restored position
The reader page embedded a snapshot of reading state (position,
bookmarks) server-side at render time. Browsers may reuse that HTML
(heuristic caching, bfcache), so opening a book could restore a stale
position — and worse, the restore's relocate auto-saved it back,
overwriting a newer device push minutes later. A KOReader sync followed
by opening the web reader would silently revert the row to the old web
position; the row's source and the rendered page disagreed.

The web reader is intrinsically tied to the server, so it has no business
preserving reading state client-side:

- The rendered page now carries only immutable book metadata. The reader
  fetches progress fresh (cache: no-store) from the existing progress
  API at open and restores with the same priority as before (page for
  fixed-layout, CFI, percentage, fresh start); a failed fetch opens at
  the start and writes nothing. Initial bookmarks likewise come from
  their endpoint instead of the embed; annotations already did.
- Progress saves are gated on deliberate navigation only (page turns,
  keys, slider, search/TOC/bookmark/back-stack jumps, tap zones — each
  marks the session as user-moved). Restores and section-load
  relocations never write, so displaying a position can no longer
  clobber a newer one. A bfcache-resurrected page resets the flag and
  cannot write its frozen position either. This replaces the old
  five-second post-init suppression, which a stale page bypassed.
- The server-rendered initial progress badges render a neutral
  placeholder until the first relocate fills them (sub-second).

No API, schema, or sync-engine changes. Normal reading saves exactly as
before — the first save now simply waits for the first real page turn.
2026-09-09 14:52:30 -04:00
john-okeefe 75c1d9bb95 fix(highlights): web edits update the targeted row instead of minting duplicates
PUT /highlights/:id parsed the row id from the URL and then dropped it:
the sync-aware path routed through SaveHighlight's content-derived dedup
key, on the assumption that the same text + CFI always resolves to the
same key. That assumption breaks in practice — the stored epubcfi_start
drifts from foliate's range shape to the converter's point shape after a
device echo rewrites the row (bucketPosition cuts at the last colon, so
'…/6,/1:367,…' and '…/6/1:367' bucket differently), and the user can
edit the selection text. The recomputed key then misses the row being
edited and createHighlight mints a second one: the edited row (with
note, no device pos0) beside the original — served to KOReader as two
highlights, one noted and one not. Editing a selection's text would hit
the same trap.

SaveHighlightRequest gains an optional HighlightID. When set, the save
resolves the row by id (ownership-checked), LWWs against it under its
stored dedup key, and never re-derives identity from content. The PUT
handler passes the already-parsed id. Device pushes, Kobo, and the sync
queue send no id and keep the identity-based flow untouched.

applyLWW also stops wiping stored locators on web edits: the web reader
sends empty start/end positions (it never had a CRE xpointer), so a
note/color edit now keeps the device-native positions and CFIs instead
of blanking them — round-trip serve-back for device-created highlights
survives web-side edits.
2026-09-09 14:12:18 -04:00
john-okeefe 905218dd4b fix(reader): stop tap zones from paging during long-press text selection
Selecting text near the left edge on a touch browser could page back
instead: a long-press released just inside the 500ms tap window (Android
selection engages at ~400-500ms, right at the guard boundary) or landing
on a margin resolved as a tap, armed the 280ms debounce, and nothing
ever cancelled it. Four hardenings in the tap-zone pipeline:

- contextmenu (Android's long-press-engaged signal) suppresses the
  matching pointerup from counting as a tap, closing the duration race
- pointercancel (the browser taking over the gesture) now resets the
  tracked pointer so stale state can never match a later touch
- selectionchange on the host document and every content iframe cancels
  an armed tap action: a selection appearing right after finger-lift
  means the 'tap' was a long-press selection engaging
- the host-viewport pointerup honors the tracked any-selection flag,
  closing the blind spot where selections in iframes or the host's own
  fixed-layout text layer were invisible to the host surface (the
  per-surface check only ran for iframe docs)

Purely touch-path (coarse pointer) changes; desktop behavior untouched.
2026-09-09 13:00:39 -04:00
john-okeefe 94dad5e089 fix(reader): stop page-turn and zoom shortcuts while typing in form fields
handleKeydown computed a 'typing' guard for the event target but only
applied it to the drawer-shortcut block (t/s/b/?//). The vi-style page
turns (h/l), arrows, and zoom keys (+/−/0) fired regardless, so typing
into the note textarea hijacked the keys: 'Wh' turned back a page on the
h, arrows moved pages instead of the caret, and digits/minus zoomed.

Return early for INPUT/SELECT/TEXTAREA/contentEditable targets, keeping
Escape live so popovers and drawers stay dismissable from the keyboard
mid-note. Covers the selection-popover note field, the notes drawer
textarea, bookmark rename, and the search box. The now-unreachable
'!typing' condition on the shortcut block is dropped.
2026-09-09 12:59:37 -04:00
john-okeefe 2366faccce fix(reader): make device-synced highlights editable on the web
Device-synced highlights paint with a synthesized range CFI (renderCfi,
built by toRangeCfi from the stored point CFIs and selection text) while
the stored locator stays a point CFI. Three follow-ons from that split:

- show-annotation (click-to-edit) matched the clicked value against the
  stored point cfi only, so clicking a device-created highlight never
  opened the edit popover — it listed in the drawer but was uneditable.
  Match either the stored cfi or the renderCfi the overlay was added by.

- deleteHighlightById removed the overlay with the stored point cfi,
  which never matched the painted value; the highlight box lingered
  until reload. Delete with the value it was added by.

- saveHighlightChanges re-added the overlay without removing the old
  value; an edit that changes the synthesized range (note/text edits
  change the UTF-16 length it derives from) would ghost the old paint
  beside the new one. Remove the previous overlay value first when the
  edit changed it.

Web-created highlights are unaffected: their stored CFI is already a
native range, so renderCfi === cfi for them.
2026-09-09 12:38:24 -04:00
john-okeefe e40530824e feat(koreader): one conversion route for every feature; bookmarks get web CFIs
Release / build-and-push (push) Successful in 2m14s
Progress, highlights, notes, and bookmarks entered position conversion
through three different doors: progress converted inline with an
uncached converter, annotations through the facade, bookmarks not at
all (the raw xpointer was stored verbatim, cfi_position stayed empty,
and the web drawer's goToBookmark silently no-ops on cfi-less entries).

Unify on the facade (ConvertToCanonical/ConvertFromCanonical):

- annotationEpub context resolved once per push: media item + EPUB path
  shared by every annotation instead of re-fetched per entry
- progress forward: the inline block becomes one facade call;
  non-reflowable formats pass through unchanged, and the cached
  converter stops re-parsing the book on every sync
- progress reverse: convertCFIToXPointer delegates to reverseConvertCFI,
  keeping the stored percentage in play for the fallback ladder
- bookmarks (bulk progress and /sync-bookmarks): pos0 resolves
  structural-only — bookmark text is a display label, never book text,
  so no context is supplied; webUsableCFI stores the result only for
  structural/exact epubcfi landings, discarding href/percentage results
  rather than storing dead drawer links. Also records percentage_location
  and origin_source on the legacy endpoint.
- percentages thread through: highlights/notes/bookmarks pass the device
  percentage or the derived section percentage instead of a hardcoded 0,
  so the last-resort fallback lands near the true position instead of
  the document start
- extendCFIByLength end-derivation now also fires on structural starts
  (it had silently stopped matching when the structural rung began
  landing starts with precision 'structural' rather than 'exact')

Tests: the drop-cap xpointer through the facade with empty context (the
bookmark scenario) must land structurally, not doc-start; webUsableCFI
table covers the store/discard gate.
2026-09-09 09:04:23 -04:00
john-okeefe 5a6c361c11 perf(sync): share the bounded converter cache for section percentages
Per-annotation percentage derivation (deriveAnnotationPercentage) built a
fresh CFIConverter for every highlight/note/bookmark, re-reading and
re-parsing the whole EPUB each time. Export SectionPercentageCached so
handlers reach the same bounded cache ConvertToCanonical already uses
(8 books, insertion-order eviction): one parse per book per push instead
of one per annotation.
2026-09-09 09:04:10 -04:00
john-okeefe 7b1c809ae3 feat(bookmarks): location identity, origin provenance, KOReader-style labels
- Drop UNIQUE(media_item_id,user_id,title): titles are display labels
  shared verbatim across clients; same-title bookmarks on different pages
  now coexist instead of 500ing (deleting over a tombstone no longer
  blocks future creates with that title)
- Add origin_source column recording the creating client (android/web/
  koreader), set once at insert, exposed in API responses
- Web reader auto-title mirrors KOReader's 'in <chapter>' convention,
  falling back to 'Bookmark'; adds bookmark rename in the drawer
2026-09-09 08:17:43 -04:00
34 changed files with 2629 additions and 1244 deletions
+2 -2
View File
@@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
# This stage is ONLY used for running tests, never deployed to production
FROM golang:1.26-alpine AS test-runner
RUN apk --no-cache add ca-certificates curl
RUN apk --no-cache add ca-certificates curl poppler-utils
WORKDIR /app
@@ -65,7 +65,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates curl
RUN apk --no-cache add ca-certificates curl poppler-utils
# Install kepubify for EPUB→KEPUB conversion
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
+35
View File
@@ -0,0 +1,35 @@
info:
name: Rescan Media Item
type: http
seq: 9
http:
method: POST
url: "{{base_url}}/api/media-items/{{media_item_id}}/rescan"
headers:
- name: ""
value: application/json
auth: inherit
runtime:
scripts:
- type: tests
code: |-
test_rescan_media_item_success(status, headers, body) {
if (status !== 200) {
throw new Error("Expected status 200, got " + status);
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
## Rescan Media Item
Re-extracts metadata and cover art for a single media item from the file on disk.
**Method:** POST
**Endpoint:** /api/media-items/{id}/rescan
+23 -2
View File
@@ -207,6 +207,12 @@ CREATE TABLE IF NOT EXISTS media_items (
-- Summary (distinct from description - may merge with Calibre description)
summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre)
-- Per-field user-override tracking. Column names listed here (e.g.
-- 'description', 'tags', 'cover_image_path') are user-customized and MUST
-- NOT be overwritten by library scans or per-book rescans; only the
-- reset-to-scanned-defaults action clears them.
metadata_overrides TEXT[] NOT NULL DEFAULT '{}',
-- Chapter metadata for reader navigation and progress tracking
-- Caches detected chapter structure to avoid re-parsing files
-- Populated by ReaderService.DetectChapters() on first read
@@ -232,6 +238,11 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
-- Per-field user-override tracking (idempotent backfill for existing
-- installs; the column is also declared in the media_items table
-- definition above for fresh databases)
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS metadata_overrides TEXT[] NOT NULL DEFAULT '{}';
-- Create GIN indexes for fast search field searches
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
@@ -1344,8 +1355,10 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
title VARCHAR(255) NOT NULL,
position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(media_item_id, user_id, title)
created_at TIMESTAMPTZ DEFAULT NOW()
-- No UNIQUE(media_item_id, user_id, title): bookmarks are identified by
-- their location (dedup_key), titles are display labels shared verbatim
-- across clients (KOReader auto-labels repeat within a chapter).
);
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
@@ -1386,6 +1399,14 @@ ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Provenance: the client that CREATED the bookmark (unlike
-- last_modified_source, which tracks the last writer). Set once at insert.
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS origin_source VARCHAR(30);
-- Identity is the dedup_key (location), not the title; drop the legacy
-- unique-title constraint so same-title bookmarks on different pages can
-- coexist (re-creating over a tombstone with a changed position also
-- relied on this). Catalog-only change, safe to re-run.
ALTER TABLE media_bookmarks DROP CONSTRAINT IF EXISTS media_bookmarks_media_item_id_user_id_title_key;
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
ON media_highlights (user_id, media_item_id, dedup_key)
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
package database
+3 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
package database
@@ -185,6 +185,7 @@ type MediaBookmarks struct {
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
OriginSource pgtype.Text `db:"origin_source" json:"origin_source"`
}
type MediaHighlights struct {
@@ -305,6 +306,7 @@ type MediaItems struct {
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
// Summary from ComicInfo.xml (may be merged with description from Calibre)
Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
+4 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
package database
@@ -30,6 +30,9 @@ type Querier interface {
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
// Reset an item to scanned defaults: clears per-field user overrides so the
// next rescan can freely overwrite user-customized metadata.
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
CountAdmins(ctx context.Context) (int64, error)
// Count unlinked books for a device
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
+99 -40
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
// source: queries.sql
package database
@@ -226,6 +226,18 @@ func (q *Queries) ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfBy
return err
}
const ClearMediaItemMetadataOverrides = `-- name: ClearMediaItemMetadataOverrides :exec
UPDATE media_items SET metadata_overrides = '{}', updated_at = NOW()
WHERE id = $1
`
// Reset an item to scanned defaults: clears per-field user overrides so the
// next rescan can freely overwrite user-customized metadata.
func (q *Queries) ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, ClearMediaItemMetadataOverrides, id)
return err
}
const CountAdmins = `-- name: CountAdmins :one
SELECT COUNT(*) FROM users WHERE role = 'admin'
`
@@ -624,7 +636,7 @@ func (q *Queries) CreateLibrary(ctx context.Context, arg CreateLibraryParams) (L
const CreateMediaBookmark = `-- name: CreateMediaBookmark :one
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source
`
type CreateMediaBookmarkParams struct {
@@ -670,6 +682,7 @@ func (q *Queries) CreateMediaBookmark(ctx context.Context, arg CreateMediaBookma
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
@@ -680,10 +693,10 @@ INSERT INTO media_bookmarks (
cfi_position, title, position, notes,
percentage_location, epubcfi_location, chapter_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
device_sync_data, origin_source
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
) RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
) RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source
`
type CreateMediaBookmarkFullParams struct {
@@ -702,6 +715,7 @@ type CreateMediaBookmarkFullParams struct {
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
OriginSource pgtype.Text `db:"origin_source" json:"origin_source"`
}
func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error) {
@@ -721,6 +735,7 @@ func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBo
arg.LastModifiedAt,
arg.LastModifiedSource,
arg.DeviceSyncData,
arg.OriginSource,
)
var i MediaBookmarks
err := row.Scan(
@@ -743,6 +758,7 @@ func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBo
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
@@ -896,7 +912,7 @@ const CreateMediaItem = `-- name: CreateMediaItem :one
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
`
type CreateMediaItemParams struct {
@@ -1049,6 +1065,7 @@ func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -2443,7 +2460,7 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF
}
const GetBooksByTag = `-- name: GetBooksByTag :many
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
ORDER BY title ASC
`
@@ -2516,6 +2533,7 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -2615,7 +2633,7 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU
}
const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, ci.excluded FROM media_items mi
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, ci.excluded FROM media_items mi
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
@@ -2683,6 +2701,7 @@ type GetCollectionItemsForDashboardRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -2757,6 +2776,7 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -2909,7 +2929,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
}
const GetContinueReadingItems = `-- name: GetContinueReadingItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
INNER JOIN (
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
FROM reading_progress
@@ -2993,6 +3013,7 @@ func (q *Queries) GetContinueReadingItems(ctx context.Context, arg GetContinueRe
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -3026,7 +3047,7 @@ WITH user_series_progress AS (
GROUP BY mi.series
),
next_books AS (
SELECT DISTINCT ON (mi.series) mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence,
SELECT DISTINCT ON (mi.series) mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence,
usp.last_read_at
FROM media_items mi
JOIN user_series_progress usp ON mi.series = usp.series
@@ -3034,7 +3055,7 @@ next_books AS (
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
ORDER BY mi.series, mi.series_number ASC NULLS LAST
)
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, last_read_at FROM next_books
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, last_read_at FROM next_books
ORDER BY last_read_at DESC NULLS LAST
LIMIT $1
`
@@ -3099,6 +3120,7 @@ type GetContinueSeriesItemsRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -3173,6 +3195,7 @@ func (q *Queries) GetContinueSeriesItems(ctx context.Context, arg GetContinueSer
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -4302,7 +4325,7 @@ func (q *Queries) GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID)
}
const GetLibraryItems = `-- name: GetLibraryItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
ORDER BY mi.created_at DESC
`
@@ -4370,6 +4393,7 @@ func (q *Queries) GetLibraryItems(ctx context.Context, libraryID pgtype.UUID) ([
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -4524,7 +4548,7 @@ func (q *Queries) GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLi
}
const GetMediaBookmark = `-- name: GetMediaBookmark :one
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks WHERE id = $1
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source FROM media_bookmarks WHERE id = $1
`
func (q *Queries) GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error) {
@@ -4550,13 +4574,14 @@ func (q *Queries) GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBo
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
const GetMediaBookmarkByDedupKey = `-- name: GetMediaBookmarkByDedupKey :one
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source FROM media_bookmarks
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
LIMIT 1
@@ -4594,12 +4619,13 @@ func (q *Queries) GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBo
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
const GetMediaBookmarks = `-- name: GetMediaBookmarks :many
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks
SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source FROM media_bookmarks
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
ORDER BY created_at DESC
`
@@ -4638,6 +4664,7 @@ func (q *Queries) GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksPa
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
); err != nil {
return nil, err
}
@@ -4798,7 +4825,7 @@ func (q *Queries) GetMediaHighlights(ctx context.Context, arg GetMediaHighlights
}
const GetMediaItem = `-- name: GetMediaItem :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE id = $1
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE id = $1
`
func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) {
@@ -4858,6 +4885,7 @@ func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems,
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -4871,7 +4899,7 @@ func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems,
}
const GetMediaItemByFilePath = `-- name: GetMediaItemByFilePath :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 AND library_id = $2
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 AND library_id = $2
`
type GetMediaItemByFilePathParams struct {
@@ -4936,6 +4964,7 @@ func (q *Queries) GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemBy
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -4949,7 +4978,7 @@ func (q *Queries) GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemBy
}
const GetMediaItemByFilePathAnyLibrary = `-- name: GetMediaItemByFilePathAnyLibrary :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 LIMIT 1
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 LIMIT 1
`
func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath string) (MediaItems, error) {
@@ -5009,6 +5038,7 @@ func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -5023,7 +5053,7 @@ func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath
const GetMediaItemByFilePathForSync = `-- name: GetMediaItemByFilePathForSync :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1
`
// ============================================
@@ -5086,6 +5116,7 @@ func (q *Queries) GetMediaItemByFilePathForSync(ctx context.Context, filePath st
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -5099,7 +5130,7 @@ func (q *Queries) GetMediaItemByFilePathForSync(ctx context.Context, filePath st
}
const GetMediaItemByKoboContentId = `-- name: GetMediaItemByKoboContentId :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE kobo_content_id = $1
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE kobo_content_id = $1
`
func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error) {
@@ -5159,6 +5190,7 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -5172,7 +5204,7 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID
}
const GetMediaItemByOPFIdentifier = `-- name: GetMediaItemByOPFIdentifier :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_identifier = $1
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_identifier = $1
`
// Get media item by OPF identifier
@@ -5233,6 +5265,7 @@ func (q *Queries) GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -5246,7 +5279,7 @@ func (q *Queries) GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier
}
const GetMediaItemByOPFUUID = `-- name: GetMediaItemByOPFUUID :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_uuid = $1
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_uuid = $1
`
// Get media item by OPF UUID
@@ -5307,6 +5340,7 @@ func (q *Queries) GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -5320,7 +5354,7 @@ func (q *Queries) GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text
}
const GetMediaItemBySHA256 = `-- name: GetMediaItemBySHA256 :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1
`
// Get media item by SHA-256 hash
@@ -5381,6 +5415,7 @@ func (q *Queries) GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Te
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -5394,7 +5429,7 @@ func (q *Queries) GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Te
}
const GetMediaItemBySHA256AndLibrary = `-- name: GetMediaItemBySHA256AndLibrary :one
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2
`
type GetMediaItemBySHA256AndLibraryParams struct {
@@ -5460,6 +5495,7 @@ func (q *Queries) GetMediaItemBySHA256AndLibrary(ctx context.Context, arg GetMed
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -5800,7 +5836,7 @@ func (q *Queries) GetNextRetryTime(ctx context.Context) (interface{}, error) {
}
const GetNotStartedItems = `-- name: GetNotStartedItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
AND NOT EXISTS (
SELECT 1 FROM reading_progress rp
@@ -5881,6 +5917,7 @@ func (q *Queries) GetNotStartedItems(ctx context.Context, arg GetNotStartedItems
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -6200,7 +6237,7 @@ func (q *Queries) GetReadingSpeed(ctx context.Context, arg GetReadingSpeedParams
}
const GetRecentlyAddedItems = `-- name: GetRecentlyAddedItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
LIMIT $2
@@ -6274,6 +6311,7 @@ func (q *Queries) GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAdde
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -6294,7 +6332,7 @@ func (q *Queries) GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAdde
}
const GetRecentlyReadItems = `-- name: GetRecentlyReadItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
INNER JOIN (
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
FROM reading_progress
@@ -6376,6 +6414,7 @@ func (q *Queries) GetRecentlyReadItems(ctx context.Context, arg GetRecentlyReadI
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -6496,7 +6535,7 @@ func (q *Queries) GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams
}
const GetSeriesBooks = `-- name: GetSeriesBooks :many
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
WHERE series = $1
ORDER BY series_number ASC NULLS LAST
`
@@ -6564,6 +6603,7 @@ func (q *Queries) GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]Med
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -8347,7 +8387,7 @@ func (q *Queries) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
}
const ListMediaItems = `-- name: ListMediaItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
@@ -8413,6 +8453,7 @@ type ListMediaItemsRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -8488,6 +8529,7 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams)
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -8510,7 +8552,7 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams)
}
const ListMediaItemsByLibrary = `-- name: ListMediaItemsByLibrary :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
@@ -8572,6 +8614,7 @@ type ListMediaItemsByLibraryRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -8647,6 +8690,7 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -8669,7 +8713,7 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.
}
const ListMediaItemsBySHA256AndLibrary = `-- name: ListMediaItemsBySHA256AndLibrary :many
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path
`
type ListMediaItemsBySHA256AndLibraryParams struct {
@@ -8741,6 +8785,7 @@ func (q *Queries) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg List
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -8761,7 +8806,7 @@ func (q *Queries) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg List
}
const ListMediaItemsMissingHash = `-- name: ListMediaItemsMissingHash :many
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at
`
// List media items that have no stored SHA-256 (imported before hashing existed)
@@ -8828,6 +8873,7 @@ func (q *Queries) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems,
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -8848,7 +8894,7 @@ func (q *Queries) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems,
}
const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
@@ -8983,6 +9029,7 @@ type ListMediaItemsSortedRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -9063,6 +9110,7 @@ func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSo
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -10186,7 +10234,7 @@ func (q *Queries) SearchLanguageValues(ctx context.Context, arg SearchLanguageVa
}
const SearchMediaItems = `-- name: SearchMediaItems :many
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
FROM media_items mi
JOIN libraries l ON mi.library_id = l.id
JOIN library_types lt ON l.library_type_id = lt.id
@@ -10274,6 +10322,7 @@ type SearchMediaItemsRow struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
@@ -10356,6 +10405,7 @@ func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsPara
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -11513,7 +11563,7 @@ SET
position = $4,
last_modified_at = NOW()
WHERE id = $1 AND user_id = $5
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source
`
type UpdateMediaBookmarkParams struct {
@@ -11553,6 +11603,7 @@ func (q *Queries) UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookma
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
@@ -11575,7 +11626,7 @@ UPDATE media_bookmarks SET
deleted = FALSE,
deleted_at = NULL
WHERE id = $1
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at
RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at, origin_source
`
type UpdateMediaBookmarkForSyncParams struct {
@@ -11631,6 +11682,7 @@ func (q *Queries) UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMedi
&i.ChapterReference,
&i.Deleted,
&i.DeletedAt,
&i.OriginSource,
)
return i, err
}
@@ -11825,9 +11877,10 @@ UPDATE media_items SET
alternate_info = $35,
scan_information = $36,
summary = $37,
metadata_overrides = $38,
updated_at = NOW()
WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
`
type UpdateMediaItemParams struct {
@@ -11868,6 +11921,7 @@ type UpdateMediaItemParams struct {
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
}
func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error) {
@@ -11909,6 +11963,7 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
arg.AlternateInfo,
arg.ScanInformation,
arg.Summary,
arg.MetadataOverrides,
)
var i MediaItems
err := row.Scan(
@@ -11965,6 +12020,7 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -11981,7 +12037,7 @@ const UpdateMediaItemChapterMetadata = `-- name: UpdateMediaItemChapterMetadata
UPDATE media_items
SET chapter_metadata = $2, updated_at = NOW()
WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
`
type UpdateMediaItemChapterMetadataParams struct {
@@ -12046,6 +12102,7 @@ func (q *Queries) UpdateMediaItemChapterMetadata(ctx context.Context, arg Update
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -12153,7 +12210,7 @@ SET
hash_confidence = $5,
updated_at = NOW()
WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
`
type UpdateMediaItemIdentifiersParams struct {
@@ -12232,6 +12289,7 @@ func (q *Queries) UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMedi
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
@@ -12252,7 +12310,7 @@ SET entitlement_id = $2,
kobo_metadata = $5,
updated_at = NOW()
WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, 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, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
`
type UpdateMediaItemKoboMetadataParams struct {
@@ -12326,6 +12384,7 @@ func (q *Queries) UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMed
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.TagsSearch,
+9 -2
View File
@@ -282,10 +282,17 @@ UPDATE media_items SET
alternate_info = $35,
scan_information = $36,
summary = $37,
metadata_overrides = $38,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: ClearMediaItemMetadataOverrides :exec
-- Reset an item to scanned defaults: clears per-field user overrides so the
-- next rescan can freely overwrite user-customized metadata.
UPDATE media_items SET metadata_overrides = '{}', updated_at = NOW()
WHERE id = $1;
-- name: DeleteMediaItem :exec
DELETE FROM media_items WHERE id = $1;
@@ -898,9 +905,9 @@ INSERT INTO media_bookmarks (
cfi_position, title, position, notes,
percentage_location, epubcfi_location, chapter_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
device_sync_data, origin_source
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
) RETURNING *;
-- name: UpdateMediaBookmarkForSync :one
+6
View File
@@ -77,6 +77,12 @@ func parseTableNames() ([]string, error) {
tables := make(map[string]bool)
for scanner.Scan() {
line := scanner.Text()
// Skip SQL comments: a doc line like "-- ... declared in CREATE
// TABLE above" would otherwise register a phantom table and fail
// startup verification.
if strings.HasPrefix(strings.TrimSpace(line), "--") {
continue
}
matches := pattern.FindStringSubmatch(line)
if len(matches) > 1 {
tableName := matches[1]
+28
View File
@@ -0,0 +1,28 @@
package database
import (
"regexp"
"testing"
)
// TestParseTableNamesIgnoresComments guards against phantom tables parsed out
// of SQL comments (e.g. "-- ... declared in CREATE TABLE above" once
// registered a table named "above" and crashed startup verification).
func TestParseTableNamesIgnoresComments(t *testing.T) {
tables, err := parseTableNames()
if err != nil {
t.Fatalf("parseTableNames() error: %v", err)
}
if len(tables) == 0 {
t.Fatal("parseTableNames() returned no tables")
}
for _, name := range tables {
// Every parsed table must correspond to a real CREATE TABLE statement
// at the start of a (non-comment) line.
stmt := regexp.MustCompile(`(?m)^CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?` + regexp.QuoteMeta(name) + `\s`)
if !stmt.MatchString(SchemaFile) {
t.Errorf("parseTableNames() returned phantom table %q with no matching CREATE TABLE statement", name)
}
}
}
+176 -113
View File
@@ -50,33 +50,97 @@ func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
h.annotationSvc = svc
}
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1, contextText string) (string, string) {
if pos0 == "" || h.libraryService == nil {
return "", ""
}
// annotationEpub carries the per-book context every locator conversion
// needs: the media item (format gating) and the resolved EPUB path. It is
// resolved once per request so all annotations in a push share one
// converter-cache entry instead of re-resolving (and re-parsing the book)
// per annotation.
type annotationEpub struct {
mediaItem *database.MediaItems
epubPath string
}
func (ec annotationEpub) convertible() bool {
return ec.mediaItem != nil && ec.epubPath != ""
}
func (h *KOReaderHandler) loadAnnotationEpub(ctx context.Context, mediaItemID pgtype.UUID) annotationEpub {
var ec annotationEpub
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
return ec
}
ec.mediaItem = &mediaItem
if h.libraryService != nil {
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
ec.epubPath = epubPath
}
}
return ec
}
// convertHighlightPositions resolves a device annotation's pos0/pos1
// locators to canonical CFIs through the shared facade. contextText is the
// selection's own text — a quote of the document, so the converter can
// verify structural landings against it and, when it must search, anchor a
// range end that spans block boundaries. percentage anchors the
// last-resort fallback. Only structural/exact landings are returned: a
// low-confidence conversion yields "" so an echo preserves the row's
// existing web CFIs (applyLWW coalesces empty) instead of clobbering them
// with a guess, and a new device highlight paints nowhere rather than in
// the wrong place.
func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos1, contextText string, percentage float64) (string, string) {
if pos0 == "" || !ec.convertible() {
return "", ""
}
epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
if err != nil || epubPath == "" {
return "", ""
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
startCFI := webUsableCFI(startLoc)
endCFI := ""
// A text-search start matched the selection text itself: its extent
// is the selection's true end, even across blocks.
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = startLoc.EndCFI
}
// The annotation's own text is the ideal anchor for the converter's
// text-search path: clients (thin, underpowered) send only raw
// locators, the server resolves them against the actual book.
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, contextText, mediaItem.FormatGroup, epubPath, "")
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
endCFI := endLoc.CFI
// The end conversion carries no context text, so unless it resolved
// exactly it degenerates to a percentage fallback anchored at the
// document start — useless as a range end. When the START resolved
// exactly, derive the end from it: same node, character offset
// advanced by the selection's UTF-16 length (the CFI offset unit).
if endLoc.Precision != "exact" && startLoc.Precision == "exact" && contextText != "" {
endCFI = extendCFIByLength(startLoc.CFI, contextText)
if endCFI == "" && pos1 != "" {
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
endCFI = webUsableCFI(endLoc)
}
return startLoc.CFI, endCFI
// The end conversion carries no context text; when neither resolved
// confidently, derive the end from the start advanced by the
// selection's UTF-16 length (the CFI offset unit). Multi-node
// selections produce an out-of-range offset — harmless: resolution
// clamps or fails, and consumers fall back to the start.
if endCFI == "" && startCFI != "" && contextText != "" {
endCFI = extendCFIByLength(startCFI, contextText)
}
return startCFI, endCFI
}
// convertBookmarkPosition resolves a device bookmark's locator to the
// canonical CFI for the web reader. Structural-only by design: bookmark
// text is a display label ("in <chapter>" auto-fill or a user note), never
// book text, so no context is supplied and only a structural/exact landing
// is trusted — lower rungs would store a confident-looking guess the web
// drawer would present as a real destination.
func (h *KOReaderHandler) convertBookmarkPosition(ec annotationEpub, position string, percentage float64) string {
if position == "" || !ec.convertible() || !wsync.IsCREXPointer(position) {
return ""
}
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, position, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
return webUsableCFI(loc)
}
// webUsableCFI keeps only high-confidence conversions: the web reader
// navigates bookmarks by CFI, so href/percentage/fallback results are
// discarded instead of stored as dead links.
func webUsableCFI(loc wsync.CanonicalLocator) string {
if loc.Precision != "structural" && loc.Precision != "exact" {
return ""
}
if !strings.HasPrefix(loc.CFI, "epubcfi(") || !strings.HasSuffix(loc.CFI, ")") {
return ""
}
return loc.CFI
}
// extendCFIByLength advances a point CFI's trailing character offset by the
@@ -127,22 +191,19 @@ func (h *KOReaderHandler) existingHighlightColor(ctx context.Context, mediaItemI
// deriveAnnotationPercentage computes a percentage for device-pushed
// annotations when the client didn't send one (thin clients skip their own
// per-annotation page lookups; arithmetic is only free on paging documents).
func (h *KOReaderHandler) deriveAnnotationPercentage(ctx context.Context, mediaItemID pgtype.UUID, pos0 string, page int) float64 {
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
func (h *KOReaderHandler) deriveAnnotationPercentage(ec annotationEpub, pos0 string, page int) float64 {
if ec.mediaItem == nil {
return 0
}
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
formatGroup := wsync.FormatGroup(ec.mediaItem.FormatGroup)
if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive {
if page > 0 && mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
return float64(page) / float64(mediaItem.PageCount.Int32)
if page > 0 && ec.mediaItem.PageCount.Valid && ec.mediaItem.PageCount.Int32 > 0 {
return float64(page) / float64(ec.mediaItem.PageCount.Int32)
}
return 0
}
if wsync.IsCREXPointer(pos0) && h.libraryService != nil {
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
return wsync.NewCFIConverter(epubPath).SectionPercentage(pos0)
}
if wsync.IsCREXPointer(pos0) && ec.epubPath != "" {
return wsync.SectionPercentageCached(ec.epubPath, pos0)
}
return 0
}
@@ -614,21 +675,23 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
if h.annotationSvc == nil {
return
}
ec := h.loadAnnotationEpub(ctx, mediaItemID)
for _, hl := range book.Highlights {
startPos := hl.Pos0
endPos := hl.Pos1
// The highlight's own text anchors the conversion exactly.
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, hl.Text)
pctStart := 0.0
if hl.Percentage != nil {
pctStart = *hl.Percentage
}
if pctStart == 0 {
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(hl.Page))
pctStart = h.deriveAnnotationPercentage(ec, startPos, int(hl.Page))
}
// The highlight's own text anchors the conversion exactly.
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, startPos, endPos, hl.Text, pctStart)
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": hl.Datetime,
"pos0": hl.Pos0,
@@ -676,16 +739,17 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
for _, note := range book.Notes {
startPos := note.Pos0
endPos := note.Pos1
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, note.Text)
pctStart := 0.0
if note.Percentage != nil {
pctStart = *note.Percentage
}
if pctStart == 0 {
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(note.Page))
pctStart = h.deriveAnnotationPercentage(ec, startPos, int(note.Page))
}
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, startPos, endPos, note.Text, pctStart)
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": note.Datetime,
"pos0": note.Pos0,
@@ -723,6 +787,19 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
position = fmt.Sprintf("page:%d", bookmark.Page)
}
pctLoc := 0.0
if bookmark.Percentage != nil {
pctLoc = *bookmark.Percentage
}
if pctLoc == 0 {
pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page))
}
// Device-native xpointer → canonical CFI so the web drawer can
// navigate KOReader-created bookmarks (page-only positions have no
// convertible locator; the drawer's page fallback covers those).
cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc)
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": bookmark.Datetime,
"pos0": bookmark.Pos0,
@@ -739,8 +816,11 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
UserID: userID,
Title: bookmark.Text,
Position: position,
CFIPosition: cfiPosition,
PercentageLoc: pctLoc,
ChapterNumber: int32(bookmark.Chapter),
Source: "koreader",
OriginSource: "koreader",
DeviceSyncData: deviceData,
DedupKey: dedupKey,
})
@@ -783,53 +863,40 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
if h.progressSvc != nil {
epubcfi := book.Epubcfi
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi)
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err)
} else if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) ||
mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
// Image-based fixed content (fixed-layout comic EPUBs, PDF,
// comic archives) has no extractable text, so CRE→CFI conversion
// cannot succeed. The page index (page/total_pages) is the
// canonical locator. Keep the incoming xpointer for device-native
// restore; the web reader restores by page.
log.Printf("Bookhoard: CRE→CFI skipped for %s format", mediaItem.FormatGroup)
} else if h.libraryService == nil {
log.Printf("Bookhoard: CRE→CFI libraryService is nil, skipping conversion")
} else {
epubPath, resolveErr := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
if resolveErr != nil {
log.Printf("Bookhoard: CRE→CFI failed to resolve media path: %v", resolveErr)
} else if epubPath == "" {
log.Printf("Bookhoard: CRE→CFI resolved empty epub path for %s", mediaItem.FilePath)
} else {
log.Printf("Bookhoard: CRE→CFI resolved epub path: %s", epubPath)
converter := wsync.NewCFIConverter(epubPath)
pct := 0.0
if book.Percentage >= 0 {
pct = book.Percentage
}
contextText := ""
if book.ContextText != nil {
contextText = *book.ContextText
}
result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText)
if convErr != nil {
log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr)
} else if result != nil {
if result.EPUBCFI != "" {
convertedCFI := result.EPUBCFI
epubcfi = &convertedCFI
log.Printf("Bookhoard: CRE→CFI converted to epubcfi: %s", convertedCFI)
} else if result.Href != "" {
convertedHref := result.Href
epubcfi = &convertedHref
log.Printf("Bookhoard: CRE→CFI converted to href: %s", convertedHref)
} else {
log.Printf("Bookhoard: CRE→CFI conversion: %s precision for %s", result.Precision, *epubcfi)
}
}
// Same facade every annotation uses: cached converter,
// structural-first resolution, guarded fallbacks. The facade
// passes non-reflowable formats through untouched.
ec := h.loadAnnotationEpub(ctx, mediaItemID)
if ec.convertible() {
pct := 0.0
if book.Percentage >= 0 {
pct = book.Percentage
}
contextText := ""
if book.ContextText != nil {
contextText = *book.ContextText
}
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, *epubcfi, pct, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
switch {
case strings.HasPrefix(loc.CFI, "epubcfi(") &&
(loc.Precision == "structural" || loc.Precision == "exact"):
converted := loc.CFI
epubcfi = &converted
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to %s", loc.Precision, converted)
case loc.CFI != "" && loc.CFI != *epubcfi &&
(loc.Precision == "element" || loc.Precision == "section"):
// Fragment-ID positions resolve to a section href: keep
// serving it (legacy behavior).
converted := loc.CFI
epubcfi = &converted
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to href %s", loc.Precision, converted)
default:
// Low-confidence (percentage/fallback): store no
// canonical locator — the row's percentage restores
// approximately instead of a confidently-wrong CFI,
// and the device keeps its own native position.
log.Printf("Bookhoard: CRE→CFI conversion low-confidence (%s) for %s; storing percentage only", loc.Precision, *epubcfi)
epubcfi = nil
}
}
}
@@ -1168,40 +1235,20 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
}
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
if h.libraryService == nil {
log.Printf("Bookhoard: CFI→CRE libraryService is nil, skipping reverse conversion")
return
}
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
log.Printf("Bookhoard: CFI→CRE failed to resolve media path: %v", err)
return
}
if epubPath == "" {
log.Printf("Bookhoard: CFI→CRE resolved empty epub path for %s", mediaItem.FilePath)
return
}
converter := wsync.NewCFIConverter(epubPath)
contextText := ""
if progress.ContextText.Valid {
contextText = progress.ContextText.String
}
pct := progress.Percentage.Float64
result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText)
if err != nil {
log.Printf("Bookhoard: CFI→CRE conversion error: %v", err)
return
}
if result != nil && result.XPointer != "" {
progressData.KoreaderXPointer = &result.XPointer
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer)
// Same facade path annotations use on serve: structural resolution
// first, guarded text search only as fallback, cached converter. The
// stored percentage anchors the reverse fallback ladder.
if xp := h.reverseConvertCFI(c, mediaItem, progress.Epubcfi.String, contextText, progress.Percentage.Float64); xp != "" {
progressData.KoreaderXPointer = &xp
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", xp)
}
}
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string) string {
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string, percentage float64) string {
if h.libraryService == nil || epubcfi == "" {
return ""
}
@@ -1209,7 +1256,7 @@ func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.
if err != nil || epubPath == "" {
return ""
}
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, contextText, mediaItem.FormatGroup, epubPath, "")
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, percentage, contextText, mediaItem.FormatGroup, epubPath, "")
if loc.Position != "" && loc.Position != epubcfi {
return loc.Position
}
@@ -1316,7 +1363,7 @@ func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.Media
cfi = strings.TrimPrefix(startPosition, "cfi:")
}
if cfi != "" && wsync.IsStandardEPUBCFI(cfi) {
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText); converted != "" {
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText, 0); converted != "" {
return converted
}
// Conversion failed; fall through so numeric positions still work.
@@ -1482,6 +1529,17 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
}
if h.annotationSvc != nil {
ec := h.loadAnnotationEpub(ctx, mediaItemID)
pctLoc := 0.0
if bookmark.Percentage != nil {
pctLoc = *bookmark.Percentage
}
if pctLoc == 0 {
pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page))
}
cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc)
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": bookmark.Datetime,
"pos0": bookmark.Pos0,
@@ -1493,8 +1551,11 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
UserID: pgUserID,
Title: bookmark.Text,
Position: position,
CFIPosition: cfiPosition,
PercentageLoc: pctLoc,
ChapterNumber: int32(bookmark.Chapter),
Source: "koreader",
OriginSource: "koreader",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
@@ -1588,13 +1649,15 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
}
if h.annotationSvc != nil {
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1, highlight.Text)
ec := h.loadAnnotationEpub(ctx, mediaItemID)
pctStart := 0.0
if highlight.Percentage != nil {
pctStart = *highlight.Percentage
}
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, highlight.Pos0, highlight.Pos1, highlight.Text, pctStart)
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": highlight.Datetime,
"pos0": highlight.Pos0,
+52
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"testing"
wsync "bookhoard/internal/sync"
"github.com/stretchr/testify/assert"
)
@@ -50,3 +52,53 @@ func TestKOReaderProgressRequest_DeletedAnnotationsOmitted(t *testing.T) {
assert.Empty(t, req.Books[0].DeletedHighlights)
assert.Empty(t, req.Books[0].DeletedBookmarks)
}
// The web drawer navigates bookmarks by CFI, so only high-confidence
// conversions may be stored: href/percentage/fallback results would
// become dead links. This is the sole gate for KOReader→web bookmark
// positions (bookmark text is a label, never book text, so the
// conversion runs structural-only with empty context).
func TestWebUsableCFI(t *testing.T) {
cases := []struct {
name string
loc wsync.CanonicalLocator
expected string
}{
{
name: "structural landing stored",
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/28/2/1:0)", Precision: "structural", Percentage: 0.52},
expected: "epubcfi(/6/52!/4/28/2/1:0)",
},
{
name: "exact text-search landing stored",
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/28/2/1:0)", Precision: "exact", Percentage: 0.52},
expected: "epubcfi(/6/52!/4/28/2/1:0)",
},
{
name: "percentage guess discarded",
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/2/1:0)", Precision: "percentage", Percentage: 0.52},
expected: "",
},
{
name: "fallback passthrough (raw xpointer) discarded",
loc: wsync.CanonicalLocator{CFI: "/body/DocFragment[2]/body/p[3]", Precision: "fallback", Percentage: 0.52},
expected: "",
},
{
name: "section href discarded (not a CFI)",
loc: wsync.CanonicalLocator{CFI: "ch10.xhtml", Precision: "section", Percentage: 0.52},
expected: "",
},
{
name: "structural but non-CFI value discarded",
loc: wsync.CanonicalLocator{CFI: "ch10.xhtml#h1", Precision: "structural", Percentage: 0.52},
expected: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, webUsableCFI(tc.loc))
})
}
}
+138 -54
View File
@@ -63,41 +63,41 @@ type CreateMediaItemRequest struct {
// UpdateMediaItemRequest represents the request for updating a media item
type UpdateMediaItemRequest struct {
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
Author string `form:"author" json:"author"`
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"`
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"`
Contributors []string `form:"contributors" json:"contributors"`
Language string `form:"language" json:"language"`
Edition string `form:"edition" json:"edition"`
PageCount int32 `form:"page_count" json:"page_count"`
Genre string `form:"genre" json:"genre"`
CopyrightYear int32 `form:"copyright_year" json:"copyright_year"`
GoodreadsID string `form:"goodreads_id" json:"goodreads_id"`
OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"`
GoogleBooksID string `form:"google_books_id" json:"google_books_id"`
MangaType string `form:"manga_type" json:"manga_type"`
ReadingDirection string `form:"reading_direction" json:"reading_direction"`
SeriesCount int32 `form:"series_count" json:"series_count"`
Volume int32 `form:"volume" json:"volume"`
Imprint string `form:"imprint" json:"imprint"`
AgeRating string `form:"age_rating" json:"age_rating"`
WebURL string `form:"web_url" json:"web_url"`
MetadataNotes string `form:"metadata_notes" json:"metadata_notes"`
CommunityRating float64 `form:"community_rating" json:"community_rating"`
StoryArc string `form:"story_arc" json:"story_arc"`
IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"`
AlternateInfo string `form:"alternate_info" json:"alternate_info"`
ScanInformation string `form:"scan_information" json:"scan_information"`
Summary string `form:"summary" json:"summary"`
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
Author string `form:"author" json:"author"`
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"`
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"`
Contributors []string `form:"contributors" json:"contributors"`
Language string `form:"language" json:"language"`
Edition string `form:"edition" json:"edition"`
PageCount int32 `form:"page_count" json:"page_count"`
Genre string `form:"genre" json:"genre"`
CopyrightYear int32 `form:"copyright_year" json:"copyright_year"`
GoodreadsID string `form:"goodreads_id" json:"goodreads_id"`
OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"`
GoogleBooksID string `form:"google_books_id" json:"google_books_id"`
MangaType string `form:"manga_type" json:"manga_type"`
ReadingDirection string `form:"reading_direction" json:"reading_direction"`
SeriesCount int32 `form:"series_count" json:"series_count"`
Volume int32 `form:"volume" json:"volume"`
Imprint string `form:"imprint" json:"imprint"`
AgeRating string `form:"age_rating" json:"age_rating"`
WebURL string `form:"web_url" json:"web_url"`
MetadataNotes string `form:"metadata_notes" json:"metadata_notes"`
CommunityRating float64 `form:"community_rating" json:"community_rating"`
StoryArc string `form:"story_arc" json:"story_arc"`
IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"`
AlternateInfo string `form:"alternate_info" json:"alternate_info"`
ScanInformation string `form:"scan_information" json:"scan_information"`
Summary string `form:"summary" json:"summary"`
}
// CreateMediaNoteRequest represents the request for creating a media note
@@ -152,6 +152,9 @@ type CreateMediaBookmarkRequest struct {
ChapterNumber int32 `json:"chapter_number"`
Percentage float64 `json:"percentage"`
ChapterReference int32 `json:"chapter_reference"`
// Origin labels the creating client for display ("android", "web").
// Optional: defaults to "web" for browser callers.
Origin string `json:"origin" validate:"omitempty,max=30"`
}
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
@@ -569,24 +572,33 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
Summary: existingMedia.Summary,
}
// Bulk edits are user customizations: keep existing overrides and mark
// each applied update as overridden so scans preserve it.
overrides := existingMedia.MetadataOverrides
if update.Updates.Title != nil {
updateParams.Title = *update.Updates.Title
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
}
if update.Updates.Author != nil {
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
}
if update.Updates.Genre != nil {
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
overrides = utils.MergeOverrides(overrides, utils.OverrideGenre)
}
if update.Updates.Language != nil {
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
overrides = utils.MergeOverrides(overrides, utils.OverrideLanguage)
}
if len(update.Updates.Tags) > 0 {
normalizedTags := utils.NormalizeTags(update.Updates.Tags)
updateParams.Tags = normalizedTags
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
updateParams.TagsSearch = tagsSearch
overrides = utils.MergeOverrides(overrides, utils.OverrideTags)
}
updateParams.MetadataOverrides = overrides
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
if err != nil {
@@ -922,22 +934,22 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
}
resp := map[string]interface{}{
"id": progress.ID,
"media_item_id": progress.MediaItemID,
"user_id": progress.UserID,
"current_page": progress.CurrentPage,
"total_pages": progress.TotalPages,
"last_read_at": progress.LastReadAt,
"percentage": progress.Percentage,
"character_offset": progress.CharacterOffset,
"epubcfi": progress.Epubcfi,
"chapter": progress.Chapter,
"chapter_progress": progress.ChapterProgress,
"format_group": progress.FormatGroup,
"total_characters": progress.TotalCharacters,
"chapter_count": progress.ChapterCount,
"last_sync_device": progress.LastSyncDevice,
"last_sync_source": progress.LastSyncSource,
"id": progress.ID,
"media_item_id": progress.MediaItemID,
"user_id": progress.UserID,
"current_page": progress.CurrentPage,
"total_pages": progress.TotalPages,
"last_read_at": progress.LastReadAt,
"percentage": progress.Percentage,
"character_offset": progress.CharacterOffset,
"epubcfi": progress.Epubcfi,
"chapter": progress.Chapter,
"chapter_progress": progress.ChapterProgress,
"format_group": progress.FormatGroup,
"total_characters": progress.TotalCharacters,
"chapter_count": progress.ChapterCount,
"last_sync_device": progress.LastSyncDevice,
"last_sync_source": progress.LastSyncSource,
"last_sync_timestamp": progress.LastSyncTimestamp,
}
@@ -1237,6 +1249,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
}
coverPath := existing.CoverImagePath.String
coverUploaded := false
if req.CoverAction == "remove" {
coverPath = ""
@@ -1248,6 +1261,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
}
coverPath = savedPath
coverUploaded = true
}
}
@@ -1256,7 +1270,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
alternateInfoBytes = []byte(req.AlternateInfo)
}
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
params := database.UpdateMediaItemParams{
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Title: req.Title,
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
@@ -1294,7 +1308,17 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
AlternateInfo: alternateInfoBytes,
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
})
}
// 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)
if req.CoverAction == "remove" || coverUploaded {
overrides = utils.MergeOverrides(overrides, utils.OverrideCoverImagePath)
}
params.MetadataOverrides = overrides
item, err := mh.db.UpdateMediaItem(c.Request().Context(), params)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
@@ -1306,6 +1330,46 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
return c.JSON(http.StatusOK, item)
}
// RescanMediaItem handles POST /api/media-items/:id/rescan (admin only)
func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
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"})
}
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
scanner := services.NewMediaScanner(mh.db)
defer scanner.Close()
// reset_overrides=true discards user customizations first, returning the
// item to pure scanned defaults (the "Reset to Scanned" action).
resetOverrides := c.QueryParam("reset_overrides") == "true"
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, resetOverrides); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, item)
}
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
@@ -1653,6 +1717,11 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
ChapterReference: req.ChapterReference,
Source: "web",
ModifiedAt: time.Now(),
// The PUT targets this exact row (from the URL): identity must
// not be re-derived from content — a device echo has usually
// rewritten the stored CFI to point shape, so the computed key
// would miss and mint a duplicate beside the edited row.
HighlightID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1750,6 +1819,10 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back
// to the plain query when the service isn't wired (e.g. some tests).
if mh.annotationSvc != nil {
origin := req.Origin
if origin == "" {
origin = "web"
}
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
@@ -1762,6 +1835,7 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
PercentageLoc: req.Percentage,
ChapterReference: req.ChapterReference,
Source: "web",
OriginSource: origin,
ModifiedAt: time.Now(),
})
if err != nil {
@@ -2175,7 +2249,17 @@ func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file
return "", fmt.Errorf("media item has no file path")
}
coverRelPath := relativeFilePath + ".cover.jpg"
// Custom covers live at a dedicated sidecar path (distinct from the
// scanner-generated {file}.cover.jpg) so scans can never overwrite a
// user-uploaded cover and the metadata_overrides set can protect it.
ext := ".jpg"
switch contentType {
case "image/png":
ext = ".png"
case "image/webp":
ext = ".webp"
}
coverRelPath := relativeFilePath + ".custom_cover" + ext
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
if err != nil {
+4
View File
@@ -7,6 +7,10 @@ import "bookhoard/internal/database"
type MediaDetail struct {
database.MediaItems // Embedded - ALL book fields available
// Absolute on-disk location (library folder + relative path), resolved by
// the page handler so the UI can show where the file lives.
FileLocation string `json:"file_location"`
// User-specific data
Rating *database.MediaRatings `json:"rating,omitempty"`
Collections []database.Collections `json:"collections"`
+28 -8
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
@@ -984,12 +986,12 @@ func registerFrontendRoutes(cfg *Config) {
}
totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount
conflict.Items = append(conflict.Items, templates.HashConflictItemData{
ID: uuid.UUID(mi.ID.Bytes).String(),
Title: mi.Title,
Author: mi.Author.String,
FilePath: mi.FilePath,
FileSize: mi.FileSize.Int64,
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
ID: uuid.UUID(mi.ID.Bytes).String(),
Title: mi.Title,
Author: mi.Author.String,
FilePath: mi.FilePath,
FileSize: mi.FileSize.Int64,
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
HasReadingData: totalData > 0,
})
@@ -1090,9 +1092,9 @@ func registerFrontendRoutes(cfg *Config) {
// already have their own dedicated UI cards (timezone dropdown, scan
// settings) so they aren't listed twice.
dedicatedUI := map[string]bool{
"default_timezone": true,
"default_timezone": true,
"scan_poll_interval_seconds": true,
"auto_scan_enabled": true,
"auto_scan_enabled": true,
}
var tunableSettings []templates.SettingEntry
if cfg.Settings != nil {
@@ -1267,6 +1269,23 @@ func registerFrontendRoutes(cfg *Config) {
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
}
// Resolve the on-disk location (library folder + relative path) so the
// detail page can show where the file lives, even for sparse metadata.
// Admin-only: everyday users never receive the absolute path.
fileLocation := ""
if user.Role == "admin" {
fileLocation = mediaItem.FilePath
if folders, ferr := cfg.Queries.GetLibraryFolders(c.Request().Context(), mediaItem.LibraryID); ferr == nil {
for _, folder := range folders {
candidate := filepath.Join(folder.FolderPath, mediaItem.FilePath)
if _, serr := os.Stat(candidate); serr == nil {
fileLocation = candidate
break
}
}
}
}
// Fetch rating
var rating *database.MediaRatings
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
@@ -1330,6 +1349,7 @@ func registerFrontendRoutes(cfg *Config) {
// Assemble response (no field duplication!)
detail := handlers.MediaDetail{
MediaItems: mediaItem, // Embedded - ALL fields available
FileLocation: fileLocation,
Rating: rating,
Collections: collections,
ReadingProgress: progress,
+1
View File
@@ -60,6 +60,7 @@ func registerMediaRoutes(cfg *Config) {
// Admin-only media routes
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
// Shelf management (protected)
+6 -69
View File
@@ -1,18 +1,15 @@
package router
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"bookhoard/internal/services"
"bookhoard/internal/sync"
"bookhoard/internal/utils"
"bookhoard/templates"
"bytes"
"errors"
"net/http"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
@@ -69,21 +66,10 @@ func registerReaderRoutes(cfg *Config) {
if !visible {
return renderErrorPage(c, "Access denied", "access_denied")
}
// Get reading progress
var progress database.ReadingProgress
progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
UserID: uuidToPGType(userUUID),
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
progress = database.ReadingProgress{}
}
// Get bookmarks
bookmarks, _ := cfg.Queries.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
UserID: uuidToPGType(userUUID),
})
// Convert to template types
// Convert to template types. Reading state is deliberately NOT
// fetched or embedded: the reader pulls position, bookmarks, and
// annotations from the APIs at open time so the page can never
// carry (nor write back) a stale snapshot.
mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.Bytes[0:16])
metadata := templates.ReaderMetadata{
@@ -104,58 +90,9 @@ func registerReaderRoutes(cfg *Config) {
TotalCharacters: mediaItem.TotalCharacters.Int64,
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
}
// Progress conversion (inline)
progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16])
progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16])
progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16])
templateProgress := templates.ReadingProgress{
ID: progressUUID.String(),
MediaItemID: progressMediaUUID.String(),
UserID: progressUserUUID.String(),
CurrentPage: int(progress.CurrentPage.Int32),
TotalPages: int(progress.TotalPages.Int32),
Percentage: progress.Percentage.Float64 * 100,
EpubCfi: textToString(progress.Epubcfi),
LastReadAt: progress.LastReadAt.Time,
Chapter: int(progress.Chapter.Int32),
ChapterProgress: progress.ChapterProgress.Float64 * 100,
FormatGroup: mediaItem.FormatGroup,
}
// Bookmarks conversion (inline, with loop)
templateBookmarks := make([]templates.Bookmark, len(bookmarks))
for i, b := range bookmarks {
bookmarkUUID, _ := uuid.FromBytes(b.ID.Bytes[0:16])
bookmarkMediaUUID, _ := uuid.FromBytes(b.MediaItemID.Bytes[0:16])
bookmarkUserUUID, _ := uuid.FromBytes(b.UserID.Bytes[0:16])
var pageNumber *int
if b.PageNumber.Valid {
val := int(b.PageNumber.Int32)
pageNumber = &val
}
var chapterNumber *int
if b.ChapterNumber.Valid {
val := int(b.ChapterNumber.Int32)
chapterNumber = &val
}
templateBookmarks[i] = templates.Bookmark{
ID: bookmarkUUID.String(),
MediaItemID: bookmarkMediaUUID.String(),
UserID: bookmarkUserUUID.String(),
PageNumber: pageNumber,
ChapterNumber: chapterNumber,
CfiPosition: textToString(b.CfiPosition),
Title: b.Title,
Position: textToString(b.Position),
Notes: textToString(b.Notes),
CreatedAt: b.CreatedAt.Time,
}
}
// 8. Render template
// Render template
var buf bytes.Buffer
err = templates.Reader(user, metadata, templateProgress, templateBookmarks).Render(c.Request().Context(), &buf)
err = templates.Reader(user, metadata).Render(c.Request().Context(), &buf)
if err != nil {
return renderErrorPage(c, "Error rendering reader", "render_error")
}
+164 -58
View File
@@ -23,6 +23,7 @@ import (
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
@@ -156,18 +157,18 @@ type CalibreOPFMetadata struct {
// never closed.
func NewMediaScanner(db *database.Queries) *MediaScanner {
return &MediaScanner{
db: db,
watcher: nil,
settingsCache: NewSettingsCache(30 * time.Second),
dirtyDirs: make(map[string]time.Time),
fileStability: make(map[string]*atomic.Bool),
watching: atomic.Bool{},
scanInProgress: atomic.Bool{},
folders: []string{},
adminID: pgtype.UUID{},
db: db,
watcher: nil,
settingsCache: NewSettingsCache(30 * time.Second),
dirtyDirs: make(map[string]time.Time),
fileStability: make(map[string]*atomic.Bool),
watching: atomic.Bool{},
scanInProgress: atomic.Bool{},
folders: []string{},
adminID: pgtype.UUID{},
defaultLibraryID: pgtype.UUID{Valid: false},
libraryTypes: make(map[string][]string),
logger: NewScannerLogger(),
libraryTypes: make(map[string][]string),
logger: NewScannerLogger(),
}
}
@@ -696,7 +697,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
if s.forceRescan {
fmt.Printf("Force rescan enabled, updating existing media item: %s\n", path)
// Use UPDATE instead of DELETE+INSERT to preserve created_at
if err := s.updateMediaItem(ctx, existingItem.ID, path, info); err != nil {
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
}
// Recompute hash identifiers too - a force rescan is the admin's
@@ -707,7 +708,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
// Normal behavior: check if file has changed (by size)
if existingItem.FileSize.Int64 != info.Size() {
fmt.Printf("File size changed, updating media item: %s\n", path)
_ = s.updateMediaItem(ctx, existingItem.ID, path, info)
_ = s.updateMediaItem(ctx, existingItem, path)
// The bytes changed, so any stored hash is stale.
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
return false, nil
@@ -757,7 +758,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n",
existingByHash.FilePath, path)
if s.forceRescan {
_ = s.updateMediaItem(ctx, existingByHash.ID, path, info)
_ = s.updateMediaItem(ctx, existingByHash, path)
}
return false, nil
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
@@ -2078,56 +2079,98 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
// Use pdfcpu API to extract images from first page
// ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error
err = pdfcpuapi.ExtractImagesFile(pdfPath, tmpDir, []string{"1"}, nil)
if err == nil {
// Check for extracted images in the temp directory
entries, err := os.ReadDir(tmpDir)
if err == nil && len(entries) > 0 {
// Find the largest image (likely the cover)
var largestImage string
var largestSize int64
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
// Skip very small files (likely thumbnails or icons)
if info.Size() < 1000 {
continue
}
if info.Size() > largestSize {
largestImage = filepath.Join(tmpDir, entry.Name())
largestSize = info.Size()
}
}
if largestImage != "" {
// Read the image
imageData, err := os.ReadFile(largestImage)
if err == nil && len(imageData) > 0 {
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
coverPath := pdfPath + ".cover.jpg"
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover file: %v", err)
}
return coverPath, nil
}
}
}
}
// No embedded raster cover found (e.g. vector/text first page) - fall back
// to rendering the first page with pdftoppm (poppler-utils).
return s.renderPDFCoverPage(pdfPath), nil
}
// renderPDFCoverPage renders the first page of a PDF file to a JPEG image
// using pdftoppm. It saves the cover next to the PDF ({pdf_path}.cover.jpg).
// Returns the path to the saved cover, or empty string if rendering failed.
func (s *MediaScanner) renderPDFCoverPage(pdfPath string) string {
if _, err := exec.LookPath("pdftoppm"); err != nil {
fmt.Printf("Warning: pdftoppm not available, skipping PDF cover render for %s\n", pdfPath)
return ""
}
tmpDir, err := os.MkdirTemp("", "pdf-render-")
if err != nil {
// No images found or extraction failed - this is OK, just return empty
return "", nil
fmt.Printf("Warning: failed to create temp dir for PDF cover render %s: %v\n", pdfPath, err)
return ""
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
fmt.Printf("Warning: failed to remove temp directory %s: %v\n", tmpDir, err)
}
}()
outPrefix := filepath.Join(tmpDir, "cover")
// -cropbox renders the CropBox (the viewer-visible region, matching pdf.js)
// rather than the MediaBox; poppler falls back to the MediaBox when no
// CropBox is defined. This matters for PDFs whose page 1 is a full print
// cover wrap (back + spine + front) with a CropBox covering just the front.
cmd := exec.Command("pdftoppm", "-jpeg", "-f", "1", "-l", "1", "-singlefile", "-cropbox", "-r", "150", pdfPath, outPrefix)
if output, err := cmd.CombinedOutput(); err != nil {
fmt.Printf("Warning: failed to render PDF cover from %s: %v, output: %s\n", pdfPath, err, string(output))
return ""
}
// Check for extracted images in the temp directory
entries, err := os.ReadDir(tmpDir)
if err != nil || len(entries) == 0 {
return "", nil
}
// Find the largest image (likely the cover)
var largestImage string
var largestSize int64
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
// Skip very small files (likely thumbnails or icons)
if info.Size() < 1000 {
continue
}
if info.Size() > largestSize {
largestImage = filepath.Join(tmpDir, entry.Name())
largestSize = info.Size()
}
}
if largestImage == "" {
return "", nil
}
// Read the image
imageData, err := os.ReadFile(largestImage)
if err != nil || len(imageData) == 0 {
return "", nil
imageData, err := os.ReadFile(outPrefix + ".jpg")
if err != nil || len(imageData) < 1000 {
fmt.Printf("Warning: PDF cover render produced no usable image for %s\n", pdfPath)
return ""
}
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
coverPath := pdfPath + ".cover.jpg"
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
return "", fmt.Errorf("failed to write cover file: %v", err)
fmt.Printf("Warning: failed to write rendered PDF cover for %s: %v\n", pdfPath, err)
return ""
}
return coverPath, nil
return coverPath
}
// ComicInfo represents metadata from ComicInfo.xml
@@ -2562,7 +2605,11 @@ func countArchiveImages(filePath string) (int, error) {
return count, nil
}
func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.UUID, path string, _ os.FileInfo) error {
// updateMediaItem re-extracts metadata for an existing media item and writes
// it back. Fields listed in the item's metadata_overrides set are preserved
// from the existing row so rescans never clobber user customizations; only
// reset-to-scanned-defaults (RescanMediaItem with reset) clears them.
func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.MediaItems, path string) error {
// Re-extract metadata for the update
metadata, err := s.extractMetadata(path)
if err != nil {
@@ -2581,8 +2628,8 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
if metadata.AlternateInfo != "" {
alternateInfoBytes = []byte(metadata.AlternateInfo)
}
_, err = s.db.UpdateMediaItem(ctx, database.UpdateMediaItemParams{
ID: mediaItemID,
params := database.UpdateMediaItemParams{
ID: existing.ID,
Title: metadata.Title,
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
@@ -2614,10 +2661,69 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
AlternateInfo: alternateInfoBytes,
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
})
}
// Keep user-customized fields, and keep the override set itself intact.
utils.ApplyMetadataOverrides(&params, existing)
params.MetadataOverrides = existing.MetadataOverrides
_, err = s.db.UpdateMediaItem(ctx, params)
return err
}
// RescanMediaItem re-extracts metadata for a single media item and updates it.
// It is the per-book rescan used by the Edit Metadata dialog and backfills
// covers for items imported before the PDF render fallback existed.
// With resetOverrides, user customizations are discarded first: the item is
// returned to pure scanned defaults (the "Reset to Scanned" action).
func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.UUID, resetOverrides bool) error {
item, err := s.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
return fmt.Errorf("media item not found: %w", err)
}
if resetOverrides {
if err := s.db.ClearMediaItemMetadataOverrides(ctx, mediaItemID); err != nil {
return fmt.Errorf("failed to clear metadata overrides: %w", err)
}
item.MetadataOverrides = nil
}
folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID)
if err != nil || len(folders) == 0 {
return fmt.Errorf("no library folders found for library")
}
folderPaths := make([]string, 0, len(folders))
for _, folder := range folders {
folderPaths = append(folderPaths, folder.FolderPath)
}
s.folders = folderPaths
var fullPath string
for _, folder := range folders {
candidate := filepath.Join(folder.FolderPath, item.FilePath)
if _, err := os.Stat(candidate); err == nil {
fullPath = candidate
break
}
}
if fullPath == "" {
return fmt.Errorf("media file not found on disk: %s", item.FilePath)
}
if _, err := os.Stat(fullPath); err != nil {
return fmt.Errorf("failed to stat media file: %w", err)
}
if err := s.updateMediaItem(ctx, item, fullPath); err != nil {
return fmt.Errorf("failed to update media item: %w", err)
}
s.recomputeHashInfo(ctx, mediaItemID, item.LibraryID, fullPath)
return nil
}
func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) {
return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
FilePath: s.getRelativePath(filePath),
+119 -26
View File
@@ -60,21 +60,28 @@ const (
)
type SaveHighlightRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
SelectionText string
StartPosition string
EndPosition string
Color string
NoteText string
PercentageStart float64
PercentageEnd float64
EpubcfiStart string
EpubcfiEnd string
ChapterReference int32
Source string
ModifiedAt time.Time
DeviceSyncData json.RawMessage
MediaItemID pgtype.UUID
UserID pgtype.UUID
SelectionText string
StartPosition string
EndPosition string
Color string
NoteText string
// HighlightID, when valid, targets that exact row (web PUTs edit by
// id): the save LWWs against it directly under its stored dedup key.
// The computed key depends on fields that legitimately change — the
// stored CFI drifts range→point shape after device echoes, and the
// user can edit the selection text — so a key-based upsert would mint
// a duplicate beside the very row being edited.
HighlightID pgtype.UUID
PercentageStart float64
PercentageEnd float64
EpubcfiStart string
EpubcfiEnd string
ChapterReference int32
Source string
ModifiedAt time.Time
DeviceSyncData json.RawMessage
// DedupKey overrides the computed key when the client echoes back an
// annotation it received from us (device echoes carry device-native
// locators, so the computed key would never match the original row and
@@ -90,6 +97,35 @@ type SaveHighlightResult struct {
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
dedupKey := req.DedupKey
// Web edits arrive with the row id from the URL: resolve by id first
// and LWW against that row under its stored key. Content-derived keys
// are for lookups by identity (device pushes carry no row ids); an
// edit must never re-derive identity from (possibly edited) content.
if req.HighlightID.Valid {
byID, idErr := s.db.GetMediaHighlight(ctx, req.HighlightID)
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
return nil, fmt.Errorf("query highlight by id: %w", idErr)
}
if idErr == nil {
if byID.UserID != req.UserID || byID.MediaItemID != req.MediaItemID {
return nil, fmt.Errorf("highlight %s belongs to another user or media item", req.HighlightID)
}
if dedupKey == "" {
dedupKey = byID.DedupKey.String
}
if byID.Deleted.Bool {
if !incomingNewerThanTombstone(req.ModifiedAt, byID.DeletedAt, byID.LastModifiedAt) {
return &SaveHighlightResult{Highlight: byID, Outcome: SaveOutcomeDeleted}, nil
}
// Newer than the tombstone: a deliberate re-create. Resurrect
// via the LWW update (which clears deleted/deleted_at).
}
return s.applyLWW(ctx, req, byID, dedupKey)
}
// No row with that id: fall through to identity-based resolution.
}
if dedupKey == "" {
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
}
@@ -186,17 +222,38 @@ func (s *AnnotationService) applyLWW(
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
// Web edits carry no device locators (the web reader never had a CRE
// xpointer) and may carry no CFI either: keep the stored ones so
// device-native serve-back and round-trip identity survive a web-side
// note/color edit instead of being wiped to empty.
startPosition := req.StartPosition
if startPosition == "" {
startPosition = existing.StartPosition.String
}
endPosition := req.EndPosition
if endPosition == "" {
endPosition = existing.EndPosition.String
}
epubcfiStart := req.EpubcfiStart
if epubcfiStart == "" {
epubcfiStart = existing.EpubcfiStart.String
}
epubcfiEnd := req.EpubcfiEnd
if epubcfiEnd == "" {
epubcfiEnd = existing.EpubcfiEnd.String
}
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
ID: existing.ID,
SelectionText: req.SelectionText,
StartPosition: pgText(req.StartPosition),
EndPosition: pgText(req.EndPosition),
StartPosition: pgText(startPosition),
EndPosition: pgText(endPosition),
Color: pgText(req.Color),
NoteText: pgText(req.NoteText),
PercentageStart: pgFloat8(req.PercentageStart),
PercentageEnd: pgFloat8(req.PercentageEnd),
EpubcfiStart: pgText(req.EpubcfiStart),
EpubcfiEnd: pgText(req.EpubcfiEnd),
EpubcfiStart: pgText(epubcfiStart),
EpubcfiEnd: pgText(epubcfiEnd),
ChapterReference: pgInt4(req.ChapterReference),
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
@@ -222,7 +279,9 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
textEq(req.Color, existing.Color) &&
textEq(req.NoteText, existing.NoteText) &&
floatEq(req.PercentageStart, existing.PercentageStart) &&
floatEq(req.PercentageEnd, existing.PercentageEnd)
floatEq(req.PercentageEnd, existing.PercentageEnd) &&
locatorRefresher(req.EpubcfiStart, existing.EpubcfiStart) &&
locatorRefresher(req.EpubcfiEnd, existing.EpubcfiEnd)
return !contentSame, !contentSame
}
@@ -233,6 +292,22 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
return req.ModifiedAt.After(existingMod.Time), true
}
// locatorRefresher reports whether an incoming locator leaves the stored
// one untouched: either the push carried none (empty coalesces to the
// stored value in applyLWW) or it matches what is stored. A non-empty
// incoming locator that DIFFERS is a deliberate refresh: device echoes
// re-derive their canonical CFIs on every push, and an improvement (a
// converter fix re-landing a corrupted anchor, drift repair) must reach
// the row even when the annotation content is otherwise identical —
// without this, content-equal echoes resolve to "skip" and a bad stored
// locator can never heal.
func locatorRefresher(incoming string, existing pgtype.Text) bool {
if incoming == "" {
return true
}
return textEq(incoming, existing)
}
func (s *AnnotationService) TombstoneHighlight(
ctx context.Context,
userID, mediaItemID pgtype.UUID,
@@ -590,6 +665,9 @@ type SaveBookmarkRequest struct {
Source string
ModifiedAt time.Time
DeviceSyncData json.RawMessage
// OriginSource records the client that created the bookmark (set once
// at insert; unlike Source it is not updated by later writers).
OriginSource string
// DedupKey overrides the computed key for device echoes (see
// SaveHighlightRequest).
DedupKey string
@@ -624,9 +702,9 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
}
// Newer than the tombstone: a deliberate re-create. Resurrect via the
// LWW update instead of INSERT (the tombstoned row still holds the
// UNIQUE(media_item_id, user_id, title) slot).
// Newer than the tombstone: a deliberate re-create at the same
// location. Resurrect via the LWW update so the row keeps its id
// and origin.
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
}
@@ -656,6 +734,7 @@ func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmark
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
OriginSource: pgText(req.OriginSource),
})
if err != nil {
return nil, fmt.Errorf("create bookmark: %w", err)
@@ -683,13 +762,25 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
}
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
// Web saves carry no device locators: keep the stored ones so a web
// title/note edit never wipes the device-native position (mirrors the
// highlight path's coalescing).
cfiPosition := req.CFIPosition
if cfiPosition == "" {
cfiPosition = existing.CfiPosition.String
}
position := req.Position
if position == "" {
position = existing.Position.String
}
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
ID: existing.ID,
PageNumber: pgInt4(req.PageNumber),
ChapterNumber: pgInt4(req.ChapterNumber),
CfiPosition: pgText(req.CFIPosition),
CfiPosition: pgText(cfiPosition),
Title: req.Title,
Position: pgText(req.Position),
Position: pgText(position),
Notes: pgText(req.Notes),
PercentageLocation: pgFloat8(req.PercentageLoc),
EpubcfiLocation: pgText(req.EpubcfiLocation),
@@ -714,7 +805,9 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
if req.ModifiedAt.IsZero() {
contentSame := strings.EqualFold(req.Title, existing.Title) &&
textEq(req.Notes, existing.Notes)
textEq(req.Notes, existing.Notes) &&
locatorRefresher(req.CFIPosition, existing.CfiPosition) &&
locatorRefresher(req.Position, existing.Position)
return !contentSame, !contentSame
}
existingMod := existing.LastModifiedAt
+79
View File
@@ -369,3 +369,82 @@ func TestIncomingNewerThanTombstone(t *testing.T) {
})
}
}
// Content-equal device echoes must still count as changed when they carry
// a locator that differs from the stored one: echoes re-derive canonical
// CFIs on every push, and a better conversion (or a repair of a corrupted
// anchor) has to reach the row — otherwise the skip path discards it and
// the bad locator can never heal.
func TestCompareIncomingLocatorDriftRefreshes(t *testing.T) {
svc := &AnnotationService{}
existing := database.MediaHighlights{
SelectionText: "CHAPTER IV. “What a pity it is, Elinor,”",
Color: pgtype.Text{String: "#90caf9", Valid: true},
EpubcfiStart: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/32/3:576)", Valid: true},
EpubcfiEnd: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/4/1:93)", Valid: true},
}
base := SaveHighlightRequest{
SelectionText: existing.SelectionText,
Color: "#90caf9",
}
t.Run("identical content and locators skip", func(t *testing.T) {
req := base
req.EpubcfiStart = existing.EpubcfiStart.String
req.EpubcfiEnd = existing.EpubcfiEnd.String
_, changed := svc.compareIncoming(req, existing)
if changed {
t.Error("identical echo must not rewrite the row")
}
})
t.Run("better start locator refreshes despite identical content", func(t *testing.T) {
req := base
req.EpubcfiStart = "epubcfi(/6/12!/4/2[x]/2/3:0)"
req.EpubcfiEnd = existing.EpubcfiEnd.String
_, changed := svc.compareIncoming(req, existing)
if !changed {
t.Error("locator drift on a content-equal echo must trigger an update")
}
})
t.Run("empty incoming locators leave the row alone", func(t *testing.T) {
req := base // no CFIs: applyLWW coalesces empty to stored
_, changed := svc.compareIncoming(req, existing)
if changed {
t.Error("empty locators coalesce; must not count as drift")
}
})
}
// Bookmarks share the echo-refresh rule for their locators.
func TestCompareIncomingBookmarkLocatorDrift(t *testing.T) {
svc := &AnnotationService{}
existing := database.MediaBookmarks{
Title: "in CHAPTER IV.",
CfiPosition: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/2/3:0)", Valid: true},
Position: pgtype.Text{String: "/body/DocFragment[6]/body/div[1]/h2[1]/text().0", Valid: true},
}
t.Run("same locators skip", func(t *testing.T) {
req := SaveBookmarkRequest{
Title: existing.Title,
CFIPosition: existing.CfiPosition.String,
Position: existing.Position.String,
}
if _, changed := svc.compareIncomingBookmark(req, existing); changed {
t.Error("identical bookmark echo must not rewrite the row")
}
})
t.Run("new CFI refreshes despite same title", func(t *testing.T) {
req := SaveBookmarkRequest{
Title: existing.Title,
CFIPosition: "epubcfi(/6/12!/4/2[x]/2/3:5)",
Position: existing.Position.String,
}
if _, changed := svc.compareIncomingBookmark(req, existing); !changed {
t.Error("bookmark locator drift must trigger an update")
}
})
}
+153 -24
View File
@@ -12,6 +12,7 @@ import (
"strconv"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/html"
@@ -245,7 +246,12 @@ func parseElementPart(part string) (string, int) {
}
type ConversionResult struct {
EPUBCFI string
EPUBCFI string
// EndEPUBCFI is set when the conversion matched a context that is a
// quote of the document (text search): the range end anchor of the
// quoted text, valid across block boundaries. Selections use it as
// the highlight end; point positions ignore it.
EndEPUBCFI string
Href string
Percentage float64
Precision string
@@ -510,23 +516,22 @@ func (c *CFIConverter) convertByStructuralPath(body *html.Node, xp *CREXPointer,
return nil
}
// Text is the final verification: when the device sent usable words and
// they disagree with this structural landing, reject it and let text
// search / percentage decide rather than storing a confident-but-wrong CFI.
// Text is the final verification, in quote form: a usable context must
// be a prefix of the document as read forward from this landing point.
// Device captures are exactly that shape — a position's context is the
// text from the position onward (the plugin's walk-up may concatenate
// the heading with the paragraphs below), and a selection is the
// document text between its two anchors. The single-block containment
// checks stay as secondary acceptance for reverse shapes.
if usable, normalized := usableContextText(contextText); usable {
doc := documentTextFrom(body, textNode, localOffset, utf8.RuneCountInString(normalized)+64)
flat := blockFlattenedText(textNode)
if flat != "" && !strings.Contains(flat, normalized) && !strings.Contains(normalized, flat) {
// Compare a prefix too: device sends ~100 chars from the reader
// position while the block may be longer.
prefix := normalized
if utf8.RuneCountInString(prefix) > 40 {
runes := []rune(prefix)
prefix = string(runes[:40])
}
if !strings.Contains(flat, prefix) {
log.Printf("Bookhoard: structural landing disagrees with context in %s (block %q vs ctx %q)", href, truncateForLog(flat, 80), truncateForLog(normalized, 80))
return nil
}
if !(strings.HasPrefix(doc, normalized) ||
strings.Contains(flat, normalized) ||
strings.Contains(normalized, flat)) {
log.Printf("Bookhoard: structural landing disagrees with context in %s (reads %q vs ctx %q)",
href, truncateForLog(doc, 80), truncateForLog(normalized, 80))
return nil
}
}
@@ -592,28 +597,149 @@ func (c *CFIConverter) convertFragmentID(s string, storedPercentage float64) (*C
}, nil
}
// docRunePos records which text node (and rune offset within it) produced
// each rune of the flattened document text.
type docRunePos struct {
node *html.Node
off int
}
// flattenDocumentForSearch renders the document's text as a
// whitespace-normalized rune stream in document order, crossing block
// boundaries: a single space separates blocks, while inline spans join
// directly (drop-cap splits like <span>C</span>onvergence read as one
// word). Every emitted rune carries its source (node, rune offset) so
// matches map back to CFIs.
func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
var runes []rune
var poss []docRunePos
pendingSpace := false
var lastBlock *html.Node
appendNode := func(n *html.Node) {
block := findBlockParent(n)
if lastBlock != nil && block != lastBlock {
pendingSpace = true
}
lastBlock = block
off := 0
for _, r := range n.Data {
if unicode.IsSpace(r) {
pendingSpace = true
} else {
if pendingSpace && len(runes) > 0 {
runes = append(runes, ' ')
poss = append(poss, docRunePos{node: n, off: off})
}
pendingSpace = false
runes = append(runes, r)
poss = append(poss, docRunePos{node: n, off: off})
}
off++
}
}
var walk func(n *html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
if strings.TrimSpace(n.Data) != "" {
appendNode(n)
}
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(body)
return runes, poss
}
// documentTextFrom reads the whitespace-normalized document text starting
// at rune `offset` in `start` (typically a structural landing point),
// crossing block boundaries — the document "as read" from that position.
// Capped at maxRunes.
func documentTextFrom(body *html.Node, start *html.Node, offset, maxRunes int) string {
runes, poss := flattenDocumentForSearch(body)
begin := -1
for i := range poss {
if poss[i].node == start && poss[i].off >= offset {
begin = i
break
}
}
if begin < 0 {
return ""
}
// The separator space before a block's first content rune shares its
// (node, offset) — skip it so the read starts on real text.
if runes[begin] == ' ' && begin+1 < len(runes) {
begin++
}
end := len(runes)
if begin+maxRunes < end {
end = begin + maxRunes
}
return string(runes[begin:end])
}
func (c *CFIConverter) convertByTextSearch(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult {
normalizedCtx := normalizeWhitespace(contextText)
if normalizedCtx == "" {
return nil
}
match, matchOffset := findTextInNode(body, normalizedCtx)
if match == nil {
log.Printf("Bookhoard: text search no match for %q in %s", normalizedCtx, href)
// Match against the whole document flattened in reading order — the
// context may span block boundaries (a selection covering several
// paragraphs, a walk-up capture joining a heading with what follows).
runes, poss := flattenDocumentForSearch(body)
text := string(runes)
words := strings.Fields(normalizedCtx)
quoted := make([]string, len(words))
for i, w := range words {
quoted[i] = regexp.QuoteMeta(w)
}
pattern := strings.Join(quoted, `\s+`)
re, err := regexp.Compile(pattern)
if err != nil {
return nil
}
loc := re.FindStringIndex(text)
if loc == nil {
log.Printf("Bookhoard: text search no match for %q in %s", truncateForLog(normalizedCtx, 60), href)
return nil
}
startRune := utf8.RuneCountInString(text[:loc[0]])
endRune := utf8.RuneCountInString(text[:loc[1]]) // exclusive
spineIndex := xp.FragmentIndex - 1
cfi, err := buildCFI(spineIndex, match, matchOffset)
if err != nil || cfi == "" {
startPos := poss[startRune]
startCFI, err := buildCFI(spineIndex, startPos.node, startPos.off)
if err != nil || startCFI == "" {
log.Printf("Bookhoard: text search found match but buildCFI failed: %v", err)
return nil
}
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", normalizedCtx, cfi)
// The matched context is a quote of the document: its extent gives
// selections a true range end, across block boundaries.
endCFI := ""
if endRune > startRune && endRune <= len(poss) {
endPos := poss[endRune-1]
endOff := endPos.off + 1
if nRunes := utf8.RuneCountInString(endPos.node.Data); endOff > nRunes {
endOff = nRunes
}
if ec, err := buildCFI(spineIndex, endPos.node, endOff); err == nil && ec != "" {
endCFI = ec
}
}
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", truncateForLog(normalizedCtx, 60), startCFI)
return &ConversionResult{
EPUBCFI: cfi,
EPUBCFI: startCFI,
EndEPUBCFI: endCFI,
Href: href,
Percentage: storedPercentage,
Precision: "exact",
@@ -780,7 +906,10 @@ func (c *CFIConverter) convertByPercentageOffset(body *html.Node, xp *CREXPointe
EPUBCFI: cfi,
Href: href,
Percentage: storedPercentage,
Precision: "exact",
// This is a char-count estimate, not an exact landing: say
// so. Callers gate storage on precision, and a guess must
// never masquerade as an exact anchor.
Precision: "percentage",
}, nil
}
}
+111
View File
@@ -666,3 +666,114 @@ func TestReverseIgnoresSingleCharContext(t *testing.T) {
t.Errorf("single-char reverse context must fall back to percentage, got %s", reverse.Precision)
}
}
// A position at a chapter heading sends walk-up context: the heading text
// concatenated with the paragraphs below (block walk-up in the plugin).
// The structural landing at the heading is correct and must verify — the
// context is a prefix of the document as read from the landing.
func TestWalkUpContextVerifiesAtHeading(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
// ch10: h1[1] "Chapter 10", h1[2] "The Three C's of the New Covenant",
// then paragraphs. Position at h1[2]'s text start; the device captured
// the heading plus the following paragraph.
xp := "/body/DocFragment[2]/body/h1[2]/text().0"
ctx := "The Three C's of the New Covenant The Cleansing Life of Christ"
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("walk-up heading → %s (%s)", result.EPUBCFI, result.Precision)
if result.Precision != "structural" {
t.Fatalf("expected structural precision (quote verification), got %s (%s)", result.Precision, result.EPUBCFI)
}
if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") {
t.Errorf("collapsed to doc start: %s", result.EPUBCFI)
}
// The landing must be the heading, not a paragraph below it.
reverse, rerr := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
if rerr != nil {
t.Fatalf("reverse conversion error: %v", rerr)
}
if !strings.Contains(reverse.XPointer, "h1[2]") {
t.Errorf("expected landing in h1[2], got %s", reverse.XPointer)
}
}
// A selection spanning blocks (heading tail into the next paragraph) must
// be findable by text search across block boundaries, and the matched
// quote's extent gives a true range end.
func TestCrossBlockSearchSpansBlocks(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
// No element path → structural rung skipped, text search runs.
xp := "/body/DocFragment[2]/body"
ctx := "New Covenant The Cleansing Life of Christ"
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("cross-block search → %s … %s (%s)", result.EPUBCFI, result.EndEPUBCFI, result.Precision)
if result.Precision != "exact" {
t.Fatalf("expected exact text-search precision, got %s", result.Precision)
}
if result.EPUBCFI == "" || result.EndEPUBCFI == "" {
t.Fatalf("expected range anchors, got %q…%q", result.EPUBCFI, result.EndEPUBCFI)
}
if result.EPUBCFI == result.EndEPUBCFI {
t.Fatalf("range collapsed: %s", result.EPUBCFI)
}
// The start lands in the heading, the end in the following paragraph.
startRev, err1 := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
endRev, err2 := c.ConvertStandardToCRE(result.EndEPUBCFI, result.Percentage, "")
if err1 != nil || err2 != nil {
t.Fatalf("reverse conversions failed: %v %v", err1, err2)
}
if !strings.Contains(startRev.XPointer, "h1[2]") {
t.Errorf("expected start in h1[2], got %s", startRev.XPointer)
}
if !strings.Contains(endRev.XPointer, "p[1]") {
t.Errorf("expected end in p[1] (following paragraph), got %s", endRev.XPointer)
}
}
// The percentage rung is a char-count estimate: it must never label its
// landing "exact". Feed it an unmatchable context so the ladder falls all
// the way through.
func TestPercentageFallbackIsHonestlyLabeled(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
result, err := c.ConvertCREToStandard(xp, 0.52, "zzz qqq vvv uuu www")
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("unmatchable context → %s (%s)", result.EPUBCFI, result.Precision)
if result.Precision != "percentage" {
t.Errorf("percentage rung must not claim exact, got %s", result.Precision)
}
}
// The bookmark route supplies no context (bookmark text is a display
// label, never book text), so the facade must still resolve the drop-cap
// xpointer structurally instead of collapsing to the document start.
func TestConvertToCanonical_DropCapEmptyContext(t *testing.T) {
epubPath := writeDropCapEPUB(t)
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
loc := ConvertToCanonical(LocatorSourceKOReader, xp, 0.52, "", string(FormatGroupReflowable), epubPath, "")
t.Logf("facade drop-cap (no context) → %s (%s)", loc.CFI, loc.Precision)
if loc.Precision != "structural" && loc.Precision != "exact" {
t.Fatalf("expected structural/exact precision, got %s (%s)", loc.Precision, loc.CFI)
}
if !strings.HasPrefix(loc.CFI, "epubcfi(") {
t.Fatalf("expected standard epubcfi, got %s", loc.CFI)
}
if strings.HasSuffix(loc.CFI, "/4/2/1:0)") {
t.Errorf("empty-context conversion collapsed to doc start: %s", loc.CFI)
}
}
+13 -2
View File
@@ -14,7 +14,11 @@ const (
)
type CanonicalLocator struct {
CFI string
CFI string
// EndCFI carries the matched context's range end (text-search
// conversions of selections) so callers can anchor a true highlight
// range across block boundaries.
EndCFI string
Precision string
Percentage float64
}
@@ -58,6 +62,13 @@ func cachedConverter(epubPath string) *CFIConverter {
return c
}
// SectionPercentageCached resolves the spine-section percentage of a CRE
// xpointer through the shared bounded converter cache, so per-annotation
// lookups parse the EPUB once per book instead of once per annotation.
func SectionPercentageCached(epubPath, xpointer string) float64 {
return cachedConverter(epubPath).SectionPercentage(xpointer)
}
func ConvertToCanonical(
source LocatorSource,
devicePos string,
@@ -87,7 +98,7 @@ func ConvertToCanonical(
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
}
if result.EPUBCFI != "" {
return CanonicalLocator{CFI: result.EPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
return CanonicalLocator{CFI: result.EPUBCFI, EndCFI: result.EndEPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
}
if result.Href != "" {
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
+305
View File
@@ -0,0 +1,305 @@
package utils
import (
"strconv"
"strings"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
// Per-field user-override tracking for media item metadata.
//
// metadata_overrides is a TEXT[] column on media_items holding the names of
// columns the user has customized via the metadata editor. Library scans and
// per-book rescans MUST preserve those columns (applyMetadataOverrides);
// only the reset-to-scanned-defaults action clears the set.
const (
OverrideTitle = "title"
OverrideAuthor = "author"
OverrideISBN = "isbn"
OverrideDescription = "description"
OverrideCoverImagePath = "cover_image_path"
OverrideSeries = "series"
OverrideSeriesNumber = "series_number"
OverrideTags = "tags"
OverrideAsin = "asin"
OverrideDatePublished = "date_published"
OverridePublisher = "publisher"
OverrideContributors = "contributors"
OverrideLanguage = "language"
OverrideEdition = "edition"
OverridePageCount = "page_count"
OverrideGenre = "genre"
OverrideCopyrightYear = "copyright_year"
OverrideGoodreadsID = "goodreads_id"
OverrideOpenlibraryID = "openlibrary_id"
OverrideGoogleBooksID = "google_books_id"
OverrideMangaType = "manga_type"
OverrideReadingDirection = "reading_direction"
OverrideSeriesCount = "series_count"
OverrideVolume = "volume"
OverrideImprint = "imprint"
OverrideAgeRating = "age_rating"
OverrideWebURL = "web_url"
OverrideMetadataNotes = "metadata_notes"
OverrideCommunityRating = "community_rating"
OverrideStoryArc = "story_arc"
OverrideIsBlackAndWhite = "is_black_and_white"
OverrideAlternateInfo = "alternate_info"
OverrideScanInformation = "scan_information"
OverrideSummary = "summary"
)
// hasOverride reports whether key is in the override set.
func hasOverride(overrides []string, key string) bool {
for _, k := range overrides {
if k == key {
return true
}
}
return false
}
// MergeOverrides unions existing and added, preserving order and dropping
// duplicates. Returns a non-nil slice so it always satisfies NOT NULL columns.
func MergeOverrides(existing []string, added ...string) []string {
seen := make(map[string]bool, len(existing)+len(added))
merged := make([]string, 0, len(existing)+len(added))
for _, k := range existing {
if k != "" && !seen[k] {
seen[k] = true
merged = append(merged, k)
}
}
for _, k := range added {
if k != "" && !seen[k] {
seen[k] = true
merged = append(merged, k)
}
}
return merged
}
// Normalizers turn nullable column values into comparable strings so that
// "unset" (invalid/zero) forms compare equal regardless of which side they
// come from.
func normText(t pgtype.Text) string {
if !t.Valid {
return ""
}
return t.String
}
func normInt(i pgtype.Int4) string {
if !i.Valid {
return ""
}
return strconv.FormatInt(int64(i.Int32), 10)
}
func normFloat(f pgtype.Float8) string {
if !f.Valid {
return ""
}
return strconv.FormatFloat(f.Float64, 'g', -1, 64)
}
func normBool(b pgtype.Bool) string {
if !b.Valid {
return ""
}
return strconv.FormatBool(b.Bool)
}
func normDate(d pgtype.Date) string {
if !d.Valid {
return ""
}
return d.Time.Format("2006-01-02")
}
func normStringSlice(s []string) string {
if len(s) == 0 {
return ""
}
return strings.Join(s, "\x1f")
}
func normBytes(b []byte) string {
if len(b) == 0 {
return ""
}
return string(b)
}
// detectMetadataOverridesDiff returns the keys whose value in params differs
// from the existing row. Used by the metadata editor save path to grow the
// override set with exactly the fields the user changed.
func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
var changed []string
add := func(key string, differs bool) {
if differs {
changed = append(changed, key)
}
}
add(OverrideTitle, params.Title != existing.Title)
add(OverrideAuthor, normText(params.Author) != normText(existing.Author))
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(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
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(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors))
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
add(OverridePageCount, normInt(params.PageCount) != normInt(existing.PageCount))
add(OverrideGenre, normText(params.Genre) != normText(existing.Genre))
add(OverrideCopyrightYear, normInt(params.CopyrightYear) != normInt(existing.CopyrightYear))
add(OverrideGoodreadsID, normText(params.GoodreadsID) != normText(existing.GoodreadsID))
add(OverrideOpenlibraryID, normText(params.OpenlibraryID) != normText(existing.OpenlibraryID))
add(OverrideGoogleBooksID, normText(params.GoogleBooksID) != normText(existing.GoogleBooksID))
add(OverrideMangaType, normText(params.MangaType) != normText(existing.MangaType))
add(OverrideReadingDirection, normText(params.ReadingDirection) != normText(existing.ReadingDirection))
add(OverrideSeriesCount, normInt(params.SeriesCount) != normInt(existing.SeriesCount))
add(OverrideVolume, normInt(params.Volume) != normInt(existing.Volume))
add(OverrideImprint, normText(params.Imprint) != normText(existing.Imprint))
add(OverrideAgeRating, normText(params.AgeRating) != normText(existing.AgeRating))
add(OverrideWebURL, normText(params.WebUrl) != normText(existing.WebUrl))
add(OverrideMetadataNotes, normText(params.MetadataNotes) != normText(existing.MetadataNotes))
add(OverrideCommunityRating, normFloat(params.CommunityRating) != normFloat(existing.CommunityRating))
add(OverrideStoryArc, normText(params.StoryArc) != normText(existing.StoryArc))
add(OverrideIsBlackAndWhite, normBool(params.IsBlackAndWhite) != normBool(existing.IsBlackAndWhite))
add(OverrideAlternateInfo, normBytes(params.AlternateInfo) != normBytes(existing.AlternateInfo))
add(OverrideScanInformation, normText(params.ScanInformation) != normText(existing.ScanInformation))
add(OverrideSummary, normText(params.Summary) != normText(existing.Summary))
return changed
}
// DetectMetadataOverrides returns the union of the existing override set and
// any fields whose incoming (user-submitted) values differ from the stored
// row. Overrides accumulate: a field stays protected until an explicit reset,
// even if a later save reverts the value.
func DetectMetadataOverrides(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
return MergeOverrides(existing.MetadataOverrides, detectMetadataOverridesDiff(params, existing)...)
}
// ApplyMetadataOverrides restores every overridden field in params from the
// existing row so scanner updates cannot clobber user customizations. Derived
// search columns are restored together with their base column.
func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing database.MediaItems) {
o := existing.MetadataOverrides
if hasOverride(o, OverrideTitle) {
params.Title = existing.Title
}
if hasOverride(o, OverrideAuthor) {
params.Author = existing.Author
}
if hasOverride(o, OverrideISBN) {
params.Isbn = existing.Isbn
}
if hasOverride(o, OverrideDescription) {
params.Description = existing.Description
}
if hasOverride(o, OverrideCoverImagePath) {
params.CoverImagePath = existing.CoverImagePath
}
if hasOverride(o, OverrideSeries) {
params.Series = existing.Series
}
if hasOverride(o, OverrideSeriesNumber) {
params.SeriesNumber = existing.SeriesNumber
}
if hasOverride(o, OverrideTags) {
params.Tags = existing.Tags
params.TagsSearch = existing.TagsSearch
}
if hasOverride(o, OverrideAsin) {
params.Asin = existing.Asin
}
if hasOverride(o, OverrideDatePublished) {
params.DatePublished = existing.DatePublished
}
if hasOverride(o, OverridePublisher) {
params.Publisher = existing.Publisher
}
if hasOverride(o, OverrideContributors) {
params.Contributors = existing.Contributors
params.ContributorsSearch = existing.ContributorsSearch
}
if hasOverride(o, OverrideLanguage) {
params.Language = existing.Language
}
if hasOverride(o, OverrideEdition) {
params.Edition = existing.Edition
}
if hasOverride(o, OverridePageCount) {
params.PageCount = existing.PageCount
}
if hasOverride(o, OverrideGenre) {
params.Genre = existing.Genre
}
if hasOverride(o, OverrideCopyrightYear) {
params.CopyrightYear = existing.CopyrightYear
}
if hasOverride(o, OverrideGoodreadsID) {
params.GoodreadsID = existing.GoodreadsID
}
if hasOverride(o, OverrideOpenlibraryID) {
params.OpenlibraryID = existing.OpenlibraryID
}
if hasOverride(o, OverrideGoogleBooksID) {
params.GoogleBooksID = existing.GoogleBooksID
}
if hasOverride(o, OverrideMangaType) {
params.MangaType = existing.MangaType
}
if hasOverride(o, OverrideReadingDirection) {
params.ReadingDirection = existing.ReadingDirection
}
if hasOverride(o, OverrideSeriesCount) {
params.SeriesCount = existing.SeriesCount
}
if hasOverride(o, OverrideVolume) {
params.Volume = existing.Volume
}
if hasOverride(o, OverrideImprint) {
params.Imprint = existing.Imprint
}
if hasOverride(o, OverrideAgeRating) {
params.AgeRating = existing.AgeRating
}
if hasOverride(o, OverrideWebURL) {
params.WebUrl = existing.WebUrl
}
if hasOverride(o, OverrideMetadataNotes) {
params.MetadataNotes = existing.MetadataNotes
}
if hasOverride(o, OverrideCommunityRating) {
params.CommunityRating = existing.CommunityRating
}
if hasOverride(o, OverrideStoryArc) {
params.StoryArc = existing.StoryArc
}
if hasOverride(o, OverrideIsBlackAndWhite) {
params.IsBlackAndWhite = existing.IsBlackAndWhite
}
if hasOverride(o, OverrideAlternateInfo) {
params.AlternateInfo = existing.AlternateInfo
}
if hasOverride(o, OverrideScanInformation) {
params.ScanInformation = existing.ScanInformation
}
if hasOverride(o, OverrideSummary) {
params.Summary = existing.Summary
}
}
+112
View File
@@ -0,0 +1,112 @@
package utils
import (
"reflect"
"testing"
"bookhoard/internal/database"
"github.com/jackc/pgx/v5/pgtype"
)
func TestMergeOverrides(t *testing.T) {
got := MergeOverrides([]string{"title", "tags"}, "tags", "description", "")
want := []string{"title", "tags", "description"}
if !reflect.DeepEqual(got, want) {
t.Errorf("MergeOverrides() = %v, want %v", got, want)
}
if MergeOverrides(nil) == nil {
t.Error("MergeOverrides(nil) must return non-nil slice")
}
}
func TestDetectMetadataOverrides(t *testing.T) {
existing := database.MediaItems{
Title: "Scanned Title",
Description: pgtype.Text{String: "Scanned description", Valid: true},
Tags: []string{"foo", "bar"},
}
params := database.UpdateMediaItemParams{
// unchanged
Title: "Scanned Title",
// changed
Description: pgtype.Text{String: "My custom description", Valid: true},
Tags: []string{"foo", "bar"},
}
got := DetectMetadataOverrides(params, existing)
if !reflect.DeepEqual(got, []string{"description"}) {
t.Errorf("DetectMetadataOverrides() = %v, want [description]", got)
}
// Overrides accumulate: an existing override survives a later save.
existing.MetadataOverrides = []string{"publisher"}
got = DetectMetadataOverrides(params, existing)
want := []string{"publisher", "description"}
if !reflect.DeepEqual(got, want) {
t.Errorf("DetectMetadataOverrides() accumulate = %v, want %v", got, want)
}
// An untouched save detects nothing new.
noop := database.UpdateMediaItemParams{
Title: existing.Title,
Description: existing.Description,
Tags: existing.Tags,
}
got = DetectMetadataOverrides(noop, existing)
if !reflect.DeepEqual(got, []string{"publisher"}) {
t.Errorf("DetectMetadataOverrides() noop = %v, want [publisher]", got)
}
}
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},
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
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
}
got := DetectMetadataOverrides(params, existing)
if !reflect.DeepEqual(got, []string{"series_number"}) {
t.Errorf("DetectMetadataOverrides() = %v, want [series_number]", got)
}
}
func TestApplyMetadataOverrides(t *testing.T) {
existing := database.MediaItems{
Title: "Custom Title",
Description: pgtype.Text{String: "Custom desc", Valid: true},
Tags: []string{"mine"},
TagsSearch: []string{"mine"},
CoverImagePath: pgtype.Text{String: "books/x.epub.custom_cover.jpg", Valid: true},
}
params := database.UpdateMediaItemParams{
Title: "Scanned Title",
Description: pgtype.Text{String: "Scanned desc", Valid: true},
Tags: []string{"scanned"},
TagsSearch: []string{"scanned"},
CoverImagePath: pgtype.Text{String: "books/x.epub.cover.jpg", Valid: true},
}
// Only title and tags overridden; scanned description and cover win.
existing.MetadataOverrides = []string{"title", "tags"}
ApplyMetadataOverrides(&params, existing)
if params.Title != "Custom Title" {
t.Errorf("Title = %q, want %q", params.Title, "Custom Title")
}
if !reflect.DeepEqual(params.Tags, []string{"mine"}) || !reflect.DeepEqual(params.TagsSearch, []string{"mine"}) {
t.Error("Tags/TagsSearch must be restored together")
}
if params.Description.String != "Scanned desc" {
t.Errorf("Description = %q, want scanned value", params.Description.String)
}
if params.CoverImagePath.String != "books/x.epub.cover.jpg" {
t.Errorf("CoverImagePath = %q, want scanned value", params.CoverImagePath.String)
}
}
+17 -7
View File
@@ -107,10 +107,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
}
</button>
if user.Role == "admin" {
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
@Icon("edit", "h-4 w-4")
<span>Edit</span>
</button>
}
</div>
<!-- Rating -->
<div class="mb-5" @mouseleave="ratingHover = 0">
@@ -370,12 +372,18 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p>
<p style="color: var(--text-primary)">{ book.MimeType.String }</p>
</div>
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
</div>
}
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
</div>
}
if user.Role == "admin" {
<div class="md:col-span-2 lg:col-span-3">
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Location</p>
<p class="font-mono text-xs break-all select-all" style="color: var(--text-secondary)">{ book.FileLocation }</p>
</div>
}
</div>
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
@@ -426,7 +434,9 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</div>
@ProgressSyncModal(user, book)
@NotesHighlightsModal(user, book)
@MetadataEditorModal(book)
if user.Role == "admin" {
@MetadataEditorModal(book)
}
@ErrorToast(errorMessage)
</body>
</html>
+44 -31
View File
@@ -290,25 +290,6 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<input type="file" id="cover-upload-input" accept="image/jpeg,image/png,image/webp" class="hidden"
@change="handleCoverUpload($event)" />
<div class="w-64 space-y-2">
<button
type="button"
class="btn btn-primary w-full"
@click="generateCover()"
x-show="coverGenerating"
disabled
>
@Icon("refresh", "h-4 w-4")
Generating...
</button>
<button
type="button"
class="btn btn-primary w-full"
@click="generateCover()"
x-show="!coverGenerating"
>
@Icon("refresh", "h-4 w-4")
Generate Cover
</button>
<button
type="button"
class="btn btn-ghost w-full"
@@ -593,23 +574,54 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Contributors (comma-separated)</label>
<input type="text" name="contributors" value={ stringSliceToString(book.Contributors) } class="input" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Format</label>
<input type="text" value={ textToString(book.MimeType) } readonly
class="input opacity-60" />
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Size</label>
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
class="input opacity-60" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Format</label>
<input type="text" value={ textToString(book.MimeType) } readonly
class="input opacity-60" />
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Size</label>
<input type="text" value={ fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024) } readonly
class="input opacity-60" />
</div>
</div>
<div>
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Path</label>
<input type="text" value={ book.FileLocation } readonly
class="input opacity-60 font-mono text-xs" />
</div>
</div>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
<div class="flex items-center justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);">
<div class="flex items-center gap-2">
<button
@click="rescanBook()"
:disabled="rescanning"
class="btn btn-secondary"
>
<span x-show="!rescanning" class="inline-flex items-center gap-2">@Icon("sync", "h-4 w-4")<span>Rescan</span></span>
<svg x-show="rescanning" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
<button
@click="resetMetadata()"
:disabled="resetting"
class="btn btn-secondary"
title="Discard all manual edits and restore scanned metadata"
>
<span x-show="!resetting" class="inline-flex items-center gap-2">@Icon("refresh", "h-4 w-4")<span>Reset to Scanned</span></span>
<svg x-show="resetting" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
</div>
<div class="flex space-x-3">
<button
@click="hideMetadataEditor()"
class="btn btn-secondary"
@@ -625,5 +637,6 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</button>
</div>
</div>
</div>
</div>
}
+126 -113
View File
@@ -689,23 +689,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"absolute inset-0 bg-black bg-opacity-40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center\"><span class=\"text-white text-sm font-semibold\">Click to upload</span></div></div><input type=\"file\" id=\"cover-upload-input\" accept=\"image/jpeg,image/png,image/webp\" class=\"hidden\" @change=\"handleCoverUpload($event)\"><div class=\"w-64 space-y-2\"><button type=\"button\" class=\"btn btn-primary w-full\" @click=\"generateCover()\" x-show=\"coverGenerating\" disabled>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "Generating...</button> <button type=\"button\" class=\"btn btn-primary w-full\" @click=\"generateCover()\" x-show=\"!coverGenerating\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Generate Cover</button> <button type=\"button\" class=\"btn btn-ghost w-full\" @click=\"removeCover()\" x-show=\"hasExistingCover || newCoverPreview\">Remove Cover</button></div></div><div class=\"flex-1 min-w-0 space-y-2\"><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('basic')\"><span class=\"font-semibold\">Basic Info</span> <span class=\"inline-flex\"><span x-show=\"openSections.basic\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"absolute inset-0 bg-black bg-opacity-40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center\"><span class=\"text-white text-sm font-semibold\">Click to upload</span></div></div><input type=\"file\" id=\"cover-upload-input\" accept=\"image/jpeg,image/png,image/webp\" class=\"hidden\" @change=\"handleCoverUpload($event)\"><div class=\"w-64 space-y-2\"><button type=\"button\" class=\"btn btn-ghost w-full\" @click=\"removeCover()\" x-show=\"hasExistingCover || newCoverPreview\">Remove Cover</button></div></div><div class=\"flex-1 min-w-0 space-y-2\"><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('basic')\"><span class=\"font-semibold\">Basic Info</span> <span class=\"inline-flex\"><span x-show=\"openSections.basic\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -713,7 +697,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span> <span x-show=\"!openSections.basic\" x-cloak>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span> <span x-show=\"!openSections.basic\" x-cloak>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -721,59 +705,59 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span></span></button><div x-show=\"openSections.basic\" 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);\">Title</label> <input type=\"text\" name=\"title\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span></span></button><div x-show=\"openSections.basic\" 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);\">Title</label> <input type=\"text\" name=\"title\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 336, Col: 58}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 317, Col: 58}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" 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=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" 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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Author))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 340, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 321, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Description</label> <textarea name=\"description\" rows=\"3\" class=\"input\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Description</label> <textarea name=\"description\" rows=\"3\" class=\"input\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Description))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 345, Col: 41}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 326, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Summary</label> <textarea name=\"summary\" rows=\"2\" class=\"input\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Summary</label> <textarea name=\"summary\" rows=\"2\" class=\"input\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Summary))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 350, Col: 37}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 331, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Tags</label><div class=\"flex flex-wrap gap-1.5 mb-2\"><template x-for=\"(tag, idx) in editorTags\" :key=\"idx\"><span class=\"chip\"><span x-text=\"tag\"></span> <button type=\"button\" class=\"hover:bg-surface-hover rounded leading-none\" @click=\"removeEditorTag(idx)\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Tags</label><div class=\"flex flex-wrap gap-1.5 mb-2\"><template x-for=\"(tag, idx) in editorTags\" :key=\"idx\"><span class=\"chip\"><span x-text=\"tag\"></span> <button type=\"button\" class=\"hover:bg-surface-hover rounded leading-none\" @click=\"removeEditorTag(idx)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -781,43 +765,43 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</button></span></template></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</button></span></template></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, tag := range book.Tags {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span data-editor-tag=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span data-editor-tag=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(tag)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 363, Col: 36}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 344, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\" class=\"hidden\"></span>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" class=\"hidden\"></span>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div><input type=\"text\" x-model=\"tagSearch\" @input.debounce.300ms=\"searchEditorTags()\" @keydown=\"onTagKeydown($event)\" @blur=\"hideEditorTagDropdown()\" placeholder=\"Add tag...\" class=\"input\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"mt-1 w-full rounded-lg border max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-secondary); border-color: var(--border); box-shadow: var(--shadow-card);\"><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 hover:bg-surface-hover\" :class=\"highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''\" @click=\"selectEditorTagSuggestion(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><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Community Rating (0-10)</label> <input type=\"number\" name=\"community_rating\" min=\"0\" max=\"10\" step=\"0.1\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<div><input type=\"text\" x-model=\"tagSearch\" @input.debounce.300ms=\"searchEditorTags()\" @keydown=\"onTagKeydown($event)\" @blur=\"hideEditorTagDropdown()\" placeholder=\"Add tag...\" class=\"input\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"mt-1 w-full rounded-lg border max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-secondary); border-color: var(--border); box-shadow: var(--shadow-card);\"><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 hover:bg-surface-hover\" :class=\"highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''\" @click=\"selectEditorTagSuggestion(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><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Community Rating (0-10)</label> <input type=\"number\" name=\"community_rating\" min=\"0\" max=\"10\" step=\"0.1\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f", book.CommunityRating.Float64))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 397, Col: 66}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 378, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('publication')\"><span class=\"font-semibold\">Publication</span> <span class=\"inline-flex\"><span x-show=\"openSections.publication\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('publication')\"><span class=\"font-semibold\">Publication</span> <span class=\"inline-flex\"><span x-show=\"openSections.publication\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -825,7 +809,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span> <span x-show=\"!openSections.publication\" x-cloak>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span> <span x-show=\"!openSections.publication\" x-cloak>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -833,85 +817,85 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span></span></button><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=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span></span></button><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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var40 string
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Publisher))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 415, Col: 80}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 396, Col: 80}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Date Published</label> <input type=\"date\" name=\"date_published\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Date Published</label> <input type=\"date\" name=\"date_published\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var41 string
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatDateForInput(book.DatePublished))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 420, Col: 55}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 401, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Edition</label> <input type=\"text\" name=\"edition\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Edition</label> <input type=\"text\" name=\"edition\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var42 string
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Edition))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 425, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 406, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Language</label> <input type=\"text\" name=\"language\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Language</label> <input type=\"text\" name=\"language\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var43 string
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Language))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 429, Col: 78}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 410, Col: 78}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Genre</label> <input type=\"text\" name=\"genre\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Genre</label> <input type=\"text\" name=\"genre\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var44 string
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Genre))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 433, Col: 72}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 414, Col: 72}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Copyright Year</label> <input type=\"number\" name=\"copyright_year\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Copyright Year</label> <input type=\"number\" name=\"copyright_year\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var45 string
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.CopyrightYear.Int32))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 438, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 419, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('series')\"><span class=\"font-semibold\">Series</span> <span class=\"inline-flex\"><span x-show=\"openSections.series\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('series')\"><span class=\"font-semibold\">Series</span> <span class=\"inline-flex\"><span x-show=\"openSections.series\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -919,7 +903,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</span> <span x-show=\"!openSections.series\" x-cloak>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span> <span x-show=\"!openSections.series\" x-cloak>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -927,59 +911,59 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</span></span></button><div x-show=\"openSections.series\" 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);\">Series</label> <input type=\"text\" name=\"series\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span></span></button><div x-show=\"openSections.series\" 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);\">Series</label> <input type=\"text\" name=\"series\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Series))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 456, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 437, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"input\"></div><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=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" class=\"input\"></div><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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesNumber.Int32))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 462, Col: 60}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 443, Col: 60}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Count</label> <input type=\"number\" name=\"series_count\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Count</label> <input type=\"number\" name=\"series_count\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesCount.Int32))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 468, Col: 59}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 449, Col: 59}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Volume</label> <input type=\"number\" name=\"volume\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Volume</label> <input type=\"number\" name=\"volume\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.Volume.Int32))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 474, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 455, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" class=\"input\"></div></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('identifiers')\"><span class=\"font-semibold\">Identifiers</span> <span class=\"inline-flex\"><span x-show=\"openSections.identifiers\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\" class=\"input\"></div></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('identifiers')\"><span class=\"font-semibold\">Identifiers</span> <span class=\"inline-flex\"><span x-show=\"openSections.identifiers\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -987,7 +971,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</span> <span x-show=\"!openSections.identifiers\" x-cloak>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span> <span x-show=\"!openSections.identifiers\" x-cloak>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -995,85 +979,85 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</span></span></button><div x-show=\"openSections.identifiers\" 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);\">ISBN</label> <input type=\"text\" name=\"isbn\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</span></span></button><div x-show=\"openSections.identifiers\" 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);\">ISBN</label> <input type=\"text\" name=\"isbn\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var50 string
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Isbn))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 493, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 474, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">ASIN</label> <input type=\"text\" name=\"asin\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">ASIN</label> <input type=\"text\" name=\"asin\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var51 string
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Asin))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 497, Col: 70}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 478, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Goodreads ID</label> <input type=\"text\" name=\"goodreads_id\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Goodreads ID</label> <input type=\"text\" name=\"goodreads_id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var52 string
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoodreadsID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 501, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 482, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">OpenLibrary ID</label> <input type=\"text\" name=\"openlibrary_id\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">OpenLibrary ID</label> <input type=\"text\" name=\"openlibrary_id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var53 string
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.OpenlibraryID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 505, Col: 89}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 486, Col: 89}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Google Books ID</label> <input type=\"text\" name=\"google_books_id\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Google Books ID</label> <input type=\"text\" name=\"google_books_id\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var54 string
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoogleBooksID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 509, Col: 90}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 490, Col: 90}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Web URL</label> <input type=\"url\" name=\"web_url\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Web URL</label> <input type=\"url\" name=\"web_url\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var55 string
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.WebUrl))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 513, Col: 74}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 494, Col: 74}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1081,7 +1065,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</span> <span x-show=\"!openSections.comic\" x-cloak>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</span> <span x-show=\"!openSections.comic\" x-cloak>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1089,186 +1073,186 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\" selected=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var56 string
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 531, Col: 85}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 512, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\">Unknown</option> <option value=\"no\" selected=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\">Unknown</option> <option value=\"no\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var57 string
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 532, Col: 75}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 513, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">No</option> <option value=\"yes\" selected=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\">No</option> <option value=\"yes\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var58 string
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 533, Col: 77}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 514, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var59 string
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 534, Col: 113}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 515, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\" selected=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var60 string
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 540, Col: 86}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 521, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">Auto</option> <option value=\"ltr\" selected=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\">Auto</option> <option value=\"ltr\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var61 string
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 541, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 522, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\">Left to Right</option> <option value=\"rtl\" selected=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\">Left to Right</option> <option value=\"rtl\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var62 string
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 542, Col: 84}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 523, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\">Right to Left</option> <option value=\"vertical\" selected=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">Right to Left</option> <option value=\"vertical\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var63 string
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 543, Col: 94}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 524, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var64 string
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 548, Col: 81}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 529, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var65 string
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 552, Col: 79}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 533, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var66 string
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 556, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 537, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var67 string
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 560, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 541, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var68 string
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 565, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 546, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.IsBlackAndWhite.Bool {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, " checked")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1276,7 +1260,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</span> <span x-show=\"!openSections.technical\" x-cloak>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</span> <span x-show=\"!openSections.technical\" x-cloak>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1284,59 +1268,88 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</span></span></button><div x-show=\"openSections.technical\" 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);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</span></span></button><div x-show=\"openSections.technical\" 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);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var69 string
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 589, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 570, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var70 string
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 594, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 575, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var71 string
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 599, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 580, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var72 string
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 604, Col: 98}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 585, Col: 97}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" readonly class=\"input opacity-60\"></div></div></div></div></div></div><div class=\"flex justify-end space-x-3 p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" readonly class=\"input opacity-60\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Path</label> <input type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var73 string
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FileLocation)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 591, Col: 51}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" readonly class=\"input opacity-60 font-mono text-xs\"></div></div></div></div></div><div class=\"flex items-center justify-between p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><div class=\"flex items-center gap-2\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("sync", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<span>Rescan</span></span> <svg x-show=\"rescanning\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"resetMetadata()\" :disabled=\"resetting\" class=\"btn btn-secondary\" title=\"Discard all manual edits and restore scanned metadata\"><span x-show=\"!resetting\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<span>Reset to Scanned</span></span> <svg x-show=\"resetting\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1344,7 +1357,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "Save</button></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "Save</button></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
File diff suppressed because it is too large Load Diff
+34 -54
View File
@@ -5,7 +5,11 @@ import (
"fmt"
)
func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) string {
// The init config carries only immutable book metadata. Reading state
// (position, bookmarks, annotations) is never embedded: the reader fetches
// it from the APIs at open time, so the page can never carry — nor write
// back — a stale snapshot of it.
func readerInitExpr(metadata ReaderMetadata) string {
config := map[string]interface{}{
"mediaItemId": metadata.MediaItemID,
"fileUrl": metadata.FileURL,
@@ -13,42 +17,11 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks
"readingDirection": metadata.ReadingDirection,
"mangaType": metadata.MangaType,
}
if progress.Percentage > 0 {
config["savedPercentage"] = progress.Percentage / 100
}
if progress.EpubCfi != "" {
config["savedCfi"] = progress.EpubCfi
}
// Fixed-layout & comic formats: the page index is the canonical, exact
// locator (pages are fixed images). Pass it so the reader restores by page.
if (metadata.FormatGroup == "fixed_layout" || metadata.FormatGroup == "comic_archive") && progress.CurrentPage > 0 {
config["savedPage"] = progress.CurrentPage
if progress.TotalPages > 0 {
config["savedTotalPages"] = progress.TotalPages
}
}
if len(bookmarks) > 0 {
items := make([]map[string]interface{}, 0, len(bookmarks))
for _, b := range bookmarks {
var page any
if b.PageNumber != nil {
page = *b.PageNumber
}
items = append(items, map[string]interface{}{
"id": b.ID,
"title": b.Title,
"positionLabel": b.Position,
"cfi": b.CfiPosition,
"page": page,
})
}
config["bookmarks"] = items
}
jsonBytes, _ := json.Marshal(config)
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
}
templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) {
templ Reader(user User, metadata ReaderMetadata) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -64,7 +37,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
</head>
<body
x-data="readerShell"
x-init={ readerInitExpr(metadata, progress, bookmarks) }
x-init={ readerInitExpr(metadata) }
class={ "theme-" + user.Theme + " h-screen overflow-hidden" }
>
<!-- Reading surface: edge-to-edge. Chrome overlays translucently;
@@ -96,7 +69,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
</div>
</div>
@ReaderChrome(metadata, progress)
@ReaderChrome(metadata)
<!-- Drawer scrim -->
<div
@@ -311,7 +284,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
</html>
}
templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
templ ReaderChrome(metadata ReaderMetadata) {
<div id="reader-chrome" class="transition-opacity duration-300" :class="chromeVisible ? 'opacity-100' : 'chrome-hidden opacity-0 pointer-events-none'">
<!-- Top bar -->
<div id="reader-topbar" class="fixed top-0 left-0 right-0 border-b z-40 pt-[env(safe-area-inset-top)] reader-glass">
@@ -365,17 +338,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
<div class="flex items-center gap-1">
<div class="w-px h-6 reader-sep"></div>
<div id="progress-display" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain">
if progress.FormatGroup == "reflowable" {
if metadata.EstimatedPages > 0 {
{ fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) }
} else {
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
}
} else {
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
}
</span>
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain"></span>
</div>
<div class="w-px h-6 reader-sep"></div>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
@@ -467,13 +430,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
<!-- Progress + TOC -->
<div class="flex items-center gap-1">
<div id="progress-display-fx" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3.5rem] text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
<span x-text="progressMain">
if progress.FormatGroup == "reflowable" {
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
} else {
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
}
</span>
<span x-text="progressMain"></span>
</div>
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
</div>
@@ -730,10 +687,33 @@ templ ReaderAnnotationsDrawer() {
href="#"
@click.prevent="goToBookmark(bookmark)"
class="flex-1 min-w-0 block py-2 hover:bg-gray-700 rounded px-2"
x-show="!bookmark.renameOpen"
>
<span class="font-medium block truncate" x-text="bookmark.title"></span>
<span class="text-xs block truncate" style="color: var(--text-secondary)" x-text="bookmark.positionLabel"></span>
</a>
<div class="flex-1 min-w-0 py-2" x-show="bookmark.renameOpen" @click.stop>
<input
type="text"
class="reader-note-input"
x-model="bookmark.renameText"
@keydown.enter="renameBookmark(bookmark)"
@keydown.escape="bookmark.renameOpen = false"
placeholder="Bookmark name…"
></input>
<div class="flex gap-2 mt-1">
<button @click="renameBookmark(bookmark)" class="flex-1 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700">Save</button>
<button @click="bookmark.renameOpen = false" class="flex-1 py-1 text-xs border rounded hover:opacity-80" style="border-color: var(--border);">Cancel</button>
</div>
</div>
<button
@click="startBookmarkRename(bookmark)"
class="p-2 rounded hover:bg-gray-600 opacity-0 group-hover:opacity-100 transition-opacity"
title="Rename bookmark"
aria-label="Rename bookmark"
>
</button>
<button
@click="deleteBookmark(bookmark.id)"
class="p-2 rounded hover:bg-red-900/60 opacity-0 group-hover:opacity-100 transition-opacity"
+39 -128
View File
File diff suppressed because one or more lines are too long
+66 -37
View File
@@ -1,6 +1,5 @@
import { Alpine } from "./alpine";
import { showToast } from "./toast";
import { generateCoverBlob } from "./cover-generator";
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
function getMediaId(): string {
@@ -91,12 +90,13 @@ export {
interface MetadataEditorState {
openSections: Record<string, boolean>;
coverGenerating: boolean;
hasExistingCover: boolean;
newCoverPreview: string;
coverFile: Blob | null;
coverAction: string;
saving: boolean;
rescanning: boolean;
resetting: boolean;
userRating: number;
ratingHover: number;
ratingSaving: boolean;
@@ -107,9 +107,10 @@ interface MetadataEditorState {
showMetadataEditor(): void;
hideMetadataEditor(): void;
handleCoverUpload(event: Event): void;
generateCover(): Promise<void>;
removeCover(): void;
saveMetadata(): Promise<void>;
rescanBook(): Promise<void>;
resetMetadata(): Promise<void>;
starFill(i: number): string;
ratingText(): string;
setRating(value: number): Promise<void>;
@@ -128,12 +129,6 @@ Alpine.data("bookDetail", () => {
coverImg?.src &&
!coverImg.src.includes("placeholder-book.svg");
const coverPreviewEl = document.querySelector(
".aspect-\\[2\\/3\\] img",
) as HTMLImageElement | null;
const coverSrc = coverPreviewEl?.src || "";
const fileUrl = coverSrc && !coverSrc.includes("placeholder") ? coverSrc : "";
const initialTags: string[] = [];
const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]");
tagBadges.forEach((el) => {
@@ -143,12 +138,13 @@ Alpine.data("bookDetail", () => {
return {
openSections: { basic: true } as Record<string, boolean>,
coverGenerating: false,
hasExistingCover: !!hasCover,
newCoverPreview: "",
coverFile: null as Blob | null,
coverAction: "keep",
saving: false,
rescanning: false,
resetting: false,
userRating: 0,
ratingHover: 0,
ratingSaving: false,
@@ -459,33 +455,6 @@ Alpine.data("bookDetail", () => {
reader.readAsDataURL(file);
},
async generateCover() {
this.coverGenerating = true;
try {
const formatGroup =
document
.querySelector('[data-format-group]')
?.getAttribute("data-format-group") || "reflowable";
const blob = await generateCoverBlob(fileUrl, formatGroup);
if (!blob) return;
this.coverFile = blob;
this.coverAction = "upload";
const preview = document.getElementById(
"metadata-cover-preview",
) as HTMLImageElement;
if (preview) {
preview.src = URL.createObjectURL(blob);
}
this.newCoverPreview = URL.createObjectURL(blob);
showToast("Cover generated successfully", "success");
} finally {
this.coverGenerating = false;
}
},
removeCover() {
this.coverAction = "remove";
this.coverFile = null;
@@ -559,6 +528,66 @@ Alpine.data("bookDetail", () => {
}
},
async rescanBook() {
if (this.rescanning) return;
this.rescanning = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/rescan`, {
method: "POST",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to rescan book");
}
showToast("Book rescanned successfully", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to rescan book",
"error",
);
} finally {
this.rescanning = false;
}
},
async resetMetadata() {
if (this.resetting) return;
if (
!window.confirm(
"Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.",
)
) {
return;
}
this.resetting = true;
const mediaId = getMediaId();
try {
const resp = await fetch(
`/api/media-items/${mediaId}/rescan?reset_overrides=true`,
{
method: "POST",
headers: { Authorization: getAuthHeader() },
},
);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to reset metadata");
}
showToast("Metadata reset to scanned defaults", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to reset metadata",
"error",
);
} finally {
this.resetting = false;
}
},
async searchEditorTags() {
const libraryId = document.body.getAttribute("data-library-id") || "";
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {
-82
View File
@@ -1,82 +0,0 @@
import { showToast } from "./toast";
function getToken(): string {
return localStorage.getItem("token") || "";
}
export async function generateCoverBlob(
fileUrl: string,
formatGroup: string,
): Promise<Blob | null> {
try {
const View = (await import("foliate-js/view.js")).default;
const view = new View();
const resp = await fetch(fileUrl, {
headers: { Authorization: `Bearer ${getToken()}` },
});
if (!resp.ok) {
showToast("Failed to fetch book file for cover generation", "error");
return null;
}
const blob = await resp.blob();
const file = new File([blob], "book", { type: blob.type });
const pdfOptions =
formatGroup === "fixed_layout"
? {
pdf: {
cMapUrl: "/static/vendor/pdfjs/cmaps/",
standardFontDataUrl: "/static/vendor/pdfjs/standard_fonts/",
},
}
: {};
await view.open(file, pdfOptions);
if (!view.book?.sections?.length) {
showToast("Could not read book sections", "error");
return null;
}
if (formatGroup === "fixed_layout") {
const canvas = document.createElement("canvas");
await view.renderer?.renderPage(view.book.sections[0], canvas);
return new Promise((resolve) => {
canvas.toBlob(
(b) => resolve(b),
"image/jpeg",
0.85,
);
});
}
const coverHref = view.book.cover;
if (coverHref) {
const coverBlob = await coverHref.blob();
if (coverBlob.type.startsWith("image/")) {
return coverBlob;
}
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const c = document.createElement("canvas");
c.width = img.naturalWidth;
c.height = img.naturalHeight;
c.getContext("2d")?.drawImage(img, 0, 0);
c.toBlob((b) => resolve(b), "image/jpeg", 0.85);
};
img.onerror = () => resolve(null);
img.src = URL.createObjectURL(coverBlob);
});
}
showToast("No cover found in book file", "error");
return null;
} catch (e) {
console.error("Cover generation failed:", e);
showToast("Cover generation failed", "error");
return null;
}
}
+241 -29
View File
@@ -406,6 +406,10 @@ document.addEventListener("alpine:init", () => {
tapZonesEnabled: true as boolean,
tapZoneSize: 30 as number,
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
// Any live text selection, host document or content iframe. Fed by
// selectionchange listeners (touch devices); tap zones stand down
// while one exists.
anySelection: false as boolean,
highlightItems: [] as {
id: string;
text: string;
@@ -472,11 +476,20 @@ document.addEventListener("alpine:init", () => {
positionLabel: string;
cfi: string;
page: number | null;
renameOpen?: boolean;
renameText?: string;
}[],
tocItems: [] as any[],
mediaItemId: "" as string,
saveTimeout: null as ReturnType<typeof setTimeout> | null,
initTime: 0 as number,
// Position last known to be stored (the restore at open time, or the
// last successful save). Relocations that don't move from it are never
// written back, so a restored position can't clobber a newer device
// push — while swipe/scroll paging (handled inside foliate, with no
// wrapper method to flag) still saves normally.
lastSyncedCfi: "" as string,
lastSyncedFraction: -1 as number,
lastCfi: "" as string,
contextText: "" as string,
readingTheme: "light" as string,
readingMode: "light" as string,
@@ -558,20 +571,13 @@ document.addEventListener("alpine:init", () => {
formatGroup: string;
readingDirection: string;
mangaType: string;
savedPercentage?: number;
savedCfi?: string;
savedPage?: number;
savedTotalPages?: number;
bookmarks?: {
id: string;
title: string;
positionLabel: string;
cfi: string;
page: number | null;
}[];
}) {
this.mediaItemId = config.mediaItemId;
this.bookmarkItems = config.bookmarks ?? [];
// Reading state (position, bookmarks, annotations) is never baked
// into the rendered page: the web reader is intrinsically tied to
// the server, so it reads all of it from the APIs at open time —
// a device sync between render and open can never be shadowed by a
// stale snapshot.
this.isComic = config.formatGroup === "comic_archive";
// Reading flow for comics is a per-book preference (a webtoon title
// vs. a paged manga volume); read before the renderer is chosen.
@@ -689,6 +695,14 @@ document.addEventListener("alpine:init", () => {
// out to the host document, so the viewport listeners miss them).
if (window.matchMedia("(pointer: coarse)").matches) {
this.attachTapZoneListeners(doc as unknown as HTMLElement, true);
// Same for selectionchange: a selection inside the iframe must
// cancel armed tap actions and feed the host-surface guard.
doc.addEventListener("selectionchange", () => {
const sel = doc.getSelection();
this.noteSelectionActivity(
!!sel && !sel.isCollapsed && !!sel.toString(),
);
});
}
// Text selection → highlight popover (reflowable EPUB only;
// fixed-layout highlight overlays are a later milestone).
@@ -733,6 +747,21 @@ document.addEventListener("alpine:init", () => {
() => setTimeout(checkSelection, 0),
{ passive: true },
);
// Clicks in the book dismiss the popover in ANY mode: iframe
// events never bubble to the host document (so the host
// outside-click dismiss never sees them), and the collapsed
// check above only covers create mode — edit mode had no
// outside-click path at all, leaving Esc as the only way out.
// Clicking a painted highlight still works: this hides, then
// foliate's show-annotation re-opens it in edit mode.
doc.addEventListener(
"pointerdown",
() => {
if (this.selectionPopover.open)
this.hideSelectionPopover();
},
{ passive: true },
);
doc.addEventListener(
"keyup",
(ev: KeyboardEvent) => {
@@ -824,7 +853,13 @@ document.addEventListener("alpine:init", () => {
});
this.view.addEventListener("show-annotation", (e: any) => {
const { value, index, range } = e.detail;
const h = this.highlightItems.find((x) => x.cfi === value);
// Device-synced highlights are painted with a synthesized range
// CFI (renderCfi) while the stored locator stays a point CFI, so a
// click reports the render value — match either or editing
// device highlights is impossible.
const h = this.highlightItems.find(
(x) => x.cfi === value || x.renderCfi === value,
);
if (!h) return;
const doc = this.renderer
?.getContents?.()
@@ -851,6 +886,7 @@ document.addEventListener("alpine:init", () => {
const { fraction, location, pageItem, cfi, tocItem, section } =
e.detail;
this.hideSelectionPopover();
this.lastCfi = cfi || "";
this.lastRelocateDetail = {
fraction,
location,
@@ -896,19 +932,27 @@ document.addEventListener("alpine:init", () => {
document.addEventListener("keydown", (ev: KeyboardEvent) =>
this.handleKeydown(ev),
);
if (this.isFixedLayout && config.savedPage != null && config.savedPage > 0) {
// Reading position comes from the database, fetched fresh at open
// (the rendered page carries no snapshot of it).
const saved = await this.fetchSavedLocation();
if (this.isFixedLayout && saved.page != null && saved.page > 0) {
// Fixed-layout & comics: a page index is the exact, universal locator.
// A bare number navigates directly to the section index in foliate.
await this.view.init({ lastLocation: config.savedPage - 1 })
} else if (config.savedCfi) {
await this.view.init({ lastLocation: config.savedCfi })
} else if (config.savedPercentage && config.savedPercentage > 0) {
await this.view.init({ lastLocation: saved.page - 1 })
} else if (saved.cfi) {
await this.view.init({ lastLocation: saved.cfi })
} else if (saved.percentage != null && saved.percentage > 0) {
await this.view.init({
lastLocation: { fraction: config.savedPercentage },
lastLocation: { fraction: saved.percentage },
})
} else {
await this.view.init({})
}
// The position restored above (or the start of the book on a fresh
// open) is the baseline: only relocations that actually move from
// it may write progress.
this.lastSyncedCfi = this.lastCfi;
this.lastSyncedFraction = this.lastRelocateDetail?.fraction ?? -1;
// The renderer only knows it's a PDF once frames exist (they carry
// pdf.js onZoom), i.e. after init has rendered the first spread.
// Read it now and apply the saved pointer mode — this also makes the
@@ -918,9 +962,19 @@ document.addEventListener("alpine:init", () => {
this.renderer.setAttribute("interaction-mode", this.interactionMode);
}
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
this.initTime = Date.now();
// A bfcache-resurrected page is stale by definition: re-baseline to
// its frozen position so it can't write that back until the user
// actually navigates again.
window.addEventListener("pageshow", (e: PageTransitionEvent) => {
if (e.persisted) {
this.lastSyncedCfi = this.lastCfi;
this.lastSyncedFraction =
this.lastRelocateDetail?.fraction ?? -1;
}
});
this.fetchReadingSpeed();
this.refreshAnnotations();
this.refreshBookmarks();
this.setupChrome();
this.setupTapZones();
},
@@ -983,6 +1037,26 @@ document.addEventListener("alpine:init", () => {
if (!window.matchMedia("(pointer: coarse)").matches) return;
const vp = document.getElementById("reader-viewport");
if (vp) this.attachTapZoneListeners(vp as HTMLElement, false);
// Host-document selections (fixed-layout/PDF text layers, margins):
// selectionchange never crosses iframe boundaries, so register per
// surface.
document.addEventListener("selectionchange", () => {
const sel = document.getSelection();
this.noteSelectionActivity(
!!sel && !sel.isCollapsed && !!sel.toString(),
);
});
},
// Record selection state and abort any armed tap action: a selection
// appearing right after finger-lift means the "tap" was actually a
// long-press selection engaging, and paging away would destroy the
// gesture the user just made.
noteSelectionActivity(hasSelection: boolean) {
this.anySelection = hasSelection;
if (hasSelection && this.tapZoneTimer) {
clearTimeout(this.tapZoneTimer);
this.tapZoneTimer = null;
}
},
attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
let downX = 0;
@@ -990,6 +1064,12 @@ document.addEventListener("alpine:init", () => {
let downT = 0;
let downId = -1;
let moved = false;
// Android fires contextmenu when a long-press engages text
// selection: that press must never resolve into a tap action, even
// when it was released inside the 500ms tap window (the selection
// engaging and the guard racing is exactly how corner selections
// used to page back instead).
let longPressed = false;
surface.addEventListener(
"pointerdown",
(e: PointerEvent) => {
@@ -999,6 +1079,21 @@ document.addEventListener("alpine:init", () => {
downT = Date.now();
downId = e.pointerId;
moved = false;
longPressed = false;
},
{ passive: true },
);
surface.addEventListener("contextmenu", () => {
longPressed = true;
}, { passive: true });
// The browser takes over the gesture (text selection, scroll) with
// pointercancel — no pointerup will follow. Drop the tracked
// pointer so stale state can never match a later touch.
surface.addEventListener(
"pointercancel",
() => {
downId = -1;
moved = false;
},
{ passive: true },
);
@@ -1016,7 +1111,7 @@ document.addEventListener("alpine:init", () => {
(e: PointerEvent) => {
if (e.pointerId !== downId) return;
downId = -1;
if (moved || Date.now() - downT > 500) return;
if (moved || longPressed || Date.now() - downT > 500) return;
if (!this.tapZonesEnabled) return;
const target = e.target as HTMLElement | null;
if (
@@ -1027,6 +1122,10 @@ document.addEventListener("alpine:init", () => {
return;
const sel = isDoc ? (surface as any).getSelection?.() : null;
if (sel?.toString?.()) return;
// Host-surface blind spot: selections living in content iframes
// (or the host's own fixed-layout text layer) never show in a
// per-surface check — the tracked flag covers them.
if (!isDoc && this.anySelection) return;
// No tap actions while a fixed-layout page is zoomed — taps then
// belong to the content (and double-tap zoom).
if (this.isFixedLayout && this.renderer?.zoom != null) return;
@@ -1341,7 +1440,23 @@ document.addEventListener("alpine:init", () => {
if (!resp.ok) return;
const row = await resp.json();
const idx = this.highlightItems.findIndex((h) => h.id === p.id);
// The overlay is keyed by the value it was added with; an edit can
// change it (note/text edits change the synthesized range), so
// remove the old paint before re-adding or it ghosts.
const oldValue =
idx !== -1
? this.highlightItems[idx].renderCfi ||
this.highlightItems[idx].cfi
: "";
if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row);
const newValue =
idx !== -1
? this.highlightItems[idx].renderCfi ||
this.highlightItems[idx].cfi
: "";
if (p.pdfPage < 0 && oldValue && oldValue !== newValue) {
this.view?.deleteAnnotation({ value: oldValue });
}
// Re-add so the overlay redraws with the new color.
if (p.pdfPage >= 0) {
this.renderer?.addRectAnnotation?.({
@@ -1377,7 +1492,11 @@ document.addEventListener("alpine:init", () => {
if (hl?.pdfPage >= 0) {
this.renderer?.removeRectAnnotation?.(id);
} else if (hl?.cfi) {
this.view?.deleteAnnotation({ value: hl.cfi });
// Delete with the value the overlay was added by: device-synced
// highlights paint a synthesized range, not the stored point CFI.
this.view?.deleteAnnotation({
value: hl.renderCfi || hl.cfi,
});
}
this.hideSelectionPopover();
} catch (_e) {
@@ -1451,8 +1570,52 @@ document.addEventListener("alpine:init", () => {
/* ignore note errors */
}
},
// Fresh reading position from the database — the single source of
// truth at open time. Fails soft to a fresh start: change detection
// against the restored baseline guarantees merely opening (even at
// the wrong spot) can never overwrite the stored position.
async fetchSavedLocation(): Promise<{
cfi?: string;
page?: number;
percentage?: number;
}> {
const token = getToken();
if (!token || !this.mediaItemId) return {};
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/progress`,
{
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
},
);
if (!resp.ok) return {};
const row: any = await resp.json();
const cfi: string = row?.epubcfi?.String ?? row?.epubcfi ?? "";
const page: number = row?.current_page?.Int32 ?? row?.current_page ?? 0;
// The stored percentage is a 0-1 fraction.
const pct: number = row?.percentage?.Float64 ?? row?.percentage ?? 0;
return {
cfi: typeof cfi === "string" ? cfi : "",
page: typeof page === "number" ? page : 0,
percentage: typeof pct === "number" ? pct : 0,
};
} catch (_e) {
return {};
}
},
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
if (Date.now() - this.initTime < 5000) return;
// Only an actual change from the last stored position writes
// progress: displaying a restored position must never overwrite a
// newer device push. Swipes and scrolls are handled inside foliate
// with no wrapper method to flag, so position — not intent — is the
// signal. Books without CFIs (fixed layout, PDF) compare fraction.
const currentCfi = cfi || "";
const changed =
currentCfi || this.lastSyncedCfi
? currentCfi !== this.lastSyncedCfi
: Math.abs(fraction - this.lastSyncedFraction) > 1e-4;
if (!changed) return;
if (this.saveTimeout) clearTimeout(this.saveTimeout);
this.saveTimeout = setTimeout(() => {
this.saveProgress(fraction, location, cfi);
@@ -1503,6 +1666,8 @@ document.addEventListener("alpine:init", () => {
},
body: JSON.stringify(body),
});
this.lastSyncedCfi = cfi || "";
this.lastSyncedFraction = fraction;
} catch (_e) {
// silent fail — progress save is non-critical
}
@@ -2079,6 +2244,14 @@ document.addEventListener("alpine:init", () => {
const page = this.isFixedLayout
? (this.renderer?.index ?? 0) + 1
: 0;
// Auto-label mirrors KOReader's convention ("in <chapter title>")
// so every client names unnamed bookmarks identically; falls back
// to a plain "Bookmark" for fixed-layout or TOC-less books.
const chapter = this.lastRelocateDetail?.tocItem?.label as
| string
| undefined;
const title =
!this.isFixedLayout && chapter ? `in ${chapter}` : "Bookmark";
try {
const resp = await fetch(
@@ -2090,7 +2263,7 @@ document.addEventListener("alpine:init", () => {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: `Bookmark at ${this.progressText || "current position"}`,
title,
position: this.isFixedLayout
? `page:${page}`
: cfi
@@ -2130,6 +2303,38 @@ document.addEventListener("alpine:init", () => {
/* ignore bookmark errors for now */
}
},
startBookmarkRename(bookmark: (typeof this.bookmarkItems)[number]) {
bookmark.renameOpen = true;
bookmark.renameText = bookmark.title;
},
async renameBookmark(bookmark: (typeof this.bookmarkItems)[number]) {
const token = getToken();
const title = (bookmark.renameText ?? "").trim();
if (!token || !this.mediaItemId || !title) return;
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/bookmarks/${bookmark.id}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
notes: "",
position: bookmark.positionLabel || "",
}),
},
);
if (resp.ok) {
bookmark.title = title;
}
bookmark.renameOpen = false;
} catch (_e) {
bookmark.renameOpen = false;
}
},
chapterNumberForProgress(): number {
const tocItem = this.lastRelocateDetail?.tocItem;
if (!tocItem?.label) return 0;
@@ -2425,11 +2630,18 @@ document.addEventListener("alpine:init", () => {
},
handleKeydown(event: KeyboardEvent) {
const k = event.key;
// Never hijack keys while the user is typing in a form control.
const tag = (event.target as HTMLElement)?.tagName;
// Never hijack keys while the user is typing in a form control: the
// field must receive h/l page turns, +/ zoom, and caret arrows.
// Escape stays live so popovers/drawers can still be dismissed from
// the keyboard even mid-note.
const t = event.target as HTMLElement | null;
const typing =
tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA";
t?.tagName === "INPUT" ||
t?.tagName === "SELECT" ||
t?.tagName === "TEXTAREA" ||
!!t?.isContentEditable;
this.pokeChrome();
if (typing && k !== "Escape") return;
if (k === "ArrowLeft" || k === "h") {
if (event.altKey) {
event.preventDefault();
@@ -2458,7 +2670,7 @@ document.addEventListener("alpine:init", () => {
} else if (k === "F1") {
event.preventDefault();
this.toggleHelp();
} else if (!typing) {
} else {
if (k === "t") this.toggleTOC();
else if (k === "s") this.toggleSettings();
else if (k === "b") this.addBookmark();