Author SHA1 Message Date
john-okeefe fffe0b17e6 ci(release): name Actions runs 'Release <tag>' instead of the commit message
Release / build-and-push (push) Successful in 2m34s
Add a top-level run-name so the Gitea Actions runs list shows
'Release v0.3.0' rather than the tagged commit's subject. Uses the same
expression (inputs.tag || ref_name) as the TAG env, so it resolves for
both tag pushes and manual workflow_dispatch.
2026-07-30 15:50:02 -04:00
john-okeefe 451aa48aec ci(release): auto-generate release notes via git-cliff
Release / build-and-push (push) Successful in 2m42s
Replace the image-only tag pipeline with a full release workflow that also
publishes a Gitea Release whose body is the annotated tag's message,
generated from Conventional Commits by git-cliff. No hand-written release
notes are required.

- cliff.toml: group commits (Features, Bug Fixes, Refactor, Documentation,
  Tests, Miscellaneous Tasks) with scopes and short-SHA links; emit only the
  current tag's section rather than the full history.
- .gitea/workflows/release.yml: tag-driven. Reads the release body from the
  annotated tag (git tag -l --format), so the tag message and release body are
  a single source of truth. Idempotent create/PATCH; prints the Gitea API
  error body on failure so a 403 names the missing token scope instead of
  failing silently. Adds a workflow_dispatch tag input so manual re-runs
  target the right tag instead of the default branch.
- Makefile: release VERSION=vX.Y.Z generates notes via git cliff --latest
  against a throwaway tag, then creates an annotated tag with
  --cleanup=verbatim so the markdown group headers are preserved (git's
  default cleanup strips lines starting with "#").
- release: project-attached wrapper accepting a positional version arg
  (./release 0.3.0 or ./release v0.3.0) and auto-prefixing v, for ergonomic
  one-command releases.
2026-07-30 15:40:27 -04:00
john-okeefe 5ac407057e fix(search): link results to book detail page and add cover thumbnails
Release / build-and-push (push) Successful in 2m24s
Search results navigated to /bookshelf with no filters instead of the
selected book's page. Results now link to /media/:id and display cover
thumbnails, with cover URLs resolved server-side via ResolveMediaURL.
Removes the dead selectedBook localStorage plumbing.
2026-07-30 14:55:00 -04:00
john-okeefe bf83492bf7 feat(book-detail): add Mark as Read / Unread toggle button
The book detail page had no way to mark a book finished or reset its
read state from the UI. Reading state is modelled by reading_progress
alone, where 'read' is the canonical signal percentage >= 1.0 (used by
the dashboard Recently Read collection, analytics, and sync priority).

Add a single toggle button in the action row (after Read Now) whose
label is server-rendered from completion state:
- not read  -> "Mark as Read"    -> PUT /api/media-items/:id/progress
                                       { percentage: 1.0 }
- read      -> "Mark as Unread"  -> DELETE /api/media-items/:id/progress

Mark as Unread cannot use PUT { percentage: 0 }: the progress handler
silently ignores percentage < 0.005 when existing progress > 0.01
(internal/handlers/media.go anti-regression guard), so DELETE is the
only reliable reset.

If the book has an active sync mismatch (an unresolved sync_conflicts
row), the toggle resolves it first via POST /api/conflicts/:id/resolve
before writing progress. Order matters: resolving sets resolved_at,
arming the 10-minute HasRecentConflictResolution suppression window so
the subsequent progress write does not spawn a brand-new conflict. The
resolve winner is any valid source key from the conflict data (prefers
"web"); it does not affect the final state, which the progress write
sets. A 400 "already resolved" response is tolerated.

Notes, highlights, and ratings are independent of reading_progress (they
reference media_items, not progress) and are never affected by the
toggle. After toggling the page reloads so the progress card, Sync
Progress button, and conflict banner re-render server-side.

- templates/utils.go: add conflictWinnerSource and conflictID helpers.
- templates/book_detail.templ: data-conflict-id/winner on <body> and the
  toggle button.
- web/src/book-detail.ts: toggleRead() + conflictId/conflictWinner/
  readSaving state (read from <body> in init()).
- templates/book_detail_templ.go regenerated.
2026-07-30 13:31:18 -04:00
john-okeefe 33c69e7c71 chore(templates): regenerate stale book_detail_modals templ output
Running `templ generate` to pick up the book_detail changes also
resynced book_detail_modals_templ.go, whose committed output was stale
relative to its source. The regeneration (templ v0.3.1020) reformats
boolean attribute rendering (e.g. `selected`) via
templ.ResolveAttributeValue and reflects pre-existing source additions
such as id/for label associations.

No source (.templ) change in this file; generated output only.
2026-07-30 13:08:25 -04:00
john-okeefe c2f72ca785 docs(bruno): correct rating scale and endpoint paths
The Bruno collection docs mislabeled the rating system and referenced
endpoints that do not exist.

- Update Media Rating.yml: the rating value is a 1-10 integer scale
  (displayed as 1-5 stars with half-star precision), not "typically 1-5".
- opencollection.yml: the rating routes live under
  /api/media-items/:id/rating (not /api/ratings/:media_id), GET returns
  null (not 0) when unrated, and document the PUT upsert route. Correct
  the scale to 1-10 here as well.
2026-07-30 13:08:14 -04:00
john-okeefe ca8c592496 feat(book-detail): add interactive half-star rating widget
The book detail page only displayed user ratings as static, non-clickable
stars. The full rating CRUD stack already existed in the backend
(media_ratings table, POST/GET/PUT/DELETE /api/media-items/:id/rating)
but nothing in the web UI could create or update a rating.

Replace the display-only renderStars output for the user rating with an
Alpine.js widget that:
- Renders 5 stars, each split into two transparent hit zones so the
  underlying 1-10 scale maps to half-star precision (left half = x.5,
  right half = whole star).
- Shows a live hover preview via a ratingHover state field.
- Saves the rating in place through POST /api/media-items/:id/rating
  (which upserts) and reflects the value immediately, with no full page
  reload.
- Displays the numeric value (e.g. "3.5 / 5") and a Clear button that
  issues DELETE to remove the rating.
- Reads the server-rendered value from a new data-rating attribute on
  <body> during the bookDetail component init().

The community rating block is left as a display-only renderStars render
since it is imported metadata, not a user rating.

templates/book_detail_templ.go is regenerated (also picking up templ
v0.3.1020 reformatting of the generated output).
2026-07-30 13:08:00 -04:00
john-okeefe 1f5b0d0164 docs(api): document OPDS pagination links and OpenSearch search
Update the OPDS section of the API reference to reflect the now-working
catalog:

- Document the page/per_page parameters and that paging is driven by the
  rel=next/previous/first/last links plus OpenSearch paging metadata.
- Refresh the example feed XML to show the pagination links, opensearch
  namespace/elements, and standard Atom <title>/<author> elements.
- Document the search endpoint's two modes: OpenSearch description
  (application/opensearchdescription+xml, no q) and results feed (with q),
  with an example description document.
2026-07-30 12:12:58 -04:00
john-okeefe 13cc689bff fix(opds): wire up catalog pagination links and OpenSearch search
The device catalog feed was unusable on paged OPDS clients such as
KOReader: it sliced results into pages but never advertised how to reach
the next page, so clients could only ever fetch the first page (~50 books)
and could not search the catalog.

GetDeviceCatalog:
- Emit the full set of OPDS pagination link relations (self, start, first,
  previous, next, last) pointing at catalog?page=N&per_page=M, with the
  device auth token appended for path-based auth.
- Emit OpenSearch totalResults/itemsPerPage/startIndex metadata.
- Point rel=search at the OpenSearch description (correct MIME type).

SearchDeviceCatalog now branches on the q parameter:
- No q: return an OpenSearch description document whose Url template
  contains the {searchTerms} placeholder, so clients can formulate a query.
- With q: return the existing acquisition results feed, now including
  totalResults.

A pure addCatalogPaginationLinks helper holds the page/URL logic so it can
be unit tested without a database. New handler tests cover middle/first/
last/single/empty pages (correct presence of next/previous) and token
appending.

Ordering is intentionally left unchanged (created_at DESC, grouped by
library).
2026-07-30 12:12:51 -04:00
john-okeefe 9920fd47b9 feat(opds): add OpenSearch pagination metadata and search description
Extend the OPDS feed model so clients can page through large catalogs and
discover how to search them.

Feed changes:
- Add the OpenSearch namespace (xmlns:opensearch) to all feeds.
- Add optional TotalResults/ItemsPerPage/StartIndex fields, serialized as
  <opensearch:totalResults>, <opensearch:itemsPerPage> and
  <opensearch:startIndex>, plus a SetPagination helper.
- Add OpenSearchDescription/OpenSearchUrl types and a NewSearchDescription
  constructor with GenerateXML/GenerateXMLString. This produces the
  OpenSearch description document (application/opensearchdescription+xml)
  that OPDS clients like KOReader fetch to learn the {searchTerms} search
  URL template.

These are building blocks; the handlers are wired up in a follow-up commit.

Tests cover SetPagination, omission when unset, XML emission of the
paging metadata, and OpenSearch description generation/serialization.
2026-07-30 12:12:33 -04:00
john-okeefe 26f695f480 fix(opds): correct feed tests referencing non-existent entry fields
The OPDS feed test suite did not compile or pass:

- TestNewEntry asserted on entry.Creator, but the Entry struct stores the
  creator under Author.Name (the Atom <author><name> element). Assert on
  entry.Author.Name instead.
- TestFeedGenerateXML expected <dc:title>/<dc:creator> elements, but the
  Entry struct emits standard Atom <title> and <author><name>. Update the
  expected substrings to match the actual (correct) output.

These are pre-existing assertion errors unrelated to any field being
removed; the code under test was already correct.
2026-07-30 12:12:18 -04:00
john-okeefe 05370d236a feat(ui): display media counts in library switcher
The UI had no surface showing how many media items have been imported.
Surface the total in the library switcher shown on the Dashboard, Series,
and Collections pages (via the LibrarySwitcher component) and in the
Bookshelf's inline library filter.

- Add a MediaCount field to LibraryData and a TotalMediaCount helper to
  sum counts for the "All Libraries" / "All Books" option.
- resolveLibrary() now fetches per-library counts (one query) and maps
  them onto each LibraryData entry, so the switcher reflects the active
  scope without changing the component's signature.
- Each library option renders "(N)" and the "All" option renders the
  grand total across the user's visible libraries.

The "All" total is the sum of the user's visible libraries, correctly
respecting per-user library visibility rather than a raw global count.

Regenerated templ files for library_switcher and bookshelf.
2026-07-30 11:41:13 -04:00
john-okeefe 114a4574b0 feat(db): add query to count media items per visible library
Add GetVisibleLibraryMediaCounts, which returns the media item count for
each library visible to a given user in a single GROUP BY query over
media_items. It mirrors the visibility logic in GetUserVisibleLibraries
(libraries default to visible unless an explicit false row exists) so
counts can be resolved in one round-trip instead of N per-library
lookups.

Regenerated sqlc bindings (querier.go, queries.sql.go).
2026-07-30 11:41:05 -04:00
john-okeefe 3c9e4d8126 fix(ci): use REGISTRY_TOKEN PAT secret for registry login
Gitea's auto GITHUB_TOKEN lacks the package scope needed to push to the container registry, causing the login step to fail. Switch the login password to a PAT stored as the REGISTRY_TOKEN repo Actions secret (scopes: write:package, read:package).
2026-07-29 17:07:00 -04:00
john-okeefe e7c4c931ee ci(release): add tag-triggered image build & push to Gitea registry
Release / build-and-push (push) Failing after 3m56s
Adds .gitea/workflows/release.yml. On a v* git tag push (or manual dispatch), builds the Dockerfile and publishes to git.linuxhg.com/bookhoard/bookhoard under two tags: the version (${{ gitea.ref_name }}) and 'latest'. Auth uses the auto-provided GITHUB_TOKEN; no secret to manage. Pushes to main do nothing, so work-in-progress commits never ship.
2026-07-29 16:53:43 -04:00
john-okeefe bac84e24ec docs(env): document DB_PORT, SERVER_PORT, BASE_URL, COOKIE_SECURE, IMAGE_TAG in .env.example
Expose the recently-added env-driven compose settings as commented examples so self-hosters and deployers can discover them. All remain optional with defaults.
2026-07-29 16:12:21 -04:00
john-okeefe de8f71b2be refactor(compose): make app/db ports configurable via DB_PORT and SERVER_PORT
Replace hardcoded port literals with env-driven variables so a single change
in .env reconfigures the full stack consistently. Defaults are unchanged
(DB 5432, app 8765), so existing setups need no .env changes.

- DB_PORT (default 5432): drives the db host<->container port mapping,
  Postgres PGPORT (so it listens on the chosen port), and the app's
  DATABASE_PORT connection setting. Lets deployers avoid a host port conflict
  (e.g. another local Postgres) by setting DB_PORT once.
- SERVER_PORT (default 8765): drives the app host<->container mapping, the
  SERVER_PORT the app listens on, and the healthcheck target URL.
- Applied to both the base (docker-compose.yml) and the dev override
  (docker-compose.dev.yml, tests service) so dev and prod stay in sync.
2026-07-29 16:11:01 -04:00
john-okeefe 1129fcae6f fix(compose): make BASE_URL/COOKIE_SECURE configurable, drop obsolete version
Address compose issues surfaced on first production deploy:

- Remove obsolete `version: "3.8"` (ignored by Compose v2; caused a warning).
- Fix BASE_URL: it used compose-time interpolation of ${SERVER_PORT}, which is
  only defined as a runtime container env var (invisible to interpolation) and
  absent from .env. This resolved to an empty string, producing a broken
  `http://localhost:` (no port) and a startup warning. Now
  ${BASE_URL:-http://localhost:8765}, overridable per-deployment via .env.
- Move COOKIE_SECURE from the db service to the app service and make it
  configurable (${COOKIE_SECURE:-false}). It controls the session cookie Secure
  flag, an app concern; on the db service it was a no-op, so the app never
  received it and cookies were always non-secure. Set COOKIE_SECURE=true behind
  a TLS-terminating reverse proxy (Caddy/nginx/traefik), where the app speaks
  plain HTTP internally.
- Image reference unchanged: ${IMAGE_TAG:-latest} (no hardcoded version).
2026-07-29 16:02:32 -04:00
john-okeefe 76c6826920 feat(deploy): split compose into prod base + dev override
Restructure the container setup to support registry-based deployment:
the default docker-compose.yml now pulls a prebuilt app image from the
Gitea container registry instead of building locally, while a new
docker-compose.dev.yml override preserves the local build + integration
test workflow for development.

Why:
- Production and self-hosting should consume a published image, not
  rebuild from source on the host. The default `docker compose up` now
  pulls the app image (git.linuxhg.com/bookhoard/bookhoard) alongside the
  public postgres image, with no build step required.
- Development still needs to build from source and run integration
  tests, so those concerns move to an override file the Makefile applies.
  Shared config (env, volumes, ports, healthchecks) lives in one place to
  avoid drift between environments.

Changes:
- docker-compose.yml (prod base): the app service now references
  `image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}` instead
  of a build context. The tests service is removed (moved to the
  override). IMAGE_TAG lets deployers pin or roll back a specific version.
- docker-compose.dev.yml (new override): adds the local `build:` context
  for the app and defines the integration `tests` service (profile-gated).
  Everything else is inherited from the base file via compose merging.
- Makefile: introduce a COMPOSE variable that merges the base and
  override (`-f docker-compose.yml -f docker-compose.dev.yml`); all dev
  targets now use it. Plain `docker compose` against the base file only
  remains the production path.
- README: quickstart updated to pull and start prebuilt images; clone URL
  points at the Gitea instance.

The development workflow (`make rebuild-app`, `make test-integration`,
etc.) is functionally unchanged.
2026-07-29 15:47:40 -04:00
john-okeefe 75b33fdae6 feat(sync): wire annotation sync into all device and web handlers
Complete the annotation sync pipeline across all ingest and serve paths.
Previously, annotations sent inline with KOReader progress pushes were
silently discarded, and no annotations were ever served back to devices.

INGEST (device → server):

KOReader (koreader.go):
  - Add processBookAnnotations helper that processes inline highlights,
    notes, and bookmarks from every progress push (immediate + checkpoint)
  - Highlights get CRE→CFI position conversion before SaveHighlight
  - KOReader 'notes' (text + notes) stored as highlights with NoteText
    to ensure correct round-trip classification
  - Bookmarks routed through SaveBookmark with device sync data
  - Called from both updateProgressForBook and handleCheckpointSync

Kobo (kobo.go):
  - Markup handler: annotations and bookmarks route through
    AnnotationService (SaveHighlight/SaveBookmark)
  - Bookmark handler: same routing with device sync data
  - SyncFromServer handler: same routing
  - All handlers fall back to direct DB calls when annotationSvc == nil

Web reader (media.go):
  - CreateMediaHighlight → SaveHighlight (Source="web", ModifiedAt=now)
  - CreateMediaNote → SaveNote (Source="web")
  - DeleteMediaHighlight → TombstoneHighlightByID
  - DeleteMediaNote → TombstoneNoteByID (was hard delete, now tombstone)
  - All fall back to old behavior when annotationSvc == nil

SERVE (server → device):

KOReader GetMetadata (koreader.go):
  - Query and serve bookmarks from media_bookmarks table (was missing)
  - Serve deleted_highlights and deleted_bookmarks arrays containing
    device_sync_data + dedup_key for client-side deletion
  - Highlights/notes already served with reverse CFI conversion

Kobo Markup handler (kobo.go):
  - Track processed books during sync
  - Query tombstones per book, extract bookmark_id from device_sync_data
  - Return DeletedAnnotations array in KoboSyncStatus response

Conflict resolution (conflicts.go):
  - Enable annotation conflict types in ResolveConflict handler
  - Add applyAnnotationResolution dispatching to:
    applyHighlightResolution / applyBookmarkResolution / applyNoteResolution
  - Each looks up by dedup_key and applies winner's fields
  - Allow manual override of auto_resolved conflicts
    (changed check from != "unresolved" to == "user_resolved")

Infrastructure:
  - AnnotationService field + SetAnnotationService in router Config
  - Inject AnnotationService into KOReader, Kobo, Media handlers
  - Start tombstone purger goroutine in main.go (24h interval)
  - Test helpers: construct AnnotationService in test setup
2026-07-29 14:49:19 -04:00
john-okeefe 635a9439cb feat(sync): implement annotation support in sync queue processor
Wire AnnotationService into SyncQueueProcessor and implement the three
previously-stubbed execute methods:

- syncHighlight: unmarshals syncData JSON into SaveHighlightRequest,
  applies CRE→CFI conversion via AnnotationService
- syncNote: unmarshals into SaveNoteRequest
- syncBookmark: unmarshals into SaveBookmarkRequest
- Add SyncTypeBookmark to executeSync switch (was hitting default error)

Add enqueue methods for future offline/batch use:
- EnqueueHighlight / EnqueueNote / EnqueueBookmark
- Shared enqueueAnnotation helper creates queue items with
  PriorityCriticalNote and 3 max attempts
- Update types (HighlightUpdate, NoteUpdate, BookmarkUpdate) mirror the
  existing ProgressUpdate pattern

Existing handler behavior is unchanged — annotations still sync
synchronously via AnnotationService. The queue path is available for
retry-on-failure and offline batch processing scenarios.
2026-07-29 14:49:01 -04:00
john-okeefe 3b15766149 feat(sync): add AnnotationService with dedup, LWW, and tombstone management
AnnotationService is the central service for cross-device annotation sync.
It provides SaveHighlight, SaveNote, and SaveBookmark methods that handle
the full sync lifecycle:

Identity (3-layer):
  1. Server UUID (primary key)
  2. Per-device native ID stored in device_sync_data JSONB
  3. Content dedup_key: sha1(normalize(selection_text) + bucket_position)
     - CFI character offsets are stripped for bucketing so the same
       highlight at slightly different offsets still deduplicates
     - Raw positions are preserved in the DB for precise restoration

Resolution policy (LWW):
  - When the incoming annotation has an explicit ModifiedAt timestamp,
    last_modified_at wins
  - When the device sends zero ModifiedAt (creation time only), field-diff
    mode compares content fields (text/color/note/percentage) — if all
    match, the save is skipped; if any differ, the save is applied with
    server-receive-time as the new last_modified_at

Conflict detection:
  - When incoming and existing annotations have different sources (e.g.
    koreader vs kobo) and content differs, an auto_resolved sync_conflict
    is recorded with both sides' data for audit trail
  - Broadcasts a WebSocket conflict notification for real-time UI updates

Tombstone management:
  - Delete-wins: tombstoned annotations block recreation from stale pushes
  - 30-day TTL before physical purge
  - PurgeExpiredTombstones method + StartTombstonePurger goroutine (24h ticker)

Add locators.go with unified bidirectional CFI conversion:
  ConvertToCanonical / ConvertFromCanonical
  - CRE XPointer <-> standard EPUB CFI (for KOReader)
  - KEPUB CFI passthrough (for Kobo)
  - Skips non-reflowable formats (PDF, CBZ, fixed-layout EPUBs)

Add 25 unit tests covering:
  - Dedup key determinism, text normalization, position sensitivity
  - Offset insensitivity (CFI char-offset bucketing)
  - Device sync data merge (preserves existing, overwrites same source)
  - Cross-source detection
  - LWW comparison (newer wins, older skipped, fallback to updated_at)
  - Field-diff mode (identical content skipped, changes applied)
  - Tombstone TTL constant
  - CRE XPointer parsing and classification
  - Standard EPUB CFI classification
2026-07-29 14:48:47 -04:00
john-okeefe 2a15effc3e feat(db): add annotation sync schema, queries, and tombstone support
Add migration columns to media_highlights, media_notes, and media_bookmarks
for cross-device annotation sync:

- dedup_key: SHA-1 of normalized selection text + bucketed position, used
  as the stable cross-device identity for annotations
- last_modified_at / last_modified_source: edit clock for LWW resolution
  and cross-source conflict detection
- deleted / deleted_at: sticky tombstone columns for delete-wins semantics
  with a 30-day TTL before rows are physically purged
- note_text on highlights: stores attached notes from KOReader entries that
  have both selected text and a user note
- Location columns on bookmarks (cfi_position, percentage_location,
  epubcfi_location, chapter_reference, paragraph_reference)
- device_sync_data JSONB on all three tables: stores per-device native
  identifiers (e.g. KOReader pos0/datetime, Kobo bookmark_id) so each
  device can locate and manipulate its own copy of an annotation

Add partial unique indexes on (user_id, media_item_id, dedup_key) where
deleted = FALSE to enforce one active annotation per dedup key.

Add tombstone purge indexes on (deleted, deleted_at) for efficient GC.

New queries:
- GetByDedupKey for all three tables (returns active or most-recent tombstone)
- CreateFull / UpdateForSync for all three tables (populate sync columns)
- TombstoneByDedupKey / TombstoneByID for all three tables
- PurgeExpired* for all three tables (GC past TTL)
- GetActiveAnnotationsForBook (filtered union of highlights + notes)
- GetTombstonedAnnotationsForBook (union of all 3 deleted within TTL)
- GetMediaBookmarks with deleted filter
- GetMediaBookmark (singular) with deleted filter
- Added deleted=FALSE filter to GetMediaHighlights, GetAnnotationsForBook
- CreateAutoResolvedSyncConflict (INSERT with resolution_status='auto_resolved')
2026-07-29 14:48:30 -04:00
john-okeefe 78176c57a5 chore(compose): quote numeric env var values
Quote DATABASE_PORT and SERVER_PORT ("5432", "8765") in docker-compose.yml so they are treated as strings rather than YAML integers, avoiding type-coercion warnings from compose runtimes.
2026-07-29 11:08:25 -04:00
john-okeefe 980aaee0d9 refactor(setup): derive setup-complete status from admin user count
Setup completion was previously tracked by a manually-flipped setup_complete row in system_settings, written via a JWT-protected PUT /api/setup/complete endpoint. This meant any admin user created outside the setup wizard (future CLI, seed scripts, direct DB inserts) would not flip the switch, leaving the app stuck redirecting to /setup.

The trigger is now derived from real data: setup is complete iff at least one admin user exists. This is self-correcting regardless of how users are created, and re-engages setup automatically if all admins are ever removed.

Changes:
- Add internal/setupstatus package with IsSetupComplete() (queries CountAdmins, 10s in-memory cache, fails open on DB error) and Invalidate() to clear the cache. Uses an AdminCounter interface to avoid importing the database package.
- Add CountAdmins sqlc query (SELECT COUNT(*) FROM users WHERE role = 'admin') and regenerate.
- Rewire router/setup.go isSetupComplete() to delegate to setupstatus; drop the old setup_complete setting read, cache vars, and the PUT /api/setup/complete route.
- Call setupstatus.Invalidate() in the auth handler after CreateUser, UpdateUserRole, and DeleteUser so the cache reflects admin-count changes immediately.
- Align first-user promotion in Register to key off !adminExists instead of len(users) == 0, so the two checks cannot diverge.
- Remove the now-dead SetSetupComplete/GetSetupStatus handlers.
- Drop the setup_complete seed row from schema.sql.
- Remove the apiPut('/setup/complete') call from the setup wizard finishSetup(); the admin account created in submitAdmin already marks setup complete server-side.
2026-07-29 11:08:18 -04:00
john-okeefe acfb298b74 Add missing btn-secondary CSS class definition
The btn-secondary class was used 39 times across 15 templates but had
no definition in any global CSS file. The only definition existed in
error.templ's inline styles (intentionally self-contained).

Added .btn-secondary and .btn-secondary:hover to input.css using
theme-aware CSS variables (--accent) consistent with the existing
.btn-primary pattern. Rebuilt style.css via Tailwind.
2026-06-08 21:19:18 -04:00
john-okeefe ce3ab8bcbc Fix invalid templ conditional syntax in metadata editor modal
The Manga Type and Reading Direction select options used invalid inline
if syntax: 'if cond { selected }' which is not valid templ. Replaced
with the correct templ attribute syntax: selected={ boolExpression }
which properly renders the selected attribute when true and omits it
when false.

Also add for/id attributes to link labels to their select elements
for accessibility (manga_type and reading_direction).

Affected lines: book_detail_modals.templ:478-496
2026-06-08 21:19:06 -04:00
john-okeefe 6d44ae884c chore(tests): remove debug tests that depend on local file paths
Remove TestConvertHessBook and TestDebugHess (both referenced a
non-existent Hess EPUB at an absolute local path) and
TestConvertCPByTextSearch (referenced a Crime and Punishment EPUB
in the local uploads directory). These were development-time debug
tests that only worked on the author's machine.

All CFI/KEPUB conversion behavior is already covered by the proper
fixture-based tests (TestKEPUBRoundTrip, TestKEPUBConvertKEPUBToStandard,
TestKEPUBConvertWithEmElements, etc.) which use createTestEPUB and
createTestKEPUB helpers.
2026-06-08 19:45:12 -04:00
john-okeefe f6d98dd7cc feat(sync): convert KEPUB CFI to standard and extract context_text on Kobo push
When a Kobo device pushes a last-read-place bookmark, the server now
converts the KEPUB CFI (with koboSpan wrappers) to a standard EPUB CFI
and extracts surrounding text as context_text for use by other devices
(KOReader, web reader) during their pull-side CFI conversions.

Previously the raw KEPUB CFI was stored verbatim as epubcfi, which
meant foliate and CREngine couldn't resolve it (wrong child indices
due to koboSpan wrappers), and no context_text was available for the
text-search fallback in ConvertStandardToCRE.

Changes:
- kepub_cfi_converter.go: Add ExtractedContext field to
  KEPUBConversionResult, populated from the already-computed
  searchText in both ConvertKEPUBCFIToStandard and
  ConvertStandardCFIToKEPUB (exact-match and percentage-fallback
  paths).
- kobo.go: Add libraryService field and SetLibraryService setter
  (mirrors KOReaderHandler pattern). Add convertKoboCFIToStandard
  helper that resolves EPUB+KEPUB paths, instantiates the converter,
  and returns the converted CFI + extracted context. The last-read-place
  branch in Markup now calls this helper for reflowable formats,
  skipping fixed-layout/comic archives (page-index only).
- router.go: Add LibraryService to router Config.
- sync.go: Wire LibraryService to KoboHandler via SetLibraryService.
- main.go: Pass libraryService through router config.

The conversion is purely additive — if no KEPUB file exists on disk
(e.g. side-loaded EPUB without kepubify conversion), the handler
gracefully skips conversion and stores the raw CFI as before.
2026-06-08 19:45:01 -04:00
john-okeefe e584200369 feat(reader): send context_text with progress for reflowable EPUBs
The web reader now captures ~100 chars of visible text from the
foliate relocate event's range and includes it as context_text in
the progress PUT body for reflowable formats.

This enables the server's reverseByTextSearch fallback in
ConvertStandardToCRE, which is critical for single-file EPUBs
(e.g. 1984) where foliate emits coarse or fake-section CFIs that
CREngine cannot directly resolve. Previously only the KOReader
plugin sent context_text; the web reader's omission left the
fallback unusable, causing percentage-based position estimation
that was off by ~1 page.

Changes:
- Add contextText state property (line 387)
- Extract visible text from e.detail.range in relocate handler,
  normalize whitespace, and slice to 100 chars (lines 508-512)
- Include context_text in saveProgress PUT body for reflowable
  EPUBs only, alongside epubcfi (line 575)

The server-side chain was already wired: media.go accepts it,
progress.go stores it, and koreader.go passes it to the CFI
converter. No Go changes needed.
2026-06-07 11:45:33 -04:00
john-okeefe 9bd23fc1d7 fix(reader): stop sending epubcfi for fixed-layout documents
The web reader's saveProgress always sent epubcfi: cfi || "", even
for fixed-layout comics/PDFs. Foliate's comic renderer generates a
fake CFI (epubcfi(/6/{n})) for every page via CFI.fake.fromIndex,
which the server stored as a valid locator. These fake CFIs are
meaningless — the page index is the canonical locator for image-based
content — and they caused koreader to crash on pull (see prior commit).

Only set epubcfi in the request body for reflowable documents.
2026-06-07 00:27:04 -04:00
john-okeefe 762181c123 fix(sync): treat page index as sole locator for fixed-layout pull path
The push path (koreader→server) was already updated to omit epubcfi
for paging documents, and SaveProgress derives percentage from
page/total_pages for fixed formats. However, the pull path
(server→koreader) still returned any stored epubcfi and attempted
CFI→XPointer conversion, and SaveProgress preserved stale CFI values
written by the web reader (which generates fake CFIs via
CFI.fake.fromIndex for every comic/PDF page).

These fake CFI strings lingered in the database and koreader's pull
path picked them up over progress.page, causing tonumber("epubcfi(...)")
→ nil → GotoPage(nil) → crash when the user confirmed the sync prompt.

Changes:
- GetMetadata (koreader.go): for fixed_layout/comic_archive formats,
  skip returning epubcfi and skip the CFI→CRE XPointer conversion.
  Reflowable documents are byte-for-byte unchanged.
- SaveProgress (progress.go): for fixed formats, explicitly clear
  epubcfi and character_offset on every save so stale values from
  prior web-reader sessions are cleaned up over time.
2026-06-07 00:26:53 -04:00
john-okeefe d11a623f9f fix(sync): skip CRE->CFI conversion for image-based fixed formats
The CRE->CFI converter works by text search / character-offset mapping
across the EPUB spine. Image-based fixed content (fixed-layout comic
EPUBs, PDFs, comic archives) has no extractable text, so the conversion
can never succeed and only wastes time parsing content docs while
logging a failed percentage-precision result.

Guard the conversion in the KOReader progress handler: when the matched
media item's FormatGroup is fixed_layout or comic_archive, skip
ConvertCREToStandard entirely. The incoming xpointer is left as-is so
KOReader<->KOReader restore via GotoXPointer still works; the web reader
restores by page index (the canonical locator for these formats).

This covers fixed-layout comic EPUBs in particular: KOReader routes all
EPUBs through CREngine (has_pages == false), so they send a real
xpointer that passes IsCREXPointer and would otherwise trigger the
doomed text extraction. Reflowable EPUBs (format_group == reflowable)
still run the conversion exactly as before.
2026-06-07 00:04:03 -04:00
john-okeefe caaf27427e feat(reader): apply RTL reading direction for fixed-layout manga/comics
foliate's goLeft/goRight (and spread ordering) swap on book.dir ===
"rtl", but makeComicBook never sets dir, so manga/comic archives
always paged left-to-right even when the metadata says right-to-left.

For fixed-layout content, set this.book.dir = "rtl" from the
readingDirection metadata after the book opens. Reflowable EPUBs are
unaffected: they keep whatever direction foliate read from the OPF.

This is scoped to isFixedLayout (FXL EPUB, PDF, comics) so it cannot
reach the reflowable path.
2026-06-07 00:03:54 -04:00
john-okeefe 423a1c0bf7 feat(sync): use page index as canonical locator for fixed-layout & comic formats
Fixed-layout EPUBs, PDFs, DjVu, and comic archives (cbz/cbr/cb7/cbt)
are page-based: each page is a fixed image, so a page index is an exact,
universal locator regardless of screen size or device. Sync previously
treated these like reflowable content (CFI-first restore, percentage
fallback, character-offset math), which was both wrong and lossy. This
makes the page index the canonical position for fixed-layout and comic
formats while leaving the reflowable path byte-for-byte unchanged.

Backend:
- progress.go SaveProgress: branch on mediaItem.FormatGroup. For
  fixed_layout/comic_archive derive percentage from current_page/
  total_pages and skip the CFI/character-offset back-fills (meaningless
  for image content). The reflowable derivation block is preserved
  verbatim under an else.
- kobo.go: Kobo only sends a percentage, so for fixed-layout/comic
  formats derive CurrentPage via PercentageToPage(percentage, pageCount)
  using the media item's known page count, so Kobo->web lands on the
  exact page.

Reader:
- reader.templ readerInitExpr: pass savedPage/savedTotalPages to the
  reader config for fixed_layout/comic_archive formats.
- reader.ts: when isFixedLayout and savedPage is present, restore via
  view.init({ lastLocation: savedPage - 1 }) (a bare number navigates
  foliate directly to the section index). Reflowable falls through to
  the existing CFI->percentage path, unchanged.
2026-06-07 00:03:46 -04:00
john-okeefe b1d2ccc87c feat(opds): serve comic archives in native format with correct mime types
The OPDS device catalog previously hard-coded every acquisition link as
application/epub+zip and always offered kepub/pdf alternate links, which
is wrong for comic archives (cbz/cbr/cb7/cbt) and other non-epub media.

- Resolve the acquisition mime type from the media item's stored
  mime_type (falling back to format_mimetype, then epub) instead of
  assuming epub
- Only offer reflowable conversions (kepub for kobo, pdf) for ebooks;
  comic archives are served as-is in their native format
- Derive the native format label (epub/pdf/cbz/cbr/...) from the file
  path in ListFormats rather than always reporting epub
- Add resolveMimeType, isComicArchive, and formatLabelFromPath helpers
2026-06-07 00:03:18 -04:00
john-okeefe 3170aa6b77 feat(setup): collect server base URL during first-run wizard
Add a Server URL field to the admin-account step of the setup wizard so
the public base URL (used for device sync, OPDS feed, and API endpoints)
is configured up front instead of requiring a later visit to admin
settings.

- setup.templ: base URL input on the admin step plus a summary entry
- setup.ts: default baseUrl to window.location.origin and persist it via
  PUT /system/config ({ base_url }) right after the admin account is
  created, with a non-fatal warning if the save fails
2026-06-07 00:03:01 -04:00
john-okeefe f75d68bf66 feat(ui): add first-run setup wizard with 4 guided steps
Add a single-page multi-step setup wizard that guides new users
through initial configuration:

Step 1 - Admin Registration: Creates the first user (auto-admin)
  using the existing POST /api/auth/register endpoint, with
  real-time password validation and confirmation matching.

Step 2 - Library Creation: Create one or more libraries (Ebooks,
  Audiobooks, Comics, Manga) using POST /api/libraries. Libraries
  list updates inline as they're added.

Step 3 - Folder Configuration: Add filesystem folders to each
  library using the existing GET /api/libraries/browse endpoint for
  a visual directory browser. Folders are attached via POST
  /api/libraries/:id/folders.

Step 4 - Initial Scan: Triggers a manual scan of all libraries via
  POST /api/libraries/scan with real-time progress polling using the
  existing scan status endpoint.

On completion, the wizard calls PUT /api/setup/complete and sets the
selectedLibrary cookie to the first library's UUID, ensuring the
dashboard loads with populated content instead of an empty 'All
Libraries' view. Handles the edge case where a stale JWT from a
previous database instance triggers an 'already exists' error by
auto-advancing to step 2.

The wizard reuses all existing API calls, Alpine.js utilities, and
form validation functions — no backend logic was duplicated.
2026-06-06 00:04:09 -04:00
john-okeefe f37de6c07c feat(router): add setup redirect middleware and setup routes
Add setupRedirectMiddleware that checks the setup_complete system
setting on every request. If setup is incomplete, all non-setup
requests are redirected to /setup so the wizard is the first thing
new users see. The check uses an in-memory cache (10s TTL) to avoid
hitting the database on every request, with cache invalidation on
setup completion.

The middleware skips /setup, /api/*, /static/*, /health, and
/favicon.ico so the wizard page, API calls, and static assets load
normally during setup.

Register two new routes:
- GET /setup: renders the setup wizard SSR template
- PUT /api/setup/complete: marks setup as complete (JWT-protected,
  requires an authenticated admin user created in step 1)
2026-06-06 00:04:00 -04:00
john-okeefe cea8e64da2 feat(api): add setup_complete system setting and status handlers
Add 'setup_complete' boolean to the system_settings seed data (defaults
to false) so fresh databases start in the unconfigured state.

Add two new handlers to SystemSettingsHandler:
- SetSetupComplete: marks setup_complete=true in the database
- GetSetupStatus: reads the current setup_complete value, returns
  {setup_complete: bool} JSON response, defaults to false if the
  setting row is missing or unparseable
2026-06-06 00:03:52 -04:00
john-okeefe 307a43f6b0 test(sync): add cross-element matching tests, enable <em> KEPUB conversion test
cfi_converter_test.go:
- TestFindTextInNode_SingleTextNode: baseline single-node match
- TestFindTextInNode_CrossEmElement: 'Vokalia and Consonantia' across
  two <em> elements — verifies match returns the 'Vokalia' text node
- TestFindTextInNode_CrossStrongElement: text crossing <strong> boundary
- TestFindTextInNode_DoesNotCrossParagraphs: verifies block boundary
  enforcement — text split across <p> elements is NOT matched
- TestFindTextInNode_NestedFormatting: <em><strong> nesting
- TestFindBlockParent: verifies findBlockParent walks up through inline
  elements to find block-level <p>
- TestCollectInlineText: verifies text segments are collected in order
  with correct content

kepub_cfi_converter_test.go:
- TestKEPUBConvertWithEmElements: previously skipped, now expects exact
  precision and verifies round-trip conversion works for text spanning
  <em> element boundaries
2026-06-03 20:00:54 -04:00
john-okeefe d5018936a0 refactor(sync): rewrite extractSurroundingText to use block-parent text collection
Instead of reading only from the single resolved text node (which
produces a tiny window when the position is inside <em> or other inline
elements), extractSurroundingText now:

1. Finds the block-level parent (e.g. <p>) of the resolved text node
2. Collects all text within that block, transparently crossing inline
   formatting elements via collectInlineText
3. Computes the global offset of the original text node within the
   concatenated block text
4. Extracts the [offset-window : offset+window] slice

This gives a full context window regardless of inline element
boundaries, enabling accurate text bridging between EPUB and KEPUB
documents even when reading positions fall inside <em>, <strong>,
<span class="koboSpan">, etc.

Falls back to single-node extraction when no block parent is found
(e.g. orphan text nodes in tests).
2026-06-03 20:00:40 -04:00
john-okeefe 3a54dda5c5 feat(sync): transparently cross inline formatting elements in text search
When finding or extracting text in EPUB/KEPUB DOM trees, inline
formatting elements like <em>, <strong>, <i>, <b>, <span>, etc. should
not break text continuity. A reader sees 'Vokalia and Consonantia' as
one phrase regardless of the <em> wrappers around each word.

Add inline formatting element set and helper functions:
- isInlineFormatting: checks if an element is an inline phrasing element
- collectInlineText: flattens text across formatting elements within
  a block-level parent, returning segments that map back to original
  text nodes
- findBlockParent: walks up from a text node to find the nearest
  block-level ancestor (used to scope text collection)
- findTextAcrossInlineElements: fallback for findTextInNode that
  concatenates text within each block element (transparently crossing
  formatting elements) and maps match positions back to actual nodes
- collectBlockElements: gathers all block-level elements containing text

The key invariant: text collection NEVER crosses block-level element
boundaries (<p>, <div>, <h1>-<h6>, <li>, etc.) to avoid concatenating
text from different paragraphs.

The findTextInNode function now tries single-text-node matching first
(fast path, unchanged), then falls back to cross-element matching only
when needed. This preserves performance for the common case.
2026-06-03 20:00:26 -04:00
john-okeefe 3c2a504747 test(sync): add comprehensive tests for KEPUB CFI converter
Test coverage for KEPUBCFIConverter with programmatically generated
EPUB and KEPUB zip fixtures:

- KEPUB->Standard text search conversion (exact precision)
- Standard->KEPUB text search conversion with round-trip verification
- Multi-position round-trip (3 phrases across different paragraphs)
- Cross-chapter conversion (spine index 1)
- No-context-text conversion (extracts surrounding text from resolved node)
- Invalid CFI handling (falls back to percentage)
- Percentage fallback for unresolvable CFIs
- 5-paragraph round-trip covering different document positions
- CFI structural difference verification (koboSpan adds DOM steps)
- Spine index consistency between EPUB and KEPUB
- Real book conversion test (skips if file not present)
- extractSurroundingText unit tests
2026-06-02 21:21:56 -04:00
john-okeefe b0dc0c591e feat(sync): add KEPUB <-> standard EPUBCFI bidirectional converter
Add KEPUBCFIConverter in internal/sync that converts between KEPUB CFIs
(which include extra koboSpan DOM steps) and standard EPUB CFIs at sync
time, so only standard epubcfi values are stored in the database.

The converter works by:
1. Resolving the source CFI in the source document (EPUB or KEPUB)
2. Extracting surrounding text at the resolved position
3. Searching for that same text in the target document
4. Building a new CFI pointing to the matched text in the target

This text-content bridging handles the DOM structural differences
between EPUB (text nodes at depth 2) and KEPUB (text nodes wrapped in
<span class="koboSpan"> at depth 3).

Falls back to percentage-based positioning when text search fails,
matching the pattern used by the existing CRE converter.

No changes to existing cfi_converter.go or KOReader conversion code.
Same-package access to unexported functions (resolveCFIToNode, buildCFI,
findTextInNode, etc.) via internal/sync package placement.
2026-06-02 21:21:28 -04:00
john-okeefe 0f225c39b9 fix(templ): correct @click attribute interpolation syntax
Templ requires Go expression interpolation for dynamic attributes,
not string embedding. Change from @click="func('{ id }')" to
@click={ "func('" + id + "')" } for device ID and registration ID
buttons.
2026-06-02 19:46:29 -04:00
john-okeefe bc59159cde feat(ui): inline conflict resolution with Keep This button
Replace the 'Go to Conflicts Page' link with inline conflict resolution.
Each conflict source now has a 'Keep This' button that resolves the
conflict directly from the book detail page.

- Conflict data now keyed by source name (koreader, web) instead of
  new/existing, with Source and Timestamp fields
- Display percentage scaled correctly (* 100)
- Fix page field name from current_page to page
- Add conflict resolution JavaScript in book-detail.ts
- Add 10-minute cooldown after resolution to prevent re-detection
2026-06-02 19:46:17 -04:00
john-okeefe f57d64563b fix(reader): scale percentage correctly, add 5s init guard
- reader.templ: divide DB percentage (0-1) by 100 was wrong; actually
  the router multiplies by 100, so template now divides by 100 to get
  back to 0-1 range for foliate-js savedPercentage
- reader.ts: add 5-second initTime guard to prevent overwriting
  existing progress with initial position on page load
2026-06-02 19:46:03 -04:00
john-okeefe 1f8c3c21ae fix(admin): cascade base_url to opds/api URLs, fix pending registration time format
- UpdateSystemConfiguration: when base_url changes, automatically
  update opds_base_url and api_base_url derived configs
- convertPending: format time.Time as RFC3339 string instead of
  relying on string type assertion which would panic
2026-06-02 19:45:39 -04:00
john-okeefe 5d22021e8e fix(opds): epubcfi Atom compatibility, path deduplication, auth tokens
- Add <title> and <author><name> elements to Atom feed for compatibility
- Remove doubled /opds/opds/ path prefix in feed URLs
- Include ?token= auth param on all OPDS URLs
- Serve kepub links only for Kobo devices
- Fix URL construction for entries and acquisitions
2026-06-02 19:45:21 -04:00
john-okeefe 8e4412544d fix(devices): move device creation from CheckRegistration to ApproveDevice
Previously device creation happened in CheckRegistrationStatus (polling
endpoint), which was racy. Now the admin's ApproveDevice handler
creates the device record and stores auth token + device ID on the
registration entry. CheckRegistrationStatus just returns the pre-created
credentials.

Also adds approved/authToken/deviceID/syncEndpoints fields to
PendingRegistration struct.
2026-06-02 19:45:14 -04:00
john-okeefe d764f820b2 fix(conflicts): use winner's source name instead of 'manual'
When resolving a conflict, the last_sync_source is now set to the
winner's actual source name (koreader, web, etc.) rather than always
'manual'. This prevents subsequent saves from re-triggering conflicts.

Also removes the strict oneof validation on the winner field since
the source name is dynamic.
2026-06-02 19:45:07 -04:00
john-okeefe 39a2829cc8 feat(media): accept context_text from web reader progress saves
Web reader can now send surrounding text at current reading position.
Stored in reading_progress.context_text for use as CFI resolution
fallback when converting epubcfi to CREngine XPointer.
2026-06-02 19:45:00 -04:00
john-okeefe 795f10d2af feat(server): wire libraryService to KOReader handler
Required for CFI converter to resolve EPUB file paths during
bidirectional CFI conversion.
2026-06-02 19:44:53 -04:00
john-okeefe 44f38803dc feat(koreader): bidirectional CFI conversion in sync pipeline
Forward (push): When KOReader pushes a CREngine XPointer, convert it
to standard epubcfi before storing. Uses CFIConverter.ConvertCREToStandard
with context_text for text search fallback.

Reverse (pull): When KOReader pulls progress, convert stored standard
epubcfi back to CREngine XPointer via CFIConverter.ConvertStandardToCRE.
Returns as koreader_xpointer field in GetMetadata response.

Other changes:
- updateProgressForBook: pass context_text to SaveProgress
- enqueueProgressForBook: pass context_text to queue
- KOReaderProgressData: add KoreaderXPointer field
- New convertCFIToXPointer helper method
- Wire libraryService in main.go for EPUB path resolution
2026-06-02 19:44:47 -04:00
john-okeefe b2b1804aa3 feat(sync): plumb context_text through SaveProgress and sync queue
- SaveProgressRequest: add ContextText field
- SaveProgress: carry existing ContextText from DB, overwrite when provided
- ProgressUpdate: add ContextText field for checkpoint sync
- SyncQueueProcessor: serialize/deserialize context_text in sync data
- buildProgressSnapshot: include context_text in snapshot data
2026-06-02 19:44:38 -04:00
john-okeefe c82f20c3f2 feat(db): add context_text column to reading_progress
Stores surrounding text (~100 chars) at the reader's current position.
Used as fallback for CFI resolution when converting between epubcfi
and CREngine XPointer formats.

Updates:
- schema.sql: add context_text TEXT column, update stored procedure
- queries.sql: add context_text to GetUniversalProgress and
  UpdateUniversalProgress queries
- Regenerate sqlc Go code (models.go, querier.go, queries.sql.go)
2026-06-02 19:44:31 -04:00
john-okeefe 5a76fed099 feat(sync): add CFI converter for CREngine XPointer to standard epubcfi
Implements ConvertCREToStandard which converts CREngine XPointers
(e.g. /body/DocFragment[6]/body/div/p[47]/text().2399) to standard
epubcfi format (e.g. epubcfi(/6/12!/4/2[id]/4/1:7)).

Key components:
- indexChildNodes: faithful port of foliate-js's epubcfi.js algorithm
  for computing CFI-compatible child node indices including virtual
  positions, null positions between adjacent elements, and text chunks
- preprocessXHTML: converts XHTML self-closing tags (e.g. <a id="x"/>)
  to open/close pairs so Go's HTML parser produces the same DOM as the
  browser's XHTML parser
- buildCFI: walks up from a text node to body, computing CFI indices
  at each level using indexChildNodes
- findTextInNode: regex-based whitespace-flexible text search for
  context_text fallback positioning
- convertByPercentageOffset: estimates position via book-wide character
  counts when no context_text is available
- ConvertCREToStandard: orchestrates text search → percentage fallback

Supports CREngine XPointer format, CREngine fragment ID format
(#_doc_fragment_N_anchor), and includes round-trip test coverage for
1984 and Crime and Punishment EPUBs.
2026-06-02 19:44:05 -04:00
john-okeefe 342c2f88b2 chore(deps): update htmx.min.js to latest from npm
Re-copy htmx.min.js from node_modules/htmx.org as part of the build
process (npm run build:ts:dev). Minor size increase from 51238 to 51250
bytes.
2026-05-27 11:22:20 -04:00
john-okeefe 854a888306 fix(toast): show success toast for HTMX JSON responses
Previously, when HTMX form submissions (profile update, password change)
returned a successful JSON response like {"message": "profile updated
successfully"}, the raw JSON was swapped into the target div as plain text.

The htmx:afterSwap listener in toast.ts only handled error responses.
Extend it to also intercept successful 2xx JSON responses that contain a
"message" field, showing a green success toast and clearing the raw JSON
from the target element. Only JSON responses are intercepted (checked via
Content-Type header), so legitimate HTML swaps are unaffected.
2026-05-27 11:22:13 -04:00
john-okeefe a4b91393a6 chore: remove completed TIMEZONE_PLAN.md
The timezone feature has been fully implemented across the codebase
(database queries, API handlers, profile form, reader settings).
This planning document is no longer needed.
2026-05-27 11:22:04 -04:00
john-okeefe 0af2ee4951 fix(scanner): replace golang.org/x/text/cases.Title with manual titlecase to prevent panic on Unicode expansion
The cases.Title caser panicked with 'slice bounds out of range' when
processing certain Unicode characters that expand during case transformation
(e.g. ß → SS). This panic crashed the entire server during scanning, causing
WebSocket disconnections and failed scan requests.
2026-05-27 11:10:28 -04:00
john-okeefe d78a182f0d feat(header): add responsive mobile hamburger menu with slide-down panel
On viewports below 970px the header now collapses to a compact bar with
only the Bookhoard logo and a hamburger button. Clicking the hamburger
reveals a slide-down panel containing:

- Full-text search input (wired to the existing debounced search API)
- Navigation links (Library, All Books, Series, Collections, Progress, Devices)
- Collapsible theme switcher with all 7 themes and 4 bookshelf backgrounds
- User section: profile/admin/logout when logged in, inline login form when logged out

Changes:
- templates/header.templ: add hamburger button, mobile panel with all controls,
  hide desktop search/theme/user controls below nav breakpoint
- web/src/header.ts: add mobileMenuOpen state to Alpine header component
- web/src/search.ts: refactor initializeSearch to wire both desktop and mobile
  search inputs, track active input for results container placement
- tailwind.config.ts: add custom 'nav' screen breakpoint at 970px so the
  mobile menu activates before the search bar becomes unusable
- web/static/style.css: rebuilt with new nav breakpoint utility classes
2026-05-24 21:20:36 -04:00
john-okeefe 13260d8c1f chore(templates): regenerate all _templ.go files with templ v0.3.1020
Upgrade from templ v0.3.1001 to v0.3.1020. Generated code changes include
JoinStringErrs -> ResolveAttributeValue and removal of manual EscapeString
calls (now handled internally by ResolveAttributeValue).
2026-05-24 21:20:24 -04:00
john-okeefe 6fcd7f4400 fix(docker): add Go build cache mount and remove unnecessary -a flag
Add --mount=type=cache for Go build cache to speed up repeated builds.
Remove the -a flag which forces full rebuilding of all packages and is
unnecessary when using cache mounts.
2026-05-24 21:20:12 -04:00
john-okeefe 6e8986bea5 fix(docker): use npm install instead of npm ci for self-contained builds
npm ci requires a package-lock.json and installs exactly what it specifies.
npm install resolves from package.json directly, making the Docker build
fully self-contained — you can clone the repo and run docker build with no
local Node tooling or pre-existing lockfile.
2026-05-24 20:23:57 -04:00
john-okeefe 87da5c3cf5 fix(reader): fix PDF rendering broken by Vite bundling of foliate-js
PDFs failed to open with error:
  Invalid factory url: "http://localhost:8765/static/undefined"

Root cause: foliate-js's pdfjsPath() uses new URL(dynamicPath, import.meta.url)
to resolve runtime asset paths (standard_fonts/, cmaps/). Vite transforms this
pattern into a static asset map lookup at build time, but can only resolve
known static file paths — not dynamically-constructed directory paths. The
lookup returns undefined, producing a broken URL.

Fix (two parts):

1. foliate-js fork (commit d164d6f): Export an overridable config.pdfjsPath
   function. Module-level code (worker, CSS) continues using import.meta.url
   directly (works fine with Vite for static filenames). The makePDF function
   uses config.pdfjsPath for runtime paths, allowing consumers to override it.

2. Bookhoard changes:
   - Update foliate-js dependency to d164d6f
   - Override config.pdfjsPath in reader.ts to resolve to /static/vendor/pdfjs/
   - Add Vite plugin (pdfjsAssets) that copies standard_fonts/ and cmaps/ from
     node_modules to the build output during vite build (the standard approach
     used by react-pdf and other pdfjs-dist consumers)
   - Remove manual cp commands from build:ts scripts
2026-05-24 20:23:41 -04:00
john-okeefe fb9427a864 refactor(makefile): auto-detect container runtime, remove systemd workarounds
Replace hardcoded 'podman' with auto-detected CONTAINER_RUNTIME variable
that prefers docker and falls back to podman. Override with:
  CONTAINER_RUNTIME=podman make rebuild-app

Remove the ensure_healthy and compose_up macros that worked around
podman-compose hanging on non-systemd systems (e.g., Void Linux with
runt). These are no longer needed — healthchecks are now handled by
plain wait loops in the targets themselves, and compose up -d no longer
blocks on health conditions.
2026-05-24 20:23:23 -04:00
john-okeefe 0af940c7b3 chore(database): regenerate with sqlc v1.31.1
Regenerated database code after sqlc version upgrade from v1.30.0 to
v1.31.1. No functional changes — only the version header in generated
files was updated.

Files: db.go, models.go, querier.go, queries.sql.go
2026-05-24 20:23:08 -04:00
john-okeefe f8745da6b2 chore(deps): update htmx.min.js 2026-05-23 23:48:13 -04:00
john-okeefe 604a2458e9 fix(makefile): add non-systemd podman healthcheck workaround
Podman relies on systemd timers to schedule automatic healthchecks. On
non-systemd systems (e.g., Void Linux with runit), healthchecks never
fire, which causes podman-compose to hang forever waiting for
service_healthy conditions that never resolve.

Add two Make macros to handle this transparently:

- compose_up: runs podman compose up -d normally on systemd, but with
  a 15-second timeout on non-systemd to create containers without
  hanging. Supports passing compose flags via $(call compose_up,args).

- ensure_healthy: on non-systemd systems, waits for the database to
  accept connections, manually triggers its healthcheck, starts the app
  container, waits for the app health endpoint, and triggers its
  healthcheck. On systemd systems, the runtime check is skipped entirely
  (zero overhead).

Both macros use a runtime shell check for /run/systemd/system, so the
same Makefile works identically on all systems without parse-time
conditionals.

Applied to all compose-up targets: up, rebuild, rebuild-force,
rebuild-force-db, rebuild-app, rebuild-app-force, test-integration,
and test-env-up.

Refs: https://github.com/containers/podman/pull/27033
2026-05-23 23:48:06 -04:00
john-okeefe 666b72c4fd feat(router): cookie-aware SSR library resolution with resolveLibrary helper
- helpers.go: Promote getText() from a local closure in frontend.go
  to a package-level function so it can be used by resolveLibrary.
  Add resolveLibrary(c, cfg, user.ID) helper that:
    1. Reads library_id query param (explicit navigation wins)
    2. Falls back to selectedLibrary cookie — validates __all__
       sentinel or real UUID, rejects garbage values silently
    3. Falls back to user's first visible library
  Returns LibraryResolution struct with LibraryID, IsAll, LibUUID,
  Libraries, and FirstID — eliminating repeated boilerplate across
  all SSR routes.

- frontend.go: Replace manual library resolution boilerplate in 5
  SSR route handlers (series, tags/detail, bookshelf, dashboard,
  collections/:id) with resolveLibrary(). Each route now gets cookie-
  aware library selection for free. Collection detail correctly
  handles All Libraries mode for both system and user collections.
  Dashboard no longer makes a redundant second GetUserVisibleLibraries
  call.
2026-05-18 17:53:42 -04:00
john-okeefe 7221906d53 feat(ts): centralized library storage with cookie-based SSR support
- storage.ts: Centralize ALL_LIBRARIES = "__all__" sentinel constant.
  setSelectedLibrary() now writes both localStorage and a cookie
  (selectedLibrary, Path=/, SameSite=Lax, max-age=365d). The
  sentinel "__all__" is used in both storage mediums — empty strings
  are never stored. getSelectedLibrary() maps __all__ back to "".
  Cookie enables server-side rendering to read the stored library
  selection without access to localStorage.

- library-switcher.ts: Import ALL_LIBRARIES and setSelectedLibrary/
  getSelectedLibrary from storage.ts instead of managing localStorage
  directly. Remove local constants.

- dashboard.ts: Remove duplicate localStorage.setItem call that was
  overwriting the __all__ sentinel with raw empty string. Fix
  reloadPage() and scan-complete handler to work with empty libraryId.
  openDashboardSettings/saveDashboardSettings show clear messages for
  All Libraries mode.

- collections.ts: Remove library switcher initialization from the
  collections list page — the list page no longer has a switcher.

- series.ts: Rewrite to use initLibrarySwitcher from library-switcher
  module and switchWithTransition for navigation. Series card links
  no longer include library_id in their URLs.

- bookshelf.ts: Autocomplete fetch calls handle empty libraryId
  correctly for All Libraries mode.

- search.ts, collection-rules.ts: Use setSelectedLibrary() and
  getSelectedLibrary() from storage.ts instead of direct localStorage
  access.
2026-05-18 17:53:27 -04:00
john-okeefe 0a9cde0fc8 chore(templates): regenerate all _templ.go files
Regenerate all templ-generated Go files. These changes are caused
by running templ generate with a slightly different CLI version
(v0.3.1001) than the go.mod dependency (v0.3.1020), resulting in
minor formatting/import diffs across all templates. No functional
changes.
2026-05-18 17:53:12 -04:00
john-okeefe d3a510d2e0 fix(templates): library switcher and bookshelf filter improvements
- bookshelf.templ: Fix form field name from "library" to "library_id"
  to match the handler's QueryParam("library_id"). Add "All Books"
  as the default option in the library filter dropdown. The bookshelf
  uses its own inline filter, NOT the universal library switcher.

- collections.templ: Remove @LibrarySwitcher from the collections list
  page — collections are not library-specific, so the switcher was
  misleading. Fix data-id interpolation bug where {collection.ID} was
  rendered as literal text instead of being interpolated.

- series.templ: Replace inline library selector with the universal
  @LibrarySwitcher component. SeriesCard links no longer include
  library_id since series detail always shows all books.
2026-05-18 17:52:49 -04:00
john-okeefe a6f5d8d693 refactor(handlers): relax library_id validation for All Libraries
- dashboard.go: library_id query param is now optional. Empty/missing
  library_id is passed as pgtype.UUID{Valid: false} to the service
  layer, enabling All Libraries mode.

- series.go: library_id is optional for series listing. GetSeriesBooks
  no longer receives a libraryID — it always returns all books in a
  series regardless of library.

- collections.go: Restructure GetCollection to handle system
  collections (query_type != "") with an optional libraryID. When
  libraryID is empty (All Libraries), GetDashboardSections receives
  pgtype.UUID{Valid: false} so no library filter is applied.
2026-05-18 17:52:37 -04:00
john-okeefe 6a352a6afb refactor(services): accept optional libraryID for All Libraries support
- dashboard_service.go: Change libraryID parameter from uuid.UUID to
  pgtype.UUID across GetDashboardSections, GetDashboardPreferences,
  and all helper methods. pgtype.UUID{Valid: false} now signals
  "no library filter" (All Libraries), which gets passed through
  to sqlc.narg() in the SQL layer.

- series_service.go: Drop libraryID parameter from GetSeriesBooks
  entirely. Series are not library-specific — all books in a series
  are shown regardless of which library they belong to.
2026-05-18 17:52:24 -04:00
john-okeefe e7a4f0f758 refactor(sql): use sqlc.narg() pattern for optional library_id in all library-filtered queries
Convert 12 SQL queries to use sqlc.narg('library_id') instead of
direct @library_id parameters. This allows passing a NULL/invalid
pgtype.UUID to mean "no library filter" (i.e., All Libraries),
making the SQL layer correctly handle the optional filter via:
  (sqlc.narg('library_id')::uuid IS NULL
   OR mi.library_id = sqlc.narg('library_id')::uuid)

Also remove the library_id filter from GetSeriesBooks entirely —
a series is a series regardless of library.

Queries affected:
- GetDashboardSections, GetRecentlyAdded, GetInProgress
- GetHighestRated, GetMostRead, GetAbandonedBooks
- GetLeastRead, GetBooksByTag, GetCollectionItemsForDashboard
- SearchMediaItemsUnified, GetSeriesCardsData

Generated code (queries.sql.go, querier.go) regenerated via sqlc.
2026-05-18 17:52:13 -04:00
john-okeefe 64d3e8d272 feat(frontend): integrate shared library switcher into all pages
Wire up the shared library switcher module on dashboard, collections
list, collection detail, and collection rules pages. All pages use SSR
for initial load and AJAX with fade transitions on library switch.

web/src/collections.ts:
- Add initCollectionsPage() that auto-detects list vs detail page
  by checking for #collection-data element
- Collections list: onSwitch fetches /api/collections?library_id=X and
  re-renders the grid with per-library book counts
- Collection detail: onSwitch fetches /api/collections/:id?library_id=X
  and re-renders the books grid
- Add renderCollectionsGrid() and renderCollectionBooks() with
  Alpine.initTree() calls for dynamic content
- Collection cards now link with ?library_id= from selected library
- Update hidden #collection-data data-library-id on switch

web/src/dashboard.ts:
- Replace standalone switchLibrary() with initLibrarySwitcher() +
  switchWithTransition() from shared module
- Extract fetchAndRenderSections() helper shared by onSwitch callback,
  reloadPage(), and saveDashboardSettings()
- Remove inline #library-select change listener and switch-library
  data-action handler (now handled by shared module)
- Scan-complete event handler unchanged (independent incremental logic)

web/src/collection-rules.ts:
- Update backToCollection() to preserve library context by appending
  ?library_id= from localStorage selectedLibrary key
2026-05-17 21:12:45 -04:00
john-okeefe 8e48aa4334 feat(templates): add library switcher to collections and dashboard pages
Replace inline library switcher HTML with shared LibrarySwitcher component
across all collection pages and the dashboard.

templates/collections.templ (Collection):
- Update signature to accept libData []LibraryData, currentLibraryID
- Add @LibrarySwitcher(libData, currentLibraryID) after header
- Change x-init to initCollectionsPage() for unified initialization

templates/collections.templ (CollectionDetail):
- Update signature to accept libData []LibraryData
- Add @LibrarySwitcher(libData, libraryID) after header
- Fix broken "Back to Collections" button: replace non-existent
  backToCollections Alpine method with a plain <a href="/collections"> link
- Change x-init to initCollectionsPage() for unified initialization

templates/dashboard.templ:
- Replace 46-line inline sticky library selector (lines 20-66) with
  @LibrarySwitcher(libData, currentLibraryID, DashboardActions())
- Dashboard-specific settings and refresh buttons extracted into the
  DashboardActions sub-component via the variadic actions parameter
2026-05-17 21:12:26 -04:00
john-okeefe 2fa3764df5 feat(ssr): pass library data to collection page templates
Update frontend route handlers for /collections and /collections/:id
to fetch user-visible libraries and pass libData + currentLibraryID
to templates, enabling the library switcher dropdown.

/collections handler:
- Fetch GetUserVisibleLibraries for the current user
- Derive currentLibraryID from query param, falling back to first library
- Convert to []templates.LibraryData and pass to Collection template

/collections/:id handler:
- Fetch GetUserVisibleLibraries alongside existing book fetching
- Pass libData to CollectionDetail template alongside existing libraryID
- Refactored to use shared libraryID variable across system/user paths
2026-05-17 21:12:13 -04:00
john-okeefe faef4d9fff feat(api): add library_id filtering to collections endpoints
Add optional library_id query parameter support to GetCollections and
GetCollection API handlers for library-scoped book filtering.

GetCollections (GET /api/collections?library_id=X):
- When library_id is provided, include per-library book_count in the
  response by querying GetCollectionItemsForDashboard for each collection
- When omitted, returns all collections as before (backward compatible)
- Added BookCount field to CollectionResponse struct

GetCollection (GET /api/collections/:id?library_id=X):
- System collections (non-empty QueryType): uses DashboardService to
  fetch library-scoped sections, matching the existing SSR handler logic
- User collections: uses GetCollectionItemsForDashboard for
  library-filtered results, excluding soft-deleted items
- When library_id is omitted, returns all books as before
2026-05-17 21:12:01 -04:00
john-okeefe a2fc2613b4 feat(library-switcher): add shared library switcher component and module
Add reusable library switcher infrastructure that can be used across
dashboard, collections list, and collection detail pages.

New files:
- templates/library_switcher.templ: Shared LibrarySwitcher component
  with variadic actions slot for page-specific buttons (e.g. dashboard
  settings/refresh). Includes DashboardActions sub-component.
- web/src/library-switcher.ts: Shared module providing:
  - initLibrarySwitcher(): syncs dropdown with localStorage, attaches
    change listener with configurable onSwitch callback
  - switchWithTransition(): generic fade-out -> spinner -> fetch ->
    fade-in transition used by all pages
  - getCurrentLibraryId(): reads "selectedLibrary" from localStorage

Modified:
- web/src/main.ts: import new library-switcher module
- web/src/types/api.d.ts: add book_count field to CollectionData
2026-05-17 21:11:47 -04:00
john-okeefe f23ee6e66a chore(templates): regenerate all templ Go files, add scan spinner to header
Regenerated templ output for all template files. Key source change:
- templates/header.templ: add scan progress spinner SVG and percentage
  display to header nav, initialize scan listener via x-init
2026-05-16 19:31:52 -04:00
john-okeefe 73fc609d7b fix(tests): protect dev admin from test cleanup, use isolated test names
Tests were deleting the development admin user, causing ON DELETE SET NULL
to cascade and set created_by_admin_id to NULL on all libraries.

- test_helpers: skip deletion of testuser@tests.bookhoard.internal
- sync_integration_test: use test-sync% prefix for isolated test data
2026-05-16 19:31:46 -04:00
john-okeefe 5dca78789d fix(docker): exclude uploads/ from build context
The uploads/ directory is bind-mounted at runtime via docker-compose and
should not be copied into the Docker build context. This was slowing down
builds and including potentially large media files in the context.
2026-05-16 19:31:39 -04:00
john-okeefe 37e092c2a8 feat(dashboard): dynamic scan-complete refresh without page reload
When a scan completes, dynamically update the dashboard carousels instead of
requiring a full page reload:

- Listen for bookhoard:scan-complete custom event dispatched by header
- Fetch updated sections from /api/dashboard/sections
- Diff existing book cards by data-media-item-id attribute
- Prepend new items to carousel tracks (afterbegin) to match API sort order
- Create entirely new section DOM for sections that don't yet exist on page
- Remove 'No items' placeholder when items are added
- Scroll carousel to left (scrollLeft=0) so newly prepended items are visible

Also:
- Extract renderSectionHTML() helper from renderDashboardCollections() for reuse
- Add data-media-item-id attribute to book card template for DOM diffing
- Add diagnostic console.log statements for debugging scan-complete flow
2026-05-16 19:31:34 -04:00
john-okeefe 4f7794767d feat(header): add scan progress spinner and dispatch scan-complete event
Add a scan progress indicator to the header that shows during library scans:
- Spinning SVG icon next to the BookHoard title
- Percentage display during active scans
- Dispatches bookhoard:scan-complete custom DOM event on window when scan
  finishes, enabling other components (dashboard) to react without polling
- Auto-resets progress display after 3 seconds
- Uses WebSocket pub/sub via addListener/removeListener with cleanup on
  header element removal
2026-05-16 19:31:23 -04:00
john-okeefe d61abb1be1 refactor(websocket): convert to pub/sub pattern with addListener/removeListener
Replace the single-listener createWebSocket pattern with a pub/sub model
using addListener/removeListener. This allows multiple components (header
spinner, dashboard refresh) to subscribe to WebSocket messages independently
without clobbering each other's handlers.

- Maintain a Set of message listeners
- Auto-connect on first addListener, auto-disconnect when last listener removed
- Retain reconnect logic with configurable delay
2026-05-16 19:31:16 -04:00
john-okeefe 1afc202ba9 chore(server): call SyncAllowedExtensions on startup 2026-05-16 19:31:09 -04:00
john-okeefe 5caebcfe45 feat(worker): broadcast scan_complete WebSocket message on scan job finish
The MessageTypeScanComplete constant existed but was never actually sent by
the worker. This meant the frontend had no way to know when a scan finished.

- After a JobTypeScan completes, broadcast scan_complete to the job's user
  via WebSocket ConnectionManager
- Includes job_id, files_scanned, new_items, and errors in the payload
- Only broadcasts for JobTypeScan (not other job types) when connManager
  is available and job.UserID is set
2026-05-16 19:31:02 -04:00
john-okeefe b1e85dca79 fix(auth): hand over library and media item ownership on admin deletion
When an admin was deleted, the ON DELETE SET NULL foreign key would set
created_by_admin_id to NULL on all their libraries. This caused the scanner
to fail to find an admin ID for broadcasting scan-complete WebSocket messages.

- On admin deletion, reassign all libraries and media items to the next admin
- Prevents created_by_admin_id from ever being NULL on active libraries
- Uses new ReassignLibraries and ReassignMediaItems DB queries
2026-05-16 19:30:54 -04:00
john-okeefe b8dc4e87d5 fix(library): sync allowed extensions from Go source of truth to DB on startup
AllowedExtensions in Go was the intended single source of truth for library
type file extensions, but it was never synced to the database. This caused
missing extensions like .pdf for manga to be absent from library_types.

- Add SyncAllowedExtensions() to sync Go AllowedExtensions map to DB
- Call SyncAllowedExtensions() from cmd/server/main.go on startup
- Ensure .pdf is included in manga extensions
2026-05-16 19:30:46 -04:00
john-okeefe 0855dd7b3b fix(scanner): replace mtime polling with recursive fsnotify watching
The root cause of scanner failures in Podman containers was NOT that
inotify doesn't work through bind mounts (it does — same kernel, same
inodes). The real bug was SetFolders() only watching root directories.
Linux has no recursive inotify — every subdirectory must be added
individually to the watcher.

Changes:
- SetFolders() now walks all subdirectories and adds each to the watcher
  (same approach as Audiobookshelf/Kavita)
- Remove broken mtime-based detection: seedDirectoryMtimes,
  pollDirectoryChanges, detectChangedRoots, checkDirectoryMtimes,
  SyncFilesystemWithDatabase — all unreliable in container overlay mounts
- Replace StartPolling with startBackupScan: enqueues full JobTypeScan
  every 5 minutes (down from 30) as a safety-net fallback
- enqueueLibraryScan() sets job.UserID from admin ID so the worker can
  broadcast WebSocket messages
- performInitialScan() sets job.UserID for the same reason
- Add [WATCHER] prefix logging to all fsnotify event loop messages
- Add defense-in-depth: fallback to GetFirstAdmin() when library has
  no created_by_admin_id (NULL from test cleanup)
- Fix processDirectoryScanJob to use prefix-match (GetLibraryByFolderPathPrefix)
- Fix nil context panic: all jobs now set Context: context.Background()
- Remove mtime-related tests; update default interval test from 30m to 5m
2026-05-16 19:30:39 -04:00
john-okeefe d0460885ff feat(db): add imported_at column to media_items for accurate "Recently Added" sorting
The created_at column stores file modification time (intentional for preserving
original metadata), but this makes 'Recently Added' sorting unreliable for
imported files. Add imported_at column that records the actual database insert
timestamp.

Changes:
- Add imported_at TIMESTAMPTZ column to media_items (nullable)
- Update GetRecentlyAddedItems to sort by imported_at DESC NULLS LAST first
- Add ReassignLibraries and ReassignMediaItems queries for admin deletion handover
- Add SyncLibraryTypeExtensions query for startup extension sync
- Update all media_items SELECT queries to include imported_at column
2026-05-16 19:30:27 -04:00
john-okeefe ced90cd1f4 feat(scanner): add directory mtime-based fast polling for container environments
Podman rootless containers with overlay storage do not propagate inotify
events through bind mounts, making the fsnotify file watcher ineffective.
This caused new files added on the host to go undetected until the
5-minute full-filesystem-walk polling fallback caught them.

Add a lightweight directory mtime polling mechanism that runs every 10
seconds, checking stat() on all subdirectories under watched library
folders against a cached mtime value. When a directory's mtime changes
(indicating files were added/removed/renamed), it feeds into the existing
markDirectoryDirty() → processDirtyDirectories() → job queue pipeline.

Changes:
- Add dirMtimes cache + mutex to MediaScanner struct
- Add seedDirectoryMtimes() to populate cache on startup (prevents
  false-positive flood on first poll)
- Add pollDirectoryChanges() goroutine (10s ticker) and
  checkDirectoryMtimes() (walks directories, compares mtimes)
- Launch mtime poller from WatchChanges() alongside existing goroutines
- Rename StartPolling logs to [ORPHAN-CLEANUP] to clarify its role
- Change default poll interval from 60s → 30m (new file detection now
  handled by the fast mtime poll; full sync focuses on orphan cleanup)
- Update GetScanSettings default from 60 → 1800 seconds
- Add 5 tests: seed cache, skip nonexistent, detect new dir, skip
  unchanged, detect modified dir

Expected result: new files detected in ~20 seconds (10s poll + 10s
debounce) regardless of inotify/container support.
2026-05-12 16:54:35 -04:00
john-okeefe 4b43bd04ba chore: regenerate templ files for v0.3.1001
Reverts generated Go template files from templ v0.3.1020 back to
v0.3.1001 output. Changes include filename path prefix adjustments
(admin_library.templ → templates/admin_library.templ) and attribute
handling differences (ResolveAttributeValue → JoinStringErrs + EscapeString).
2026-05-12 16:54:14 -04:00
john-okeefe 553a1dc19b refactor(reader): remove vendored pdfjs files from git, drop CJK cmaps
Remove 185 binary files (169 CMaps + 16 standard fonts) from git
tracking. These are build artifacts copied from
node_modules/@bookhoard/foliate-js at build time and should not be
version-controlled.

Changes:
- Remove web/static/vendor/pdfjs/ from git (169 cmap files + 16
  standard font files)
- Add web/static/vendor/ to .gitignore
- Drop CJK cmap copying from build scripts — the app is English-only
  and CJK support can be re-added later if needed (saves ~1.7MB in
  the container image)
- Update all three build scripts (build:ts, build:ts:dev,
  build:ts:watch) to copy only standard_fonts/ from node_modules
- Remove cMapUrl from reader.ts PDF config since we no longer ship
  cmaps
- Keep standardFontDataUrl pointing to the build-copied fonts which
  are needed for PDFs with non-embedded standard fonts (Helvetica,
  Times, Courier, etc.)
2026-05-11 14:57:22 -04:00
john-okeefe f3cacd1b16 fix(docker): relax healthcheck intervals and add start_period to postgres
The postgres container was being healthchecked every 5s which is
aggressive for a database, especially on slower machines or under load.
The bookhoard service was checked every 10s.

- Increase postgres healthcheck interval from 5s to 30s
- Add start_period: 10s to postgres to give it time to initialize
  before healthcheck failures count against retries
- Increase bookhoard healthcheck interval from 10s to 30s
2026-05-11 14:56:53 -04:00
john-okeefe b829b7fe22 chore: regenerate all templ Go files for v0.3.1020
templ v0.3.1020 generates different code than v0.3.1001 (uses
ResolveAttributeValue for attribute handling). Regenerated all 33
_templ.go files to match the new runtime.
2026-05-10 16:13:53 -04:00
john-okeefe d42d09b8db chore: upgrade templ v0.3.1001 → v0.3.1020, pin in Dockerfile
Newer templ generates ResolveAttributeValue calls that don't exist in
v0.3.1001 runtime, causing Docker build failures ("undefined:
templ.ResolveAttributeValue"). Pin templ CLI version in Dockerfile to
match go.mod instead of using @latest.

Also updated local templ CLI to v0.3.1020 to match.
2026-05-10 16:13:36 -04:00
john-okeefe 71832bee24 fix(templates): fix ErrorToast rendering literal { message } instead of error text
ErrorToast used Go string literal '{ message }' instead of templ
interpolation, so the actual error message was never shown — just the
literal text "{ message }" appeared in the toast.
2026-05-10 16:13:21 -04:00
john-okeefe 504f145f64 feat(metadata-editor): replace tags text input with badge picker + autocomplete
Replace the comma-separated text input for tags in the metadata editor
with a badge-based tag picker:
- Current tags shown as removable pill badges (✕ button per tag)
- Autocomplete input queries existing tags via shared tag-dropdown module
- Results shown as inline dropdown (not absolute, avoids overflow clipping
  from the modal's overflow-y-auto content area)
- Keyboard navigation: ArrowUp/Down, Enter to select, comma to add new,
  Escape to close
- Supports adding new tags not in the database (type + Enter/comma)
- Hidden data-editor-tag spans seed initial tags from server-side render
- collectFormData() now accepts editorTags array, skips the removed
  tags text input
2026-05-10 16:13:07 -04:00
john-okeefe 46e0744802 feat(bookshelf): replace broken datalist tag filter with custom autocomplete
The HTML <datalist> approach for tag autocomplete was unreliable across
browsers — showed empty suggestions or no dropdown at all.

Replace with a custom Alpine.js dropdown:
- New tag-dropdown.ts shared module: searchTagSuggestions() queries
  /api/media-items/search?tags=...&library_id=... and returns results
- Bookshelf: absolute-positioned dropdown below tags_filter input, shows
  tag name + book count per suggestion
- Keyboard navigation: ArrowUp/Down to highlight, Enter to select,
  Escape to close
- Click suggestion to populate the filter input
2026-05-10 16:12:44 -04:00
john-okeefe a93be75408 feat(book-detail): add tags and contributors display, fix series/tag links
- Add clickable tag badges between comic badges and synopsis, linking to
  /tags/detail?name=<tag>&library_id=<id> for browsing books by tag
- Add Contributors as comma-separated row in the metadata grid
- Add data-library-id attribute to body for tag autocomplete API calls
- Fix series badge link: append &library_id= so /series/detail works
  when navigated from book detail page (was returning empty/404)
2026-05-10 16:12:28 -04:00
john-okeefe dc07a2f19f fix(search): add json tags to FieldValue struct for correct API response
FieldValue struct had no json tags, so Go marshaled fields as uppercase
(Value, Count, Score) but frontend expected lowercase (value, count).
This caused all autocomplete dropdowns (tags, author, series, language)
to silently fail — tagSuggestions[].value was undefined, crashing
toLowerCase() calls and producing empty dropdowns.
2026-05-10 16:12:10 -04:00
john-okeefe fa38ca0d1b feat(db): add GetBooksByTag query for tag detail page
Uses $2 = ANY(tags) to match against the tags text[] column with GIN
index support. sqlc generates a single string Column2 param (not []string).
2026-05-10 16:11:46 -04:00
john-okeefe ae2e03d499 refactor(templates): generalize SeriesDetail into reusable BrowseDetail
Replace the single-purpose SeriesDetail template with a parameterized
BrowseDetail component that accepts badge icon/label, title, page title,
back URL/label, empty state icon/message, and book list. Both series
detail and new tag detail pages use the same template with different
params, eliminating duplication.

Series detail: 📚 Series, back to /series, "All Series"
Tag detail: 🏷️ Tag, back to /bookshelf, "Bookshelf"

Deleted series_detail.templ and series_detail_templ.go.
Updated frontend.go series route to call BrowseDetail with series params.
Added /tags/detail route calling BrowseDetail with tag params.
2026-05-10 16:11:29 -04:00
john-okeefe 65c08fb523 feat(frontend): wire metadata editor Alpine component in book-detail.ts
Replace placeholder toast with full metadata editor Alpine data component:
- Modal show/hide (showMetadataEditor, hideMetadataEditor)
- Accordion section toggle
- Cover upload via FileReader preview
- Cover generation via dynamic cover-generator import
- Cover removal with placeholder fallback
- saveMetadata(): collects form data, sends PUT as JSON or multipart
  depending on whether a cover file is present
- Back button fix: skip overwriting sessionStorage back URL when
  referrer is the current page (preserves navigation after page reload)
2026-05-10 11:53:50 -04:00
john-okeefe 0680e051c2 feat(frontend): add client-side cover generator via dynamic foliate-js import
New cover-generator.ts module that dynamically imports foliate-js/view.js
only when cover generation is requested, keeping it out of the main bundle.

Supports all media types:
- PDF (fixed_layout): renders page 1 to canvas via view.renderer
- EPUB/CBZ (reflowable): extracts book.cover blob from parsed metadata
- Falls back to canvas-to-JPEG conversion for non-JPEG sources
2026-05-10 11:53:31 -04:00
john-okeefe 03156e7866 feat(book-detail): wire metadata editor button and add format-group data attr
- Replace showMetadataEditorPlaceholder() toast with showMetadataEditor()
  that opens the metadata editor modal
- Add data-format-group attribute to body for client-side cover generation
- Include @MetadataEditorModal(book) in the page modals section
2026-05-10 11:53:11 -04:00
john-okeefe 4f2e1e36bf feat(templates): add metadata editor modal with cover management
New MetadataEditorModal component with:
- Cover section (w-64 h-96, matching book detail page layout): click-to-upload,
  Generate Cover button, Remove Cover button
- Accordion sections: Basic Info, Publication, Series, Identifiers,
  Comic/Manga, Technical — covering all 34 editable metadata fields
- Modal capped at 90vh with scrollable content area
- Cover upload via hidden file input with hover overlay
- Select dropdowns for MangaType and ReadingDirection
- Read-only display for Format and File Size
2026-05-10 11:52:53 -04:00
john-okeefe 36b2afeed7 feat(templates): add helper functions for metadata editor form rendering
Add textToString, tagSliceToString, stringSliceToString, and
formatDateForInput to convert pgtype/[]string values into HTML input
value attributes for the metadata editor form fields.
2026-05-10 11:52:36 -04:00
john-okeefe b088ec97f6 fix(reader): add explicit PDF.js resource paths and pin foliate-js fork
foliate-js could not locate cmaps and standard_fonts at runtime because
no explicit paths were provided to the PDF.js config. This caused
rendering failures for PDFs using CJK fonts or standard PDF fonts.

Changes:
- Pass cMapUrl and standardFontDataUrl to view.open() in reader.ts
- Pin foliate-js fork to commit 74c317d in package.json for reproducibility
- Update build:ts script to copy cmaps/ and standard_fonts/ to
  web/static/vendor/pdfjs/ during build
2026-05-10 11:52:23 -04:00
john-okeefe a92739d210 fix(handlers): wire all 37 fields in UpdateMediaItem, add cover upload support
UpdateMediaItem handler:
- Add form: tags to UpdateMediaItemRequest for dual JSON/multipart binding
- Add 8 missing fields (Language, Edition, PageCount, Genre, CopyrightYear,
  GoodreadsID, OpenlibraryID, GoogleBooksID)
- Add CoverAction field (keep/upload/remove) with multipart cover handling
- Fetch existing record before update to preserve cover_image_path when
  cover_action is "keep" (was clearing cover on every JSON save)
- Add saveCoverImage() method: validates image type, resolves library path,
  saves as {file_path}.cover.jpg
- Add HX-Redirect response header for HTMX clients

HandleBulkUpdate:
- Copy all 37 fields from existingMedia (was missing GoogleBooksID + 14
  new fields), preventing data loss on bulk metadata updates.
2026-05-10 11:52:04 -04:00
john-okeefe f199918775 fix(scanner): wire all metadata fields in updateMediaItem and skip image dupes
updateMediaItem (used by force rescan) was missing 22 fields including
Language, Genre, PageCount, CopyrightYear, GoodreadsID, and all 14 new
columns from the SQL query fix. Now wires all 37 UpdateMediaItemParams.

Also adds hasSiblingBookFile() early exit in processMediaFile: if a file
is an image (jpg/png/webp/etc) and its directory contains an actual book
file (epub/pdf/cbz/etc), skip importing the image as a standalone media
item. This prevents cover images and interior art from appearing as
duplicate library entries.
2026-05-10 11:51:43 -04:00
john-okeefe a3504f1ae5 fix(db): add 14 missing columns to UpdateMediaItem query
The UpdateMediaItem SQL query only SET 23 of 37 media_items columns,
causing all 3 call sites (admin PUT, bulk update, force rescan) to
silently NULL out the 14 unwired fields on every update.

Added: manga_type, reading_direction, series_count, volume, imprint,
age_rating, web_url, metadata_notes, community_rating, story_arc,
is_black_and_white, alternate_info, scan_information, summary.

Regenerated sqlc Go code (queries.sql.go) with 37-param
UpdateMediaItemParams struct.
2026-05-10 11:51:27 -04:00
john-okeefe 355ae5f7a9 fix(series): remove library switcher from detail page, add title tooltips to BookCard
Remove the library selector dropdown from the series detail page
since the page is scoped to the library from the browse page.
Replace it with a simple '← All Series' back link in the sticky bar.

Add title attributes to the shared BookCard template so the full
book title and author are visible on hover (useful for truncated
text with line-clamp).
2026-05-08 20:58:43 -04:00
john-okeefe ddc14e3314 feat(series): add dedicated series detail page instead of bookshelf filter
Create a new /series/detail?name=X&library_id=Y SSR page that shows
all books in a specific series, replacing the broken approach of
linking to /bookshelf?series_filter=X (the bookshelf SSR handler
ignores all filter query params).

The series detail page features:
- Back link to /series browse page
- Library selector dropdown (full page navigation on change)
- Series name header with book count badge
- Book grid using the shared BookCard template
- Empty state for series with no books

Update all links to point to the new page:
- Series cards on /series browse page
- Series badge on book detail page
- JS-rendered cards in series.ts switchLibrary

Add seriesDetailPage Alpine component for the detail page's
library switcher (simple navigation, no AJAX needed).
2026-05-08 20:50:59 -04:00
john-okeefe a57694b738 fix(tests): URL-encode series name in special characters test
The TestGetSeries_SpecialCharactersInName test was failing with a 400
status because the series name 'Series: Book & Other (Vol. 1)' was
interpolated directly into the URL without encoding. The ampersand was
parsed as a query parameter delimiter, corrupting the request.

Use url.QueryEscape() to properly encode the name parameter.
2026-05-08 20:31:30 -04:00
john-okeefe 3bcae0abf5 chore: regenerate templ Go files (path reference update) 2026-05-08 20:27:50 -04:00
john-okeefe cce7ad4907 test(series): add unit and integration tests for series feature
Unit tests:
- series_service_test.go: test continueSeriesRowToMediaItems conversion
  with valid fields, null fields, and comprehensive field mapping
- series_test.go: test handler initialization and textToString helper

Integration tests (series_integration_test.go):
- GET /api/series: requires library_id, rejects invalid UUID, returns
  empty array for empty library, pagination params, limit clamped to
  100, response structure validation, special characters in names
- GET /api/series/books: requires library_id and name, handles
  nonexistent series, unauthorized access
- Restore Continue Series system collection
- Dashboard sections include all 5 collections (including continue-series)

Update test helpers:
- Add SeriesHandler to setupTestServer router config
- Add 5th Continue Series collection to createDefaultCollectionsForUser
- Update dashboard integration test for 5 collections
2026-05-08 20:27:40 -04:00
john-okeefe c5cda015b3 feat(series): add AJAX library switching and stacked-cascade CSS
Create web/src/series.ts with Alpine component implementing the same
switchLibrary pattern as the dashboard:
- Fetch /api/series on library change instead of full page reload
- Fade out/in transition with loading spinner
- Re-render series grid and pagination from JSON response
- Save selected library to localStorage

Import series.ts in main.ts.

Add stacked-cascade CSS to input.css for multi-cover series cards:
- Covers cascade from top-left to bottom-right with increasing z-index
- Front cover sits at bottom-right (highest z-index)
- Separate layout rules for 1-7 covers with rotation offsets
- Hover lift effect on series cards
2026-05-08 20:27:27 -04:00
john-okeefe b83e319a34 feat(series): add series browse template, nav link, and clickable badge
Create templates/series.templ with:
- Library selector dropdown (sticky, same pattern as dashboard)
- Loading spinner overlay for AJAX library switching
- Series grid with stacked-cascade multi-cover cards
- Empty state when no series found
- Pagination with Previous/Next links
- SeriesCard sub-template linking to filtered bookshelf view

Add 'Series' nav link in header between 'All Books' and 'Collections'.

Make series badge on book detail page clickable, linking to
/bookshelf?series_filter=<name>&sort=series.

Add 'Continue Series' option to restore system collection modal.
2026-05-08 20:27:15 -04:00
john-okeefe 004b761381 feat(series): add SeriesHandler, API routes, and SSR browse page
Create SeriesHandler with two API endpoints:
- GET /api/series (paginated series list with covers)
- GET /api/series/books (books in a specific series)
Uses query param ?name=X instead of path param to avoid URL encoding
issues with special characters in series names.

Add GetSeriesCardsData helper returning services.SeriesInfo for use
by the SSR route (avoids handlers→templates import cycle).

Register /api/series routes via registerSeriesRoutes in router.
Add /series SSR route in frontend.go with library-scoped pagination
and error handling, matching the dashboard/bookshelf patterns.

Add SeriesHandler to router Config and instantiate in main.go.

Add SeriesCardData type to templates/types.go.

Add Continue Series as the 5th valid system collection in
dashboard handler and auth handler's CreateDefaultCollectionsForUser.
2026-05-08 20:27:01 -04:00
john-okeefe 9405afa2c5 feat(series): add SeriesService and wire continue-series into dashboard
Create SeriesService with methods for paginated series listing, cover
path resolution, series book listing, and a conversion helper for
GetContinueSeriesItemsRow to MediaItems.

Wire the continue-series query type into DashboardService's
getCollectionItemsByQueryType switch and add its metadata to the
RestoreSystemCollection default collection map.
2026-05-08 20:26:47 -04:00
john-okeefe d48404e800 feat(series): add SQL queries for series browsing and continue-series
Add five new sqlc queries to support the series browse page and
continue-series dashboard collection:

- GetDistinctSeries: list unique series with book counts, sorted by
  most recent entry, with pagination
- GetDistinctSeriesCount: total distinct series count for pagination
- GetSeriesCovers: fetch up to N cover image paths for a series,
  ordered by series_number
- GetSeriesBooks: fetch all books in a series ordered by series_number
- GetContinueSeriesItems: CTE-based query using DISTINCT ON to find
  the next unread book per series for a given user/library, sorted
  by most recent last_read_at
2026-05-08 20:26:39 -04:00
john-okeefe ed68f92f4a fix(tests): correct date format in analytics reading stats tests
The GetReadingStats handler expects dates in MM-DD-YYYY format (01-02-2006)
but the tests were sending YYYY-MM-DD (2006-01-02), causing 400 errors on
the GetReadingStats_WithCustomDateRange and ReadingStats_FutureDateRange
test cases. Updated both test functions to use the matching format.
2026-05-01 16:59:49 -04:00
john-okeefe 607ce8ce3a Merge branch 'main' of ssh://git.linuxhg.com:2222/Bookhoard/bookhoard 2026-05-01 14:31:54 -04:00
john-okeefe ff0517d038 fix(library): sync allowed extensions across service, schema, and tests
Add .epub and .pdf to comics type, add .pdf to manga type, and ensure
avif/tiff/tif extensions are consistently included in manga across all
layers. Update test fixtures to match the canonical extension lists.
2026-05-01 14:31:17 -04:00
john-okeefe e14bbeabdd chore: remove stale planning documents (PANEL_DETECTION_PLAN, PROGRESS_MIGRATION) 2026-05-01 14:31:13 -04:00
john-okeefe a9f8e23ddd feat(ui): expand timezone dropdowns to cover all populated UTC offsets
Replaced the 8 US-centric timezone options with 24 entries covering
every populated UTC offset worldwide (UTC-10 through UTC+12). Each
option is labeled by regional name with UTC offset in parentheses,
e.g. 'Central European (UTC+1/+2)'. DST-shifting zones show both
standard and daylight offsets.

Covers: Hawaii, Alaska, Pacific, Mountain, Mountain-no DST, Central,
Eastern, Brasilia, British, Central European, Eastern European,
Moscow, Iran, Gulf, Pakistan, India, Bangladesh, Indochina, China,
Japan/Korea, Australian Central, Australian Eastern, New Zealand.

Backend already validates all IANA zones via time.LoadLocation(), so
users with uncommon zones can still set them via the API.

Updated in three locations:
- templates/profile_form.templ (user profile dropdown)
- templates/admin_settings.templ (admin settings dropdown)
- internal/handlers/sidecar.go (HTMX save response HTML)
2026-04-29 20:47:11 -04:00
john-okeefe 540fb147d2 feat(config): add TZ environment variable for container timezone
Adds TZ env var to docker-compose.yml app service (defaults to UTC)
and documents it in .env.example. This ensures the Go runtime's
time.Local is set correctly inside the container for any server-side
time operations that don't use an explicit timezone.
2026-04-29 20:33:13 -04:00
john-okeefe ffaa561cb7 chore: regenerate all templ Go files
Regenerated from .templ sources after template changes. Includes
path reference updates in error messages (templates/ prefix
shortened) from templ tool regeneration.
2026-04-29 20:33:08 -04:00
john-okeefe 4305c77df4 fix(profile): match timezone dropdown styling to rest of profile form
The timezone select used generic form-group/form-select CSS classes
while all other fields use Tailwind utilities with CSS custom
properties. Updated to use the same w-full px-3 py-2 border rounded
pattern with var(--bg-primary), var(--text-primary), and
var(--border) for visual consistency.
2026-04-29 20:33:03 -04:00
john-okeefe 1da7765466 fix(admin): wire up default timezone setting in admin settings page
The admin settings timezone dropdown was incomplete: it had no
pre-selection of the current value, was missing consistent styling,
and the form submission did not persist timezone changes.

Changes:
- frontend.go: load default_timezone from system_settings into the
  systemConfig map passed to the template
- admin_settings.templ: match card styling used by the Base URL
  section; pre-select current timezone with selected?= attribute
- sidecar.go: handle default_timezone in UpdateSystemConfiguration
  by writing to system_settings table instead of system_config;
  update HTMX response to include timezone section with current value
- Add selectedAttr() helper for HTMX HTML string response
2026-04-29 20:32:58 -04:00
john-okeefe 5da91b9c7c feat(ui): use timezone-aware time formatting across all templates
Replace hardcoded .Format() calls with FormatInTimezone() and
FormatTimestamptzInTimezone() helpers so all timestamps display in
the user's selected timezone.

Changes:
- book_detail.templ: remove incorrect templates. package prefix
- book_detail_modals.templ: add User param to ProgressSyncModal so
  timezone is available; convert Timestamp to FormatInTimezone()
- devices.templ: convert LastSync and LastSeen to FormatInTimezone()
- conflicts.templ: convert CreatedAt to FormatInTimezone()
- admin_users.templ: convert CreatedAt to FormatInTimezone() using
  currentUser.Timezone

Note: DatePublished is kept as a plain date format (MM-DD-YYYY) since
it is a pgtype.Date, not a timestamp, and does not need timezone
conversion.
2026-04-29 20:32:51 -04:00
john-okeefe f87fc45377 feat(db): add timezone column to GetUser query
The GetUser query did not select the timezone column, so the router
helper could not access userDB.Timezone. Added u.timezone to the
SELECT list so the per-user timezone is available in the template
user context.
2026-04-29 20:32:42 -04:00
john-okeefe 5be6fec408 fix(auth): resolve compile errors in timezone update handler
The timezone update block in UpdateProfile() referenced undefined
variables ctx and userUUID, causing a compile error. Fixed to use
c.Request().Context() and targetUserUUID which are the correct
variables in that handler scope.

Also added Timezone field to AdminUpdateUserRequest struct so the
timezone value is properly bound from JSON requests, since
UpdateProfile() binds to AdminUpdateUserRequest rather than
UpdateProfileRequest.
2026-04-29 20:32:38 -04:00
john-okeefe 55a9ec00e1 fix(ui): use timezone-aware formatting for Last Read timestamps in book detail and progress sync modal
Replace hardcoded 12-hour Format() calls with FormatTimestamptzInTimezone()
so that the Last Read time respects the user's selected timezone preference.
Both book_detail.templ and book_detail_modals.templ now use the same
timezone-aware helper that was introduced in the timezone support feature.
2026-04-28 21:12:01 -04:00
john-okeefe c592c745c3 Update timezone plan: remove duplicate query, use 12-hour format
- Remove UpdateSystemTimezone query from plan; reuse existing
  UpdateSystemSetting with 'default_timezone' as the key parameter
- Update handler code example to reference UpdateSystemSetting
- Update FormatInTimezone format string to 12-hour (03:04 PM)
- Update queries file description in summary table
2026-04-27 21:31:31 -04:00
john-okeefe e5726e12be Switch all user-facing time displays to 12-hour MM-DD-YYYY format
Consistently format dates and times across all templates and API
handlers using MM-DD-YYYY with 12-hour clock (03:04 PM):

- analytics.go: date keys, lastSync, lastRead timestamps
- progress.go: lastUpdated timestamp in GetAllProgress
- book_detail.templ: LastReadAt, DatePublished
- book_detail_modals.templ: progress sync timestamps, LastReadAt
- devices.templ: LastSync, LastSeen
- conflicts.templ: CreatedAt
- admin_users.templ: user CreatedAt date
2026-04-27 21:31:19 -04:00
john-okeefe f589bedad5 Add timezone dropdown to profile form and admin settings
- Add timezone select dropdown to profile form with common US
  timezones and UTC
- Add system default timezone setting to admin settings page
- Reformat profile_form.templ with consistent indentation and
  multi-line attribute formatting
2026-04-27 21:31:04 -04:00
john-okeefe 27e9a654bf Add timezone backend support (handlers, utilities, user context)
- Add FormatInTimezone and FormatTimestamptzInTimezone helpers
  in templates/utils.go for timezone-aware time display
- Add Timezone field to templates.User struct
- Pass user timezone from DB to template context in helpers.go
- Add timezone update handling in auth.go UpdateProfile with
  validation via time.LoadLocation
- Add UpdateTimezoneSettings handler in system_settings.go for
  admin system-wide default timezone using UpdateSystemSetting
2026-04-27 21:30:53 -04:00
john-okeefe caf50ade31 Add timezone support to database schema and queries
- Add timezone column (VARCHAR(50) DEFAULT 'UTC') to users table
- Add default_timezone row to system_settings seed data
- Add idx_users_timezone index for user timezone lookups
- Add UpdateUserTimezone and GetSystemTimezone queries
- Regenerate sqlc code (models, querier, queries.sql.go)
- Reuse existing UpdateSystemSetting for system timezone updates
  instead of creating a redundant UpdateSystemTimezone query
2026-04-27 21:30:37 -04:00
john-okeefe dd1ddff08d Remove completed PROGRESS_MIGRATION.md
The progress reading history migration has been fully implemented
and this planning document is no longer needed.
2026-04-27 21:30:22 -04:00
john-okeefe 1863fb3380 docs: add TIMEZONE_PLAN.md with full timezone implementation plan
Documents the approach for adding per-user timezone support with
system-wide fallback. The database already stores all timestamps as
UTC via TIMESTAMPTZ columns, so the work is primarily in the display
layer: user preference storage, timezone-aware template helpers, and
UI controls for selecting a timezone.

Covers 9 phases: schema changes, sqlc queries, template utilities,
user context updates, handler changes, profile/admin UI, template
time display conversion, docker config, and testing/deployment steps.
2026-04-26 21:21:49 -04:00
john-okeefe bc58103c62 feat(ui): persist library selection across page navigation
Save the selected library to localStorage when the user changes the
dropdown on the bookshelf page, and restore it on every page load via
a new restoreLibrarySelection() call in the header Alpine component.

This ensures that when a user navigates between dashboard, bookshelf,
collections, etc., their last-chosen library filter is automatically
re-applied rather than resetting to the default.

Changes:
- web/src/bookshelf.ts: listen for change events on #library-select
  and persist the value to localStorage
- web/src/header.ts: add restoreLibrarySelection() which checks
  localStorage and sets the matching dropdown option on page load
- templates/header.templ: call restoreLibrarySelection() in x-init
- templates/header_templ.go: regenerated from templ source
2026-04-26 21:21:40 -04:00
john-okeefe 37f84dd3ea fix(tests): repair TestUnifiedSearch and TestWebSocketProgressBroadcast
TestUnifiedSearch: Search for 'zzzznonexistent' instead of 'test' which
matches leftover test data from other tests. Fixes false 200 instead of 404.

TestWebSocketProgressBroadcast: Update to new progress endpoint
/api/media-items/:id/progress with correct PUT body format matching
ProgressService (percentage, epubcfi). Use book_id instead of
media_item_id to match WebSocket broadcast payload field names.
2026-04-25 21:34:58 -04:00
john-okeefe 2736409a79 docs: add PROGRESS_MIGRATION.md with full plan, bug list, and execution order
Documents the ProgressService migration including: data loss bug analysis,
handler-by-handler migration plan, route changes, test strategy, and
known issues for future work (conflict_detected column never set to true,
offline detector not started, server-side CFI generation needs Go EPUB
parser).
2026-04-25 21:17:19 -04:00
john-okeefe 94102af4d7 test(progress): add comprehensive integration tests for ProgressService
Adds 30 integration tests across 7 test functions covering all progress
endpoints with real HTTP requests and database verification:

- AuthContexts (8 tests): unauthenticated PUT/GET return 401, regular
  user and admin both get 200, invalid UUID returns 400, nonexistent
  item returns 200 with empty data.

- MergePreservesFields (2 tests): second PUT with only percentage
  preserves epubcfi and chapter from first save via GET verification;
  web save preserves koreader character_offset via DB query.

- EnrichmentComputesFields (2 tests): character_offset computed from
  percentage when total_characters is set on media item; GET returns
  enriched format_group and total_characters.

- ConflictDetection (3 tests): different sources with >1% diff within
  5 minutes creates sync_conflicts record; same-source rapid saves
  create no conflict; <1% diff creates no conflict.

- KoboIntegration (3 tests): ReadingSync then last-read-place preserves
  percentage via DB; standalone last-read-place sets epubcfi/chapter;
  unauthenticated returns 401.

- KOReaderIntegration (2 tests): Bearer token auth with proper request
  body returns 202 Accepted; unauthenticated returns 401.

- DeleteProgress (2 tests): DELETE clears progress; unauthenticated
  returns 401.

- EdgeCases (4 tests): empty body succeeds, 0.0% and 1.0% boundaries,
  all fields with full DB verification of each column.

Updates test_helpers to create ProgressService in setupTestServer and
inject into all handlers. Fixes previous tests that used testing.Short()
(which caused all tests to be skipped in the container) and assertions
against wrong JSON format (pgtype serializes as plain values, not
wrapped objects).
2026-04-25 21:17:08 -04:00
john-okeefe cb214b16b2 feat(reader): send richer progress payload with chapter boundaries and zoom
The reader's saveProgress() now sends a more complete payload to the
backend so ProgressService has more data for enrichment and merge:

- chapter: computed from TOC boundary index instead of missing
- reading_mode: current display mode (page, chapter, percent, time-left)
- zoom_level: for fixed-layout books (renderer.zoomPercent / 100)
- current_page: real page number for fixed-layout, location.current for
  reflowable
- total_pages: section count for fixed-layout, location.total for
  reflowable

Adds computeChapterPageBoundaries(doc) for reflowable EPUBs that maps
TOC anchors to rendered page numbers, recomputes after fonts load.
Adds computeFixedLayoutChapterBoundaries() for fixed-layout books that
resolves TOC hrefs to page indices via view.resolveNavigation().

Updates reader.templ to expose isFixedLayout to Alpine init.
2026-04-25 21:16:52 -04:00
john-okeefe 4fe36ba0a5 refactor(router): remove duplicate progress routes, add ProgressService to config
- Remove GET /progress/:id and POST /progress/:id from progress routes.
  These were superseded by the media-item progress routes. Only
  GET /progress/:id/history remains.

- Add ProgressService to router.Config so sync.go can inject it into
  KoboHandler via SetProgressService().

- Inject ProgressService into KoboHandler at route registration time
  rather than requiring a separate setup step.

- Update comment from 'Legacy progress routes' to 'Progress routes'.
2026-04-25 21:16:39 -04:00
john-okeefe 283b2f2ed7 feat(handlers): integrate ProgressService into media, koreader, kobo, and queue
All four progress write paths now delegate to ProgressService.SaveProgress:

- MediaHandler: UpdateMediaReadingProgress uses ProgressService for web
  saves with richer request body (reading_mode, zoom_level, scroll). GET
  now uses GetUniversalProgress query that JOINs media_items for
  format_group, total_characters, chapter_count.

- KOReaderHandler: updateProgressForBook delegates to ProgressService.
  Fixed device ID bug (was using userID, now uses deviceID). Removed
  duplicate UpdateDeviceLastSync with zero UUID. Added pgtype helper
  functions (textPtrToPgText, intPtrToPgInt4, int64PtrToPgInt8).

- KoboHandler: all four progress write points (Markup ReadingSync, Markup
  last-read-place, AnalyticsGettests, SyncFromServer) delegate to
  ProgressService. Fixed empty epubcfi string now correctly set to
  Valid: false. SyncFromServer preserves last_sync_source=bookhoard
  and Broadcast: false.

- QueueProcessor: syncProgress delegates to ProgressService.

- main.go: creates ProgressService after ConnectionManager, injects via
  SetProgressService() on all handlers and queue processor.

Handler tests cover pgtype conversion helpers (textPtrToPgText, etc.)
and device icon mapping.
2026-04-25 21:16:29 -04:00
john-okeefe f699899408 feat(sync): add ProgressService with merge, enrichment, and conflict detection
Introduces a centralized ProgressService that handles all reading progress
writes across web, KOReader, and Kobo clients. The service implements:

- Merge strategy: reads existing progress first, then only overwrites
  non-nil fields from the incoming request. This fixes the data loss bug
  where partial updates (e.g., Kobo last-read-place sending only epubcfi
  and chapter) would NULL out percentage, character_offset, etc.

- Enrichment: computes missing fields from available data:
  - character_offset from percentage + total_characters
  - current_page from percentage + total_pages
  - percentage from current_page + total_pages (reverse)
  - percentage from character_offset + total_characters (reverse)

- Conflict detection: when a different source writes progress within 5
  minutes with >1% difference, records a sync_conflicts row and broadcasts
  a WebSocket notification for real-time UI alerts.

- Broadcast control: SaveProgressRequest.Broadcast flag lets Kobo
  last-read-place and SyncFromServer skip WebSocket broadcasts.

- Pointer fields on SaveProgressRequest: nil means preserve existing,
  non-nil means overwrite. Eliminates ambiguity between zero values
  and not-provided fields.

Also adds unit tests for buildProgressSnapshot helper function.
2026-04-25 21:16:15 -04:00
john-okeefe e87f481988 chore(templates): update FileName references to include templates/ path prefix in generated Go files
All 25 templ-generated Go files had their error-handling FileName fields
updated from bare filenames (e.g. `dashboard.templ`) to path-prefixed
filenames (e.g. `templates/dashboard.templ`). This reflects a change in
how the templ compiler resolves source file paths, likely due to running
generation from the project root instead of within the templates directory.
The change is purely cosmetic and only affects runtime error messages,
not application behavior.

Affected templates:
- Admin: library, processing_issues, settings, sidebar, users
- Reader/Book: book_detail, book_detail_modals, bookshelf
- Collections: collection_modal, collection_rules, collections
- Other pages: conflicts, custom_section, dashboard, devices,
  docs, error, filter_item, header, profile_form, profile_modal,
  progress, queue, unlinked_books
- API: api_explorer
2026-04-25 13:41:20 -04:00
john-okeefe d68f72f21b feat(reader): wire up progress mode switching with four display modes
Connect the existing progress_mode setting dropdown to the reader's
progress display. Four modes are now functional:
- pages: overall percent + page/location number (default, existing)
- chapter: chapter title + page X / Y within current section
- percentage: overall percent only
- time-left: percent + estimated time remaining via reading speed API
The progress display in the bottom bar is now clickable to cycle through
modes with immediate visual feedback. The settings dropdown is bound
with x-model for persistence. Reading speed is fetched once on init
from the backend reading-speed API for time-left estimates.
2026-04-25 13:40:18 -04:00
john-okeefe d400377474 feat(types): add FoliateTocItem interface for foliate-js TOC entries
Add a typed interface for the tocItem data returned by foliate-js
relocate events, replacing untyped usage in the reader progress display.
2026-04-25 13:39:07 -04:00
john-okeefe 7cd88b6107 chore(templates): regenerate all templ generated Go files
Regenerated all _templ.go files after running templ generate. Changes
include updated FileName references (relative path normalization) and
line number adjustments from the templ code generator.
2026-04-24 14:03:16 -04:00
john-okeefe d38804e910 fix(progress): correct percentage display and add format-aware progress
Fix two bugs in progress display across book detail, progress page, reader,
and sync modal templates:

1. Percentage was stored as 0.0-1.0 fraction but displayed as-if 0-100
   (showing 0.5% instead of 50%). Multiply by 100 at the data source in
   both GetAllProgress and GetAllProgressData handlers, and in the reader
   route's ReadingProgress construction.

2. Progress bar width was never evaluated — { expr } inside style=".."
   was rendered as literal text by templ, resulting in 0% width bars for
   all items. Fixed by using templ's style={ expr } attribute syntax
   which evaluates the Go expression (uses SanitizeStyleAttributeValues).

Also add format-aware progress display:
- Reader template: shows "45% · Page 89/196" for reflowable (estimated
  pages), "127/342" for comics/PDFs (actual pages)
- Progress page: shows "Page X of Y (est.)" for reflowable, "X / Y"
  for fixed layout
- Add FormatGroup and EstimatedPages to ProgressWithMedia struct
- Remove hardcoded totalPages=200 fallback in progress handler (now 0)
- Add fmt import to progress.templ for string formatting
2026-04-24 14:03:03 -04:00
john-okeefe 192c978a38 feat(templates): expand reader and progress template types for format-aware display
Add fields to ReaderMetadata and ReadingProgress template types to support
KOReader-like progress display:

ReaderMetadata:
- TotalCharacters: from media item, used for estimated page calculation
- EstimatedPages: computed via sync.EstimatedPages()

ReadingProgress:
- Chapter: current chapter index from reading_progress
- ChapterProgress: within-chapter progress (0-100, multiplied from DB fraction)
- FormatGroup: item format for conditional display logic

These fields enable format-aware progress display (pages for comics/PDFs,
estimated pages for reflowable, percentage for all).
2026-04-24 14:02:43 -04:00
john-okeefe 44ec2f496a feat(sync): add estimated pages calculation for reflowable ebooks
Reflowable ebooks (EPUBs) don't have inherent page numbers since layout
depends on device settings. Add an EstimatedPages() function that converts
total character count to an estimated print page count using the industry
standard of 1800 characters per page.

This provides a consistent, device-independent page count for progress
display (e.g., "Page 89 of 196" for a reflowable EPUB), matching how
KOReader and similar readers handle the same problem.
2026-04-24 14:02:28 -04:00
john-okeefe d3ddecb840 fix(reader): persist chapter metadata cache to database
The DetectChapters function in reader.go was serializing chapter detection
results to JSON but then discarding the bytes with `_ = metadataBytes`
instead of writing them to the database. This meant chapter_metadata in
media_items was never populated, forcing re-detection on every request.

Replace the no-op discard with an actual UpdateMediaItemChapterMetadata()
call using the existing sqlc-generated query.
2026-04-24 14:02:13 -04:00
john-okeefe da1732285f fix(scanner): populate page count and total characters during media scanning
The media scanner never populated page_count or total_characters in
media_items, leaving progress display and reading position calculations
with no reliable data. This commit fixes data population for all formats:

Comics (CBZ/CBR/CB7/CBT):
- Add countArchiveImages() helper that walks archive entries and counts
  image files (.jpg, .jpeg, .png, .gif, .webp)
- Call it during comic metadata merge to set metadata.PageCount

PDFs:
- Extract pdfInfo.PageCount from the pdfcpu library (already available
  from PDFInfo call, just never used) and set metadata.PageCount

Reflowable EPUBs:
- Use book.AllChaptersText() to compute metadata.TotalCharacters
- Use book.ChapterCount() to set metadata.ChapterCount

Fixed-layout EPUBs (manga/comics in EPUB format):
- Merge .epub into the .cbz case in countArchiveImages since both are
  ZIP archives with images
- Detect fixed-layout EPUBs via DetectFixedLayoutEPUB() in both the
  Calibre sidecar path (mergeMetadata) and the no-sidecar path
  (extractMetadata), counting images when fixed-layout is detected

Format group on creation:
- Remove the guard condition on UpdateMediaItemFormatGroup so that
  format_group, is_reflowable, and has_fixed_layout are set immediately
  for every new item (not just items with text data)
- Use DetectFixedLayoutEPUB() instead of hardcoding all .epub as
  reflowable, correctly classifying fixed-layout EPUBs

Also pass PageCount to CreateMediaItem and add PageCount,
TotalCharacters, and ChapterCount fields to the MediaMetadata struct.
2026-04-24 14:01:57 -04:00
john-okeefe 0269403a5d feat(reader): wire up reading progress save and restore
The web reader had all the infrastructure for progress persistence
(updateReadingProgress/getReadingProgress API functions, PUT/GET
endpoints, database queries) but the reader.ts never called them.

Changes:
- Add debounced (2s) saveProgress call on every relocate event that
  PUTs percentage, current_page, total_pages, and epubcfi to the
  existing /api/media-items/:id/progress endpoint
- Replace renderer.next() with view.init({ lastLocation }) to restore
  the reader to the last saved position on load (CFI first, then
  fraction fallback, then default first page)
- Pass savedPercentage and savedCfi from server-side progress data
  through readerInitExpr config to the JS initReader function
- Add mediaItemId and saveTimeout to the Alpine data object

This fixes both the blank /progress page and the missing progress
section on book detail pages — both were empty because the
reading_progress table never received any data from the web reader.
2026-04-23 21:08:30 -04:00
john-okeefe a4962a87b2 fix: replace invalid new(expression) calls with proper pointer allocation
Go's new() builtin takes a type and allocates a zero value — it cannot
wrap an expression. All instances of new(someExpression) were compile
errors. Replace each with a local variable assignment and address-of
operator.

Affected files:
- handlers/koreader.go: progress field pointers (Chapter, Page, etc.)
- handlers/kobo.go: pagesRemaining pointer
- handlers/queue.go: uuidPtrToString and timestamptzPtrToString helpers
- router/reader.go: bookmark pageNumber and chapterNumber pointers
- services/media_scanner.go: validation error message pointers
- services/worker.go: StartedAt and CompletedAt timestamps
- sync/offline.go: GetDeviceStatus return pointer
- tests/device_test.go: SyncEnabled and SyncFrequencyMinutes pointers
2026-04-23 20:39:50 -04:00
john-okeefe 35c8ffe33e fix(reader): URL-encode file paths and JSON-encode init config to fix comics/manga loading
The reader failed to load comics and manga (and any file with special
characters in its path) for two reasons:

1. FileURL was built with raw fmt.Sprintf instead of ResolveMediaURL,
   so characters like '#' in paths (e.g. 'Annual #2') were interpreted
   as URL fragments, truncating the path and causing 404s.

2. The Alpine x-init expression used raw string interpolation for config
   values, so apostrophes in paths (e.g. "I'll Use My Appraisal Skill")
   broke JavaScript parsing with 'Unexpected identifier'.

Fix by using utils.ResolveMediaURL for proper URL path encoding and
json.Marshal for the initReader config to safely escape all special
characters.
2026-04-23 20:39:36 -04:00
john-okeefe 70d8ffd528 build: regenerate CSS after dashboard changes 2026-04-23 17:01:30 -04:00
john-okeefe 3a3fa8763e fix(dashboard): use anchor tags for client-side rendered book cards
When switching libraries via TypeScript, renderBookCard() built book
cards as <div> elements with data-action="view-book" for event
delegation, but the click handler was commented out — making books
unclickable after any library switch. The SSR path used proper <a> tags.

Now renderBookCard() wraps cards in <a href="/media/{id}"> to match the
SSR BookCard template, so book links work identically regardless of
whether content was server-rendered or client-rendered.

Also removed the dead view-book handler code and viewBook() stub.

Additionally fixed a listener re-registration bug where the input and
library-select change listeners were nested inside the click callback,
causing them to be registered N times after N clicks. Moved them to
initDashboard() scope so they register exactly once.
2026-04-23 17:01:20 -04:00
john-okeefe 4119a5e38e fix(reader): use proper templ expression for back link href and clean up formatting
The back link in ReaderChrome used literal curly braces inside the href
attribute string (href="/media-items/{ metadata.MediaItemID }") which
doesn't interpolate the variable in templ. Changed to use the correct
templ expression syntax: href={ "/media/" + metadata.MediaItemID }.

Also fixed minor formatting issues:
- Normalize whitespace in comment after closing div
- Collapse empty navigator-viewport div to single line
2026-04-23 17:01:01 -04:00
john-okeefe 86f44230c2 chore(bruno): mark environment IDs as secrets to prevent cross-machine syncing
Remove hardcoded values for database/environment IDs (media_item_id,
library IDs, collection_id, user_id, etc.) and mark them as secret so
that changing them per machine won't keep syncing to git.
2026-04-23 13:57:35 -04:00
john-okeefe 3c3c4e8bf5 fix(utils): URL-encode media paths to handle special characters in filenames
Cover image URLs with special characters like parentheses, #, ?, or
spaces would break because browsers interpret them as URL delimiters.
Apply url.PathEscape() per path segment in ResolveMediaURL so the
server can correctly resolve files like "Wonder Woman (2016) #001.cbz.cover.jpg".

Also adds a package doc comment and fixes the exported function comment.
2026-04-23 13:57:30 -04:00
john-okeefe 133ca1fdaa fix(scanner): extract metadata and covers for comic archives and kepub files
Comic archive formats (.cbz, .cbr, .cb7, .cbt) and .kepub files were
falling through to the default case in extractMetadata(), which only
set the title from the filename. This meant ComicInfo.xml was never
parsed and no cover images were extracted for comics without a Calibre
metadata.opf sidecar file.

The fix adds dedicated switch cases:
- .cbz/.cbr/.cb7/.cbt: calls mergeMetadata() with nil, which triggers
  existing ComicInfo.xml parsing (title, series, issue number, writer,
  publisher, genre, reading direction, etc.) and cover image extraction
  from the archive. Falls back to sidecar cover if no image is found.
- .kepub: treated the same as .epub since KEPUB is an EPUB variant,
  enabling full metadata and cover extraction.
2026-04-22 21:18:53 -04:00
john-okeefe c7f0eb406a fix(tests): handle 404 response for nonexistent library in search filter test
TestCollectionSearchLibraryFilter's 'invalid library_id' case was
expecting a 200 with empty results, but the search handler correctly
returns 404 when no results are found. The test also consumed the
response body for debug logging then tried to JSON-decode the same
body (causing EOF). Add expectedStatus field to the test struct and
return early when a specific non-200 status is expected.
2026-04-22 15:44:01 -04:00
john-okeefe 2fdf894216 fix(tests): correct input validation tests for processing issues endpoints
Three issues fixed in processing_issues_test.go:

- Empty UUID: handler returns 400 (uuid.Parse rejects empty string), not 404.
  Fix expectedStatus in both List and Stats validation tests.
- Path traversal: raw '../../' in URL creates extra path segments that don't
  match the route. Use url.PathEscape so the string is treated as a single
  path parameter, letting the handler reject it with 400.
- SQL injection: raw special characters (semicolons, quotes) caused
  httptest.NewRequest to panic. url.PathEscape prevents the panic and the
  handler rejects the decoded value via uuid.Parse.
- Remove unsupported 'audiobooks' library type from
  TestProcessingIssuesDifferentLibraryTypes (only ebooks/comics/manga exist
  in the database schema).
2026-04-22 15:43:54 -04:00
john-okeefe 17f2dc3120 fix(tests): initialize ProcessingIssuesHandler in test server setup
The setupTestServer() helper in test_helpers_test.go was not creating
a ProcessingIssuesHandler and not passing one to the router config,
causing a nil pointer dereference when any processing issues route was
hit during tests. Add handler creation and wire it into routerConfig
to match how cmd/server/main.go does it.
2026-04-22 15:43:45 -04:00
john-okeefe a06c85e72a fix(handlers): use correct route param name 'id' instead of 'libraryId' in processing issues
Both ListProcessingIssues and GetProcessingIssueStats were reading the
URL parameter 'libraryId', but the routes in internal/router/library.go
define the param as ':id'. This caused both endpoints to always fail with
an invalid library ID error since c.Param('libraryId') returns an empty
string that can't be parsed as a UUID.
2026-04-21 21:31:00 -04:00
john-okeefe e389df92c3 refactor: remove unnecessary type conversions and handle ignored errors across codebase
Remove redundant type conversions that Go 1.26 makes unnecessary or that
were already no-ops:

- uuid.UUID(x.Bytes) → x.Bytes (uuid.UUID is [16]byte, same as pgtype UUID Bytes)
- pgtype.UUID{Bytes: [16]byte(u), Valid: true} → pgtype.UUID{Bytes: u, Valid: true}
- (*time.Time)(&x.Time) → &x.Time
- json.RawMessage(x) → x where x is already []byte
- []byte(stringVal) → stringVal where []byte is expected
- int()/int64()/byte() casts on values already of the target type
- Decompressor(fn) → fn (type is identical)

Handle previously ignored error returns:

- collections.go: check json.Unmarshal error in GetCollection
- conversion_service.go: check fileSize.Scan() error
- app_test.go: check app.Shutdown() error in benchmark
2026-04-21 21:15:59 -04:00
john-okeefe ad27902790 fix(conflicts): use ListConflictsByUser in DismissAllResolved so resolved conflicts are found
DismissAllResolved was calling ListSyncConflictsByUser which filters to
'unresolved' conflicts only, so it could never find the user_resolved or
bulk_resolved conflicts it was trying to delete. The query always returned
an empty set, making dismiss-all a no-op.

Fix the leading space in three SQL query name annotations (ListConflictsByUser,
ListAllConflictsByUserAndStatus, CheckForProgressConflicts) that prevented
sqlc from generating their Go functions. Regenerate the query code and swap
DismissAllResolved to use ListConflictsByUser (no status filter) — the
existing Go loop already filters by resolution_status.
2026-04-21 21:15:34 -04:00
john-okeefe 855ef161d8 fix(docker): bump builder to Go 1.26, download sqlc binary, fix test-runner Go version
The builder stage was previously bumped to Go 1.26 but the test-runner stage
remained on Go 1.25, causing 'go.mod requires go >= 1.26.0' errors during
test execution.

Go 1.26 introduced stricter validation of replace directives in dependency
go.mod files, which broke 'go install sqlc@latest' (sqlc v1.31.0 has replace
directives). Replace go install with a direct binary download from GitHub
releases, matching the existing kepubify download pattern.

Bump test-runner from golang:1.25-alpine to golang:1.26-alpine to match the
go.mod requirement.
2026-04-21 21:15:18 -04:00
john-okeefe a6700f73e0 fix(tests): handle all Close() and Decode() errors across integration tests
Replace all unhandled resp.Body.Close() calls throughout the test suite:

- Deferred calls: replace 'defer VAR.Body.Close()' with a closure that explicitly
  discards the error via 'defer func(Body io.ReadCloser) { _ = Body.Close() }(VAR.Body)'
- Immediate calls: replace 'VAR.Body.Close()' with '_ = VAR.Body.Close()'

Replace all unhandled json.NewDecoder(VAR.Body).Decode(&x) calls with error capture
and require.NoError assertion. Files using httptest.ResponseRecorder (collections_preview,
processing_issues) use 'err :=' declaration; suite-style tests (scanner_integration,
dashboard_integration) use s.T() instead of t.
2026-04-21 20:33:05 -04:00
john-okeefe 8baecad379 fix(tests): use errors.Is() for error comparison and improve resource cleanup in analytics tests
Replace direct error equality check with errors.Is() in media_scanner_hash_test.

In analytics_test.go, improve defer patterns by capturing resp.Body as a named parameter
to avoid stale references, and add require.NoError() checks on all json.NewDecoder().Decode()
calls that were previously silently ignoring decode errors.
2026-04-20 21:20:38 -04:00
john-okeefe 9ccff320a1 refactor: use errors.Is()/errors.AsType() for error comparison and rename shadowed variables
Replace direct error equality checks (err == pgx.ErrNoRows, err != http.ErrServerClosed)
with the idiomatic errors.Is() function throughout handlers, services, middleware, and app
startup. This correctly handles wrapped error chains.

Also replace a raw type assertion (*HTTPError) with errors.AsType[*HTTPError]() in the
error handler middleware for consistency.

Additionally, rename shadowed variables for clarity:
- sidecar.go: config -> sidecarConfig, systemConfig (shadowed package-level vars)
- media_scanner.go: uuid -> uuidString (shadowed the uuid package import)
2026-04-20 21:20:22 -04:00
john-okeefe d8d6334052 feat(reader): add reading_mode (dark/light) to ReaderSettings type
Add the 'reading_mode' field with 'dark' | 'light' values to the
ReaderSettings TypeScript interface, preparing the frontend for a
dark/light reading mode toggle.
2026-04-20 20:45:54 -04:00
john-okeefe 5b2d105609 fix(scanner): always attempt cover extraction for EPUBs and relax manga detection
Two changes to EPUB metadata extraction:

1. Restructure extractMetadata so that fixed-layout detection and cover
   extraction always run for EPUBs, even when extractEPUBMetadata returns
   an error. Previously, a partial failure from the EPUB parser would skip
   cover and format detection entirely, leaving books without covers.

2. Remove the language restriction (ja/jpn) from manga reading direction
   detection. Manga tagged with 'manga' should default to RTL regardless
   of the language metadata, since the tag is an explicit signal from the
   user or metadata source.
2026-04-20 20:45:49 -04:00
john-okeefe 18811cea1b fix(sync): prevent nil pointer dereference when existing progress is missing
In applyProgressResolution and applyResolution, currentPage and totalPages
were unconditionally read from existingProgress even when the preceding
query returned err (no rows). This caused a nil pointer dereference when
no existing reading progress existed for a media item. Now declare the
variables as zero-value pgtype.Int4 and only populate them from
existingProgress when err is nil.
2026-04-20 20:45:41 -04:00
john-okeefe 3e73a582ba refactor(handlers): use errors.Is() for pgx error comparison in KOReader
Replace direct equality checks (err != pgx.ErrNoRows) with the idiomatic
errors.Is(err, pgx.ErrNoRows) pattern. This is the recommended Go practice
for error comparison as it correctly handles wrapped errors from error
chains, making the code more robust against future refactoring that might
wrap errors with fmt.Errorf and %w.
2026-04-20 20:45:35 -04:00
john-okeefe 8aeae33167 fix(sevenzip): add nil guard for subreader to prevent panic
When opening a sevenzip archive, the init() method calls sr :=SevenZipReader()
but never checked if sr was nil before using it. This could cause a nil
pointer dereference when processing malformed or empty archives. Add an
explicit nil check returning errFormat early if the subreader is nil.

Also fixes a minor import grouping whitespace issue.
2026-04-20 20:45:26 -04:00
john-okeefe 3cecb04e8d test(sync): rewrite conflict tests as real HTTP integration tests
Replace the previous mock/httptest-based conflict tests with
integration tests that exercise the full HTTP stack against a live
test server with a real database. Changes include:

- Add shared test helpers (setupConflictTest, createTestConflict,
  makeConflictData) to reduce boilerplate across test files
- Split monolithic TestConflictDetection and TestConflictsBulkOperations
  into focused test functions per scenario
- Test conflict detection, bulk resolution (most_recent, highest_progress,
  manual strategies), and edge cases (empty IDs, invalid UUIDs,
  unauthorized access)
- Verify actual database state after resolution, not just HTTP response
2026-04-20 20:43:16 -04:00
john-okeefe 6820208a36 test(handlers): add unit tests for conflict resolution logic
Add table-driven tests for the conflict handler's source selection
methods: GetMostRecentSource, GetHighestProgressSource, and
GetEarliestSource. Covers cases where each device wins, ties, and
missing/invalid data.
2026-04-20 20:43:04 -04:00
john-okeefe 77cbbf600b refactor(docs): replace deprecated strings.Title with cases.Title
strings.Title has been deprecated since Go 1.18 because it does not
handle Unicode properly. Replace it with cases.Title from
golang.org/x/text which correctly handles language-specific title
casing. Applied to breadcrumb generation and document title formatting.
2026-04-20 20:42:58 -04:00
john-okeefe 4e326dfc86 chore: bump Go dependencies
- github.com/andybalholm/brotli 1.2.0 -> 1.2.1
- github.com/go-playground/validator/v10 10.30.1 -> 10.30.2
- github.com/jackc/pgx/v5 5.9.1 -> 5.9.2
- github.com/labstack/echo/v5 5.0.4 -> 5.1.0
- github.com/yuin/goldmark 1.7.17 -> 1.8.2
- golang.org/x/crypto 0.49.0 -> 0.50.0
- golang.org/x/text 0.35.0 -> 0.36.0
- golang.org/x/image 0.37.0 -> 0.39.0
- golang.org/x/net 0.52.0 -> 0.53.0
- golang.org/x/sys 0.42.0 -> 0.43.0
- Various indirect dependency updates
2026-04-20 20:42:52 -04:00
110 changed files with 11655 additions and 2934 deletions
+15
View File
@@ -10,6 +10,21 @@ JWT_SECRET=your-secure-jwt-secret-key-here
# Generate with: openssl rand -hex 16 # Generate with: openssl rand -hex 16
DBPASS=your-secure-database-password-here DBPASS=your-secure-database-password-here
# Networking: change a port if it conflicts on your host
# Postgres port, host + container (e.g. another local DB already uses 5432)
# DB_PORT=15432
# App web port, host + container
# SERVER_PORT=8765
# Deployment
# External URL for device sync (must include protocol; defaults to http://localhost:8765)
# Examples: https://bookhoard.example.com | http://192.168.1.10:8765
# BASE_URL=https://bookhoard.example.com
# Mark session cookies Secure — set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
# COOKIE_SECURE=true
# Pin or rollback a specific published image version (defaults to "latest")
# IMAGE_TAG=1.0.0
# Optional: Override Defaults (defaults are set in docker-compose.yml) # Optional: Override Defaults (defaults are set in docker-compose.yml)
# Test Mode: WARNING - Only set to true for integration testing # Test Mode: WARNING - Only set to true for integration testing
# TEST_MODE=true # TEST_MODE=true
+112
View File
@@ -0,0 +1,112 @@
name: Release
# Overrides the default run name (the tagged commit's message) so the Actions
# runs list shows "Release v0.3.0" instead.
run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
# Publishes the Bookhoard container image to the Gitea container registry AND
# creates a Gitea Release whose body is the annotated tag's message (generated
# locally by `make release VERSION=...` via git-cliff). Triggered by a version
# tag push, or manually via workflow_dispatch with a tag. Pushing to main does
# nothing, so work-in-progress commits never ship. Each release publishes two
# image tags: the version (e.g. v0.3.0) and "latest".
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Tag to release (e.g. v0.3.0)'
required: true
type: string
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
# Resolve the target tag for both triggers: explicit input on manual
# dispatch, otherwise the pushed tag ref.
TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full history ensures the tag annotation (the release notes) is present.
fetch-depth: 0
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.linuxhg.com
username: ${{ gitea.actor }}
# PAT stored as a repo Actions secret (auto GITHUB_TOKEN lacks package scope in Gitea)
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
# Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag.
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
# pin or roll back by setting IMAGE_TAG in .env.
tags: |
git.linuxhg.com/bookhoard/bookhoard:${{ env.TAG }}
git.linuxhg.com/bookhoard/bookhoard:latest
- name: Create Gitea Release
env:
# REGISTRY_TOKEN is reused for release creation because Gitea's auto
# GITHUB_TOKEN cannot create releases on this instance. The PAT must
# carry write:repository scope. Idempotent: re-runs update an existing
# release for this tag instead of failing with 409. On any HTTP error
# the API response body is printed so a 403 names the missing scope.
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
REPO: ${{ gitea.repository }}
run: |
set -euo pipefail
: "${TAG:?TAG is required}"
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
AUTH="Authorization: token ${TOKEN}"
# Release body = the annotated tag's message (the git-cliff notes).
BODY="$(git tag -l --format='%(contents)' "${TAG}")"
# Tags containing a '-' (e.g. v0.3.0-rc1) are published as pre-releases.
PRE="false"; case "${TAG}" in *-*) PRE="true";; esac
PAYLOAD=$(jq -n \
--arg t "${TAG}" --arg n "${TAG}" --arg b "${BODY}" --argjson p "${PRE}" \
'{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}')
# POST/PATCH the release, surfacing Gitea's error message on failure
# (e.g. "token does not have write scope") instead of failing silently.
api_call() {
local method="$1" url="$2" resp code rbody
resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \
-H "${AUTH}" -H "Content-Type: application/json" \
-d "${PAYLOAD}" "${url}")"
code="$(printf '%s' "${resp}" | tail -n1)"
rbody="$(printf '%s' "${resp}" | sed '$d')"
if [ "${code}" -ge 400 ]; then
echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2
return 1
fi
}
EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)"
if [ -n "${EXISTING_ID}" ]; then
api_call PATCH "${API}/${EXISTING_ID}"
echo "Updated existing release id=${EXISTING_ID} for ${TAG}"
else
api_call POST "${API}"
echo "Created new release for ${TAG}"
fi
+5 -4
View File
@@ -8,15 +8,15 @@ RUN apk add --no-cache nodejs npm curl git
# Install Go tools (cached well) # Install Go tools (cached well)
RUN wget -O /tmp/sqlc.tar.gz https://github.com/sqlc-dev/sqlc/releases/download/v1.31.0/sqlc_1.31.0_linux_amd64.tar.gz && \ RUN wget -O /tmp/sqlc.tar.gz https://github.com/sqlc-dev/sqlc/releases/download/v1.31.0/sqlc_1.31.0_linux_amd64.tar.gz && \
tar -xzf /tmp/sqlc.tar.gz -C /usr/local/bin sqlc && \ tar -xzf /tmp/sqlc.tar.gz -C /usr/local/bin sqlc && \
rm /tmp/sqlc.tar.gz rm /tmp/sqlc.tar.gz
RUN --mount=type=cache,target=/root/go/pkg/mod \ RUN --mount=type=cache,target=/root/go/pkg/mod \
go install github.com/a-h/templ/cmd/templ@v0.3.1020 go install github.com/a-h/templ/cmd/templ@v0.3.1020
# Copy package files and install npm dependencies (cached unless package.json changes) # Copy package files and install npm dependencies (cached unless package.json changes)
COPY package*.json ./ COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
npm ci npm install
# Copy Go mod files (cached unless go.mod changes) # Copy Go mod files (cached unless go.mod changes)
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -36,7 +36,8 @@ RUN npm run build:ts
# Build Go binary (cached unless Go files or generated code changes) # Build Go binary (cached unless Go files or generated code changes)
RUN --mount=type=cache,target=/root/go/pkg/mod \ RUN --mount=type=cache,target=/root/go/pkg/mod \
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
# Test runner stage - includes Go runtime and test dependencies # Test runner stage - includes Go runtime and test dependencies
# This stage is ONLY used for running tests, never deployed to production # This stage is ONLY used for running tests, never deployed to production
+60 -25
View File
@@ -1,4 +1,4 @@
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick .PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick release
# Include .env file for environment variables (single source of truth) # Include .env file for environment variables (single source of truth)
# Ignore if .env doesn't exist yet # Ignore if .env doesn't exist yet
@@ -7,6 +7,14 @@ ifneq (,$(wildcard ./.env))
export export
endif endif
# Auto-detect container runtime: prefer docker, fall back to podman
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
# Dev compose stack: base prod file merged with the dev override (local build + tests).
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
# Default target # Default target
help: help:
@echo "Available targets:" @echo "Available targets:"
@@ -36,6 +44,9 @@ help:
@echo "Verification:" @echo "Verification:"
@echo " make verify-guidelines - Run comprehensive guidelines check" @echo " make verify-guidelines - Run comprehensive guidelines check"
@echo " make verify-quick - Run quick guidelines check" @echo " make verify-quick - Run quick guidelines check"
@echo ""
@echo "Release:"
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
# Run unit tests locally (fast, no containers) # Run unit tests locally (fast, no containers)
test: test:
@@ -44,23 +55,23 @@ test:
# Run integration tests in containers (matches production environment) # Run integration tests in containers (matches production environment)
test-integration: test-integration:
@echo "Building test containers..." @echo "Building test containers..."
podman compose --profile tests build $(COMPOSE) --profile tests build
@echo "Starting application containers..." @echo "Starting application containers..."
podman compose up -d db app $(COMPOSE) up -d db app
@echo "Waiting for services to be healthy..." @echo "Waiting for services to be healthy..."
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \ @until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
echo " Database not ready yet..."; \ echo " Database not ready yet..."; \
sleep 2; \ sleep 2; \
done; \ done; \
echo " ✓ Database is ready" echo " ✓ Database is ready"
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \ @until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
echo " Application not ready yet..."; \ echo " Application not ready yet..."; \
sleep 2; \ sleep 2; \
done; \ done; \
echo " ✓ Application is ready" echo " ✓ Application is ready"
@echo "" @echo ""
@echo "Running integration tests in container..." @echo "Running integration tests in container..."
podman compose --profile tests run --rm tests $(COMPOSE) --profile tests run --rm tests
@echo "" @echo ""
@echo "✅ Integration tests completed!" @echo "✅ Integration tests completed!"
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop." @echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
@@ -71,76 +82,76 @@ test-all: test test-integration
# Rebuild app container only (preserve DB, with cache) # Rebuild app container only (preserve DB, with cache)
rebuild-app: rebuild-app:
@echo "Rebuilding app container (database stays running)..." @echo "Rebuilding app container (database stays running)..."
podman compose up --build --force-recreate -d app $(COMPOSE) up --build --force-recreate -d app
@echo "✓ App container rebuilt and restarted" @echo "✓ App container rebuilt and restarted"
# Rebuild app container only (preserve DB, no cache) # Rebuild app container only (preserve DB, no cache)
rebuild-app-force: rebuild-app-force:
@echo "Force rebuilding app container (database stays running, no cache)..." @echo "Force rebuilding app container (database stays running, no cache)..."
podman compose build --no-cache app $(COMPOSE) build --no-cache app
podman compose up --force-recreate -d app $(COMPOSE) up --force-recreate -d app
@echo "✓ App container rebuilt and restarted" @echo "✓ App container rebuilt and restarted"
# Rebuild all containers (preserve DB, with cache) # Rebuild all containers (preserve DB, with cache)
rebuild: rebuild:
@echo "Rebuilding all containers (database preserved)..." @echo "Rebuilding all containers (database preserved)..."
podman compose up --build --force-recreate -d $(COMPOSE) up --build --force-recreate -d
@echo "✓ All containers rebuilt and restarted" @echo "✓ All containers rebuilt and restarted"
# Rebuild all containers (preserve DB, no cache) # Rebuild all containers (preserve DB, no cache)
rebuild-force: rebuild-force:
@echo "Force rebuilding all containers (database preserved, no cache)..." @echo "Force rebuilding all containers (database preserved, no cache)..."
podman compose build --no-cache $(COMPOSE) build --no-cache
podman compose up --force-recreate -d $(COMPOSE) up --force-recreate -d
@echo "✓ All containers rebuilt and restarted" @echo "✓ All containers rebuilt and restarted"
# Rebuild all containers (remove DB, no cache) # Rebuild all containers (remove DB, no cache)
rebuild-force-db: rebuild-force-db:
@echo "Force rebuilding all containers (database will be DELETED, no cache)..." @echo "Force rebuilding all containers (database will be DELETED, no cache)..."
podman compose down -v $(COMPOSE) down -v
podman compose build --no-cache $(COMPOSE) build --no-cache
podman compose up --force-recreate -d $(COMPOSE) up --force-recreate -d
@echo "✓ All containers rebuilt and restarted" @echo "✓ All containers rebuilt and restarted"
# Stop and remove containers # Stop and remove containers
clean: clean:
podman compose down -v $(COMPOSE) down -v
# Quick start (if already built) # Quick start (if already built)
up: up:
podman compose up -d $(COMPOSE) up -d
# Stop all containers (alias for clean) # Stop all containers (alias for clean)
down: down:
podman compose down $(COMPOSE) down
# Restart app container (preserves database) # Restart app container (preserves database)
restart: restart:
@echo "Restarting app container (database stays running)..." @echo "Restarting app container (database stays running)..."
podman compose restart app $(COMPOSE) restart app
@echo "✓ App container restarted" @echo "✓ App container restarted"
# Show container status # Show container status
ps: ps:
podman compose ps $(COMPOSE) ps
# Show container logs # Show container logs
logs: logs:
podman compose logs -f $(COMPOSE) logs -f
# Start containers with test mode enabled for manual testing # Start containers with test mode enabled for manual testing
test-env-up: test-env-up:
@echo "Starting containers with test mode enabled..." @echo "Starting containers with test mode enabled..."
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 podman compose up --build --force-recreate -d TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(COMPOSE) up --build --force-recreate -d
@echo "Waiting for services to be ready..." @echo "Waiting for services to be ready..."
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done @until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done @until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
@echo "✓ Test environment is ready!" @echo "✓ Test environment is ready!"
@echo "Application available at http://localhost:8765" @echo "Application available at http://localhost:8765"
# Stop test environment # Stop test environment
test-env-down: test-env-down:
podman compose down -v $(COMPOSE) down -v
# Verify project guidelines compliance # Verify project guidelines compliance
verify-guidelines: verify-guidelines:
@@ -150,3 +161,27 @@ verify-guidelines:
verify-quick: verify-quick:
@echo "Running quick project guidelines verification..." @echo "Running quick project guidelines verification..."
@./scripts/verify-quick.sh @./scripts/verify-quick.sh
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
# image and publishes a Gitea Release whose body is this tag's message. Notes come
# entirely from Conventional Commits — no hand-written message required.
#
# git-cliff's --latest needs the tag to exist to scope the notes, so we create a
# throwaway lightweight tag, generate the notes, replace it with an annotated tag,
# then push. --cleanup=verbatim keeps the markdown "###" group headers (git's
# default cleanup would strip lines starting with "#").
#
# Requires git-cliff: https://git-cliff.org/install
# Usage: make release VERSION=v0.3.0
release:
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=v0.3.0"; exit 1; }
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
@echo "Generating release notes for $(VERSION)..."
@git tag "$(VERSION)" HEAD && \
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
{ git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; }
@git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp
@git push origin "$(VERSION)"
@echo "Pushed $(VERSION) — Gitea Actions will build the image and publish the Release."
+5 -3
View File
@@ -25,7 +25,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
```bash ```bash
# 1. Clone the repository # 1. Clone the repository
git clone https://github.com/yourusername/bookhoard.git git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
cd bookhoard cd bookhoard
# 2. Set up environment # 2. Set up environment
@@ -35,8 +35,10 @@ cp .env.example .env
# DBPASS: openssl rand -hex 16 # DBPASS: openssl rand -hex 16
# Edit .env with your generated values # Edit .env with your generated values
# 3. Start the server # 3. Pull images and start the server
podman-compose up --build -d # or: docker-compose up --build -d docker compose pull
docker compose up -d
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
# 4. Open your browser # 4. Open your browser
open http://localhost:8765 open http://localhost:8765
-379
View File
@@ -1,379 +0,0 @@
# Timezone Implementation Plan
## Overview
Add per-user timezone support with system-wide fallback (set via docker-compose), defaulting to UTC. The database already stores all timestamps as UTC via `TIMESTAMPTZ` columns, so this is primarily a display-layer feature.
**Display format:** MM-DD-YYYY HH:MM (US convention, no timezone abbreviation shown)
**Timezone selection:** Manual dropdown only (no browser auto-detect)
---
## Phase 1: Database Schema
**File:** `database/schema/schema.sql`
1. Add `timezone` column directly to the `users` table definition (line ~36):
```sql
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
first_name VARCHAR(255),
last_name VARCHAR(255),
role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
theme VARCHAR(50) DEFAULT 'tokyo-night',
max_devices INTEGER DEFAULT 10,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
timezone VARCHAR(50) DEFAULT 'UTC'
);
```
> Note: The `timezone` column is already present at line 36 in the current schema. No change needed for this step.
1. Add `default_timezone` to the `system_settings` INSERT block (line ~49-52):
```sql
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
('default_timezone', 'UTC', 'System default timezone')
ON CONFLICT (setting_key) DO NOTHING;
```
1. Add index in the indexes section (after line ~460, with other user indexes):
```sql
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
```
1. Regenerate sqlc code:
```bash
cd internal/database && sqlc generate
```
---
## Phase 2: Database Queries
**File:** `internal/database/queries/queries.sql`
Add new queries:
```sql
-- name: UpdateUserTimezone :exec
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
-- name: GetSystemTimezone :one
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
```
> Note: `UpdateSystemTimezone` is omitted because the existing `UpdateSystemSetting` query handles it by passing `'default_timezone'` as the key parameter.
Regenerate after adding queries:
```bash
cd internal/database && sqlc generate
```
---
## Phase 3: Template Utilities
**File:** `templates/utils.go`
Add timezone-aware time formatting helpers:
```go
package templates
import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
// FormatInTimezone formats a time.Time in the specified timezone as MM-DD-YYYY HH:MM
func FormatInTimezone(t time.Time, timezone string) string {
if t.IsZero() {
return ""
}
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
return t.In(loc).Format("01-02-2006 03:04 PM")
}
// FormatTimestamptzInTimezone formats a pgtype.Timestamptz in the specified timezone
func FormatTimestamptzInTimezone(t pgtype.Timestamptz, timezone string) string {
if !t.Valid {
return ""
}
return FormatInTimezone(t.Time, timezone)
}
```
---
## Phase 4: User Context Update
**File:** `templates/types.go`
Add `Timezone` field to the `User` struct:
```go
type User struct {
ID string
Email string
Username string
Role string
Theme string
FirstName string
LastName string
CreatedAt time.Time
Token string
Timezone string
}
```
**File:** `internal/router/helpers.go`
Update `getTemplateUserWithTheme()` to include timezone:
```go
userTimezone := "UTC"
if userDB.Timezone.Valid {
userTimezone = userDB.Timezone.String
}
return templates.User{
// ... existing fields ...
Timezone: userTimezone,
}
```
---
## Phase 5: Handlers
**File:** `internal/handlers/auth.go`
Update `UpdateProfileRequest` struct:
```go
type UpdateProfileRequest struct {
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
}
```
Add timezone update logic in `UpdateProfile()`:
```go
if req.Timezone != "" {
if _, err := time.LoadLocation(req.Timezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{
"error": "Invalid timezone",
})
}
err := h.db.UpdateUserTimezone(ctx, database.UpdateUserTimezoneParams{
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
})
if err != nil {
return err
}
}
```
**File:** `internal/handlers/system_settings.go`
Add timezone settings handler:
```go
type UpdateTimezoneSettingsRequest struct {
DefaultTimezone string `json:"default_timezone" validate:"required"`
}
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
var req UpdateTimezoneSettingsRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
}
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
SettingKey: "default_timezone",
SettingValue: req.DefaultTimezone,
})
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
}
```
---
## Phase 6: User Profile UI
**File:** `templates/profile_form.templ`
Add timezone dropdown after the theme field:
```templ
<div class="form-group">
<label for="timezone">Timezone</label>
<select name="timezone" id="timezone" class="form-select">
<option value="UTC" selected?={ user.Timezone == "UTC" }>UTC (Coordinated Universal Time)</option>
<option value="America/New_York" selected?={ user.Timezone == "America/New_York" }>Eastern Time</option>
<option value="America/Chicago" selected?={ user.Timezone == "America/Chicago" }>Central Time</option>
<option value="America/Denver" selected?={ user.Timezone == "America/Denver" }>Mountain Time</option>
<option value="America/Los_Angeles" selected?={ user.Timezone == "America/Los_Angeles" }>Pacific Time</option>
<option value="America/Phoenix" selected?={ user.Timezone == "America/Phoenix" }>Mountain Time (no DST)</option>
<option value="America/Anchorage" selected?={ user.Timezone == "America/Anchorage" }>Alaska Time</option>
<option value="Pacific/Honolulu" selected?={ user.Timezone == "Pacific/Honolulu" }>Hawaii Time</option>
</select>
</div>
```
Include timezone in the HTMX form submission payload.
---
## Phase 7: Admin Settings UI
**File:** `templates/admin_settings.templ`
Add system default timezone setting:
```templ
<div class="setting-group">
<h3>System Defaults</h3>
<label for="default_timezone">Default Timezone</label>
<select name="default_timezone" id="default_timezone">
<option value="UTC">UTC (Coordinated Universal Time)</option>
<option value="America/New_York">Eastern Time</option>
<option value="America/Chicago">Central Time</option>
<option value="America/Denver">Mountain Time</option>
<option value="America/Los_Angeles">Pacific Time</option>
<option value="America/Phoenix">Mountain Time (no DST)</option>
<option value="America/Anchorage">Alaska Time</option>
<option value="Pacific/Honolulu">Hawaii Time</option>
</select>
</div>
```
---
## Phase 8: Template Time Display Updates
### Files to update
| Template | Line(s) | Field(s) |
| ------------------------------------ | -------------- | ----------------------------------- |
| `templates/book_detail.templ` | ~253, ~282 | `LastReadAt`, `DatePublished` |
| `templates/book_detail_modals.templ` | ~68, ~113 | `Timestamp`, `LastReadAt` |
| `templates/devices.templ` | ~83, ~91, ~174 | `LastSync`, `LastSeen`, `ExpiresAt` |
| `templates/conflicts.templ` | ~114 | `CreatedAt` |
| `templates/admin_users.templ` | ~89 | `CreatedAt` |
| `templates/queue.templ` | ~138 | `CreatedAt` |
### Change pattern
```templ
<!-- Before -->
{ book.ReadingProgress.LastReadAt.Time.Format("01-02-2006 03:04 PM") }
<!-- After -->
{ templates.FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
```
For `time.Time` fields:
```templ
<!-- Before -->
{ device.LastSync.Format("01-02-2006 03:04 PM") }
<!-- After -->
{ templates.FormatInTimezone(device.LastSync, user.Timezone) }
```
---
## Phase 9: Docker Configuration
**File:** `docker-compose.yml`
```yaml
services:
server:
environment:
- TZ=UTC
```
**File:** `.env.example`
```
# System default timezone (fallback if not set in DB)
TZ=UTC
```
---
## Files Modified Summary
| File | Change |
| --------------------------------------- | ------------------------------------------------------------------------------- |
| `database/schema/schema.sql` | Add timezone column to users, system_setting row |
| `internal/database/queries/queries.sql` | Add UpdateUserTimezone, GetSystemTimezone (reuses existing UpdateSystemSetting) |
| `templates/utils.go` | Add FormatInTimezone, FormatTimestamptzInTimezone |
| `templates/types.go` | Add Timezone field to User struct |
| `internal/router/helpers.go` | Pass timezone to template User |
| `internal/handlers/auth.go` | Handle timezone updates in UpdateProfile |
| `internal/handlers/system_settings.go` | Add timezone settings handler |
| `templates/profile_form.templ` | Add timezone dropdown |
| `templates/admin_settings.templ` | Add default timezone setting |
| `templates/book_detail.templ` | Update time displays |
| `templates/book_detail_modals.templ` | Update time displays |
| `templates/devices.templ` | Update time displays |
| `templates/conflicts.templ` | Update time displays |
| `templates/admin_users.templ` | Update time displays |
| `templates/queue.templ` | Update time displays |
| `docker-compose.yml` | Add TZ env var |
| `.env.example` | Add TZ example |
---
## Testing Checklist
- [ ] Create user, set timezone to Eastern, verify times display in MM-DD-YYYY HH:MM format
- [ ] Create user, set timezone to Pacific, verify different offset
- [ ] Test system default timezone fallback for users with no timezone set
- [ ] Verify invalid timezone values are rejected by the API
- [ ] Verify existing users (no timezone set) fall back to system default
- [ ] Verify all templates show consistent MM-DD-YYYY HH:MM format
- [ ] Run `make test-integration` to verify no regressions
---
## Deployment Steps
1. Update `database/schema/schema.sql` with new column and settings
2. Regenerate sqlc: `cd internal/database && sqlc generate`
3. Apply schema changes (restart database container with `make rebuild-force-db`)
4. Deploy backend code changes
5. Verify with existing data
+1 -1
View File
@@ -47,7 +47,7 @@ docs: |-
- `id` (string, required): Media item UUID - `id` (string, required): Media item UUID
**Request Body:** **Request Body:**
- `rating` (number, required): Rating value (typically 1-5) - `rating` (number, required): Rating value (1-10 integer scale; displayed as 1-5 stars with half-star precision)
- `review` (string, optional): Review text - `review` (string, optional): Review text
**Response:** Updated rating object **Response:** Updated rating object
+4 -3
View File
@@ -83,9 +83,10 @@ docs:
- **Update Highlight**: PUT /api/highlights/:id - Update highlight - **Update Highlight**: PUT /api/highlights/:id - Update highlight
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight - **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
Ratings (All Users) Ratings (All Users)
- **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 if unrated) - **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated)
- **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision) - **Create/Update Rating**: POST /api/media-items/:id/rating - Rate media item (1-10 scale, displayed as 1-5 stars with half-star precision). POST upserts; PUT also available.
- **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating - **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert)
- **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating
Collections (All Users) Collections (All Users)
- **List Collections**: GET /api/collections - Get user's collections - **List Collections**: GET /api/collections - Get user's collections
- **Get Collection**: GET /api/collections/:id - Collection details with media items - **Get Collection**: GET /api/collections/:id - Collection details with media items
+37
View File
@@ -0,0 +1,37 @@
# git-cliff configuration — generates the body of each Gitea Release from
# Conventional Commits accumulated since the previous tag. Invoked in CI by
# orhun/git-cliff-action with --latest so only the current tag's section is
# emitted (no full history, no header — the Gitea Release title is the tag).
# Docs: https://git-cliff.org/docs/configuration
[changelog]
header = ""
body = """
{% for group, commits in commits | group_by(attribute="group") %}\
### {{ group | upper_first }}
{% for commit in commits %}\
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
{% endfor %}\
{% endfor %}\
"""
trim = true
footer = ""
[git]
conventional_commits = true
filter_unconventional = false
require_conventional = false
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Features" },
{ message = "^fix", group = "Bug Fixes" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Refactor" },
{ message = "^docs", group = "Documentation" },
{ message = "^test", group = "Tests" },
{ message = "^chore|^ci", group = "Miscellaneous Tasks" },
{ message = ".*", group = "Other" },
]
filter_commits = false
tag_pattern = "v[0-9].*"
sort_commits = "oldest"
+9
View File
@@ -63,9 +63,13 @@ func main() {
connManager := sync.NewConnectionManager() connManager := sync.NewConnectionManager()
progressService := sync.NewProgressService(queries, connManager) progressService := sync.NewProgressService(queries, connManager)
annotationService := sync.NewAnnotationService(queries, connManager)
tombstonePurgerCancel := annotationService.StartTombstonePurger()
defer tombstonePurgerCancel()
queueProcessor := sync.NewSyncQueueProcessor(queries) queueProcessor := sync.NewSyncQueueProcessor(queries)
queueProcessor.SetProgressService(progressService) queueProcessor.SetProgressService(progressService)
queueProcessor.SetAnnotationService(annotationService)
// Create library service // Create library service
libraryService := services.NewLibraryService(queries) libraryService := services.NewLibraryService(queries)
@@ -79,6 +83,8 @@ func main() {
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
koreaderHandler.SetProgressService(progressService) koreaderHandler.SetProgressService(progressService)
koreaderHandler.SetAnnotationService(annotationService)
koreaderHandler.SetLibraryService(libraryService)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager) conflictHandler := handlers.NewConflictHandler(queries, connManager)
analyticsHandler := handlers.NewAnalyticsHandler(queries) analyticsHandler := handlers.NewAnalyticsHandler(queries)
@@ -94,6 +100,7 @@ func main() {
filtersHandler := handlers.NewFiltersHandler(queries) filtersHandler := handlers.NewFiltersHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
mediaHandler.SetProgressService(progressService) mediaHandler.SetProgressService(progressService)
mediaHandler.SetAnnotationService(annotationService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager) matchingHandler := handlers.NewMatchingHandler(queries, connManager)
jobsHandler := handlers.NewJobsHandler(queries, worker) jobsHandler := handlers.NewJobsHandler(queries, worker)
@@ -161,9 +168,11 @@ func main() {
ConnManager: connManager, ConnManager: connManager,
QueueProcessor: queueProcessor, QueueProcessor: queueProcessor,
ProgressService: progressService, ProgressService: progressService,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware, DeviceAuthMiddleware: deviceAuthMiddleware,
JobsHandler: jobsHandler, JobsHandler: jobsHandler,
LoginTracker: loginAttemptTracker, LoginTracker: loginAttemptTracker,
LibraryService: libraryService,
} }
// Register all routes and get ebook handler // Register all routes and get ebook handler
+5
View File
@@ -84,6 +84,7 @@ type TestServerSetup struct {
ConnManager *wsync.ConnectionManager ConnManager *wsync.ConnectionManager
QueueProcessor *wsync.SyncQueueProcessor QueueProcessor *wsync.SyncQueueProcessor
ProgressService *wsync.ProgressService ProgressService *wsync.ProgressService
AnnotationService *wsync.AnnotationService
CleanupCancel context.CancelFunc CleanupCancel context.CancelFunc
QueueCtx context.Context QueueCtx context.Context
QueueCancel context.CancelFunc QueueCancel context.CancelFunc
@@ -455,6 +456,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
cleanupCancel := connManager.StartCleanupTask() cleanupCancel := connManager.StartCleanupTask()
progressService := wsync.NewProgressService(queries, connManager) progressService := wsync.NewProgressService(queries, connManager)
annotationService := wsync.NewAnnotationService(queries, connManager)
queueProcessor := wsync.NewSyncQueueProcessor(queries) queueProcessor := wsync.NewSyncQueueProcessor(queries)
queueProcessor.SetProgressService(progressService) queueProcessor.SetProgressService(progressService)
@@ -463,6 +465,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor) koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
koreaderHandler.SetProgressService(progressService) koreaderHandler.SetProgressService(progressService)
koreaderHandler.SetAnnotationService(annotationService)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware) wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager) conflictHandler := handlers.NewConflictHandler(queries, connManager)
analyticsHandler := handlers.NewAnalyticsHandler(queries) analyticsHandler := handlers.NewAnalyticsHandler(queries)
@@ -482,6 +485,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
seriesHandler := handlers.NewSeriesHandler(queries) seriesHandler := handlers.NewSeriesHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker) mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
mediaHandler.SetProgressService(progressService) mediaHandler.SetProgressService(progressService)
mediaHandler.SetAnnotationService(annotationService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager) matchingHandler := handlers.NewMatchingHandler(queries, connManager)
// Create conversion service for OPDS // Create conversion service for OPDS
@@ -537,6 +541,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
ConnManager: connManager, ConnManager: connManager,
QueueProcessor: queueProcessor, QueueProcessor: queueProcessor,
ProgressService: progressService, ProgressService: progressService,
AnnotationService: annotationService,
DeviceAuthMiddleware: deviceAuthMiddleware, DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker, LoginTracker: loginAttemptTracker,
} }
+49
View File
@@ -245,6 +245,7 @@ CREATE TABLE IF NOT EXISTS reading_progress (
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1), percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
character_offset BIGINT, character_offset BIGINT,
epubcfi TEXT, epubcfi TEXT,
context_text TEXT,
chapter INTEGER, chapter INTEGER,
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1), chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
viewport_x FLOAT DEFAULT 0, viewport_x FLOAT DEFAULT 0,
@@ -937,6 +938,7 @@ BEGIN
percentage = (book_record->>'percentage')::FLOAT, percentage = (book_record->>'percentage')::FLOAT,
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END, character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END, epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
context_text = CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE existing_progress.context_text END,
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END, chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
chapter_progress = (book_record->>'percentage')::FLOAT, chapter_progress = (book_record->>'percentage')::FLOAT,
last_sync_device = 'koreader', last_sync_device = 'koreader',
@@ -955,6 +957,7 @@ BEGIN
percentage, percentage,
character_offset, character_offset,
epubcfi, epubcfi,
context_text,
chapter, chapter,
chapter_progress, chapter_progress,
last_sync_device, last_sync_device,
@@ -971,6 +974,7 @@ BEGIN
(book_record->>'percentage')::FLOAT, (book_record->>'percentage')::FLOAT,
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END, CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END, CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END,
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END, CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
(book_record->>'percentage')::FLOAT, (book_record->>'percentage')::FLOAT,
'koreader', 'koreader',
@@ -1308,3 +1312,48 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id); CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id); CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
-- ============================================
-- ANNOTATION SYNC MIGRATIONS
-- Adds dedup_key, LWW timestamps, soft-delete,
-- and device_sync_data to annotation tables.
-- ============================================
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB;
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT;
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;
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
ON media_highlights (user_id, media_item_id, dedup_key)
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup
ON media_notes (user_id, media_item_id, dedup_key)
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup
ON media_bookmarks (user_id, media_item_id, dedup_key)
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;
+57
View File
@@ -0,0 +1,57 @@
# Development override — merged on top of docker-compose.yml (the base/prod file).
# Activated by all `make` targets via:
# COMPOSE = <runtime> compose -f docker-compose.yml -f docker-compose.dev.yml
#
# What this adds over prod:
# - Local image BUILDING (prod pulls a prebuilt image from the registry)
# - The integration-tests service (dev only, gated behind the "tests" profile)
# Everything else (env vars, volumes, ports, healthchecks) is inherited from the base file.
services:
# Build the app image locally instead of pulling from the registry
app:
build:
context: .
dockerfile: ./Dockerfile
# Integration Tests - runs against containerized app and db (dev only)
tests:
build:
context: .
dockerfile: ./Dockerfile
target: test-runner
container_name: bookhoard_tests
environment:
# Database Configuration
DATABASE_HOST: db
DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS}
DATABASE_NAME: bookhoard
COOKIE_SECURE: false
# Application Configuration
JWT_SECRET: ${JWT_SECRET}
SERVER_PORT: ${SERVER_PORT:-8765}
# Test Configuration
TEST_MODE: "true"
RATE_LIMIT_ENABLED: "false"
REQUESTS_PER_MINUTE: 1000
# Conversion Service Configuration
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
# Test upload path (inside container)
TEST_UPLOAD_PATH: /app/uploads
depends_on:
db:
condition: service_healthy
app:
condition: service_healthy
volumes:
- ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub
profiles:
- tests
+13 -55
View File
@@ -1,5 +1,3 @@
version: "3.8"
services: services:
# PostgreSQL Database # PostgreSQL Database
db: db:
@@ -9,14 +7,15 @@ services:
POSTGRES_DB: bookhoard POSTGRES_DB: bookhoard
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${DBPASS} POSTGRES_PASSWORD: ${DBPASS}
COOKIE_SECURE: false # make true in production with HTTPS # PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
PGPORT: ${DB_PORT:-5432}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./database/schema:/docker-entrypoint-initdb.d - ./database/schema:/docker-entrypoint-initdb.d
# Make other volumes as needed # Make other volumes as needed
- ./uploads:/app/uploads - ./uploads:/app/uploads
ports: ports:
- "5432:5432" - "${DB_PORT:-5432}:${DB_PORT:-5432}"
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s interval: 30s
@@ -27,27 +26,29 @@ services:
- .env - .env
# Bookhoard Application # Bookhoard Application
# In production this image is pulled from the Gitea container registry.
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
app: app:
build: image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
context: .
dockerfile: ./Dockerfile
container_name: bookhoard container_name: bookhoard
environment: environment:
# Database Configuration # Database Configuration
DATABASE_HOST: db DATABASE_HOST: db
DATABASE_PORT: 5432 DATABASE_PORT: ${DB_PORT:-5432}
DATABASE_USER: postgres DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS} DATABASE_PASSWORD: ${DBPASS}
DATABASE_NAME: bookhoard DATABASE_NAME: bookhoard
# Application Configuration # Application Configuration
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
SERVER_PORT: 8765 SERVER_PORT: ${SERVER_PORT:-8765}
# IMPORTANT: Device sync requires full URL with protocol # IMPORTANT: Device sync requires full URL with protocol
# Local: http://localhost:8765 # Local: http://localhost:8765
# Local network: http://192.168.1.X:8765 # Local network: http://192.168.1.X:8765
# Domain: https://bookhoard.example.com # Domain: https://bookhoard.example.com
BASE_URL: http://localhost:${SERVER_PORT} BASE_URL: ${BASE_URL:-http://localhost:8765}
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
COOKIE_SECURE: ${COOKIE_SECURE:-false}
# Rate Limiting Configuration # Rate Limiting Configuration
TEST_MODE: ${TEST_MODE:-false} TEST_MODE: ${TEST_MODE:-false}
@@ -62,7 +63,7 @@ services:
# System timezone (fallback for server-side time operations) # System timezone (fallback for server-side time operations)
TZ: ${TZ:-UTC} TZ: ${TZ:-UTC}
ports: ports:
- "8765:8765" - "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -70,55 +71,12 @@ services:
- ./uploads:/app/uploads - ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub - bookhoard_conversion_cache:/app/cache/kepub
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"] test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10s start_period: 10s
# Integration Tests - runs against containerized app and db
tests:
build:
context: .
dockerfile: ./Dockerfile
target: test-runner
container_name: bookhoard_tests
environment:
# Database Configuration
DATABASE_HOST: db
DATABASE_PORT: 5432
DATABASE_USER: postgres
DATABASE_PASSWORD: ${DBPASS}
DATABASE_NAME: bookhoard
COOKIE_SECURE: false
# Application Configuration
JWT_SECRET: ${JWT_SECRET}
SERVER_PORT: 8765
# Test Configuration
TEST_MODE: "true"
RATE_LIMIT_ENABLED: "false"
REQUESTS_PER_MINUTE: 1000
# Conversion Service Configuration
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
# Test upload path (inside container)
TEST_UPLOAD_PATH: /app/uploads
depends_on:
db:
condition: service_healthy
app:
condition: service_healthy
volumes:
- ./uploads:/app/uploads
- bookhoard_conversion_cache:/app/cache/kepub
profiles:
- tests
# Named Volumes # Named Volumes
volumes: volumes:
postgres_data: postgres_data:
+40 -8
View File
@@ -986,25 +986,39 @@ GET /opds/devices/{deviceId}/catalog?page={page}&per_page={per_page}
- `page` (optional): Page number (default: 1) - `page` (optional): Page number (default: 1)
- `per_page` (optional): Items per page (default: 50, max: 200) - `per_page` (optional): Items per page (default: 50, max: 200)
The feed is paginated via standard OPDS link relations. Clients (e.g. KOReader)
walk pages by following the `rel="next"` link until it is absent. OpenSearch
paging metadata (`totalResults`, `itemsPerPage`, `startIndex`) is also included.
**Response** (200 - OPDS 1.2 XML): **Response** (200 - OPDS 1.2 XML):
```xml ```xml
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" <feed xmlns="http://www.w3.org/2005/Atom"
xmlns:opds="http://opds-spec.org/2010/" xmlns:opds="http://opds-spec.org/2010/"
xmlns:dc="http://purl.org/dc/elements/1.1/"> xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
<id>urn:uuid:device-id</id> <id>urn:uuid:device-id</id>
<title>Bookhoard Library</title> <title>Bookhoard Library</title>
<updated>2026-02-01T12:00:00Z</updated> <updated>2026-02-01T12:00:00Z</updated>
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog"/> <link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=2&per_page=50"/>
<link rel="search" href="http://localhost:8765/opds/devices/kobo-id/search"/> <link rel="start" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/nav"/> <link rel="first" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
<link rel="previous" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
<link rel="next" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=3&per_page=50"/>
<link rel="last" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=37&per_page=50"/>
<link rel="search" type="application/opensearchdescription+xml"
href="http://localhost:8765/opds/devices/kobo-id/search"/>
<opensearch:totalResults>1814</opensearch:totalResults>
<opensearch:itemsPerPage>50</opensearch:itemsPerPage>
<opensearch:startIndex>51</opensearch:startIndex>
<entry> <entry>
<id>urn:uuid:bookhoard-uuid-123</id> <id>urn:uuid:bookhoard-uuid-123</id>
<dc:title>The Hobbit</dc:title> <title>The Hobbit</title>
<dc:creator>J.R.R. Tolkien</dc:creator> <author><name>J.R.R. Tolkien</name></author>
<updated>2026-02-01T10:00:00Z</updated> <updated>2026-02-01T10:00:00Z</updated>
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123" <link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
@@ -1043,10 +1057,28 @@ GET /opds/devices/{deviceId}/download/{bookId}?format={format}
### Search OPDS Catalog ### Search OPDS Catalog
```http ```http
GET /opds/devices/{deviceId}/search?q={query} GET /opds/devices/{deviceId}/search # OpenSearch description
GET /opds/devices/{deviceId}/search?q={query} # search results feed
``` ```
**Response** (200 - OPDS 1.2 XML with search results) When called **without** a `q` parameter, returns an OpenSearch description
document (`application/opensearchdescription+xml`). OPDS clients fetch this to
learn the search URL template, then substitute `{searchTerms}`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>Bookhoard</ShortName>
<Description>Search the Bookhoard library</Description>
<InputEncoding>UTF-8</InputEncoding>
<OutputEncoding>UTF-8</OutputEncoding>
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"
template="http://localhost:8765/opds/devices/kobo-id/search?q={searchTerms}"/>
</OpenSearchDescription>
```
When called **with** a `q` parameter, **Response** (200 - OPDS 1.2 XML with
search results, including `opensearch:totalResults`).
### List Available Formats ### List Available Formats
+52 -31
View File
@@ -155,40 +155,55 @@ type LibraryVisibility struct {
} }
type MediaBookmarks struct { type MediaBookmarks struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"` UserID pgtype.UUID `db:"user_id" json:"user_id"`
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"` PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"` ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"` CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
Title string `db:"title" json:"title"` Title string `db:"title" json:"title"`
Position pgtype.Text `db:"position" json:"position"` Position pgtype.Text `db:"position" json:"position"`
Notes pgtype.Text `db:"notes" json:"notes"` Notes pgtype.Text `db:"notes" json:"notes"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
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"`
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
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"`
} }
type MediaHighlights struct { type MediaHighlights struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"` UserID pgtype.UUID `db:"user_id" json:"user_id"`
SelectionText string `db:"selection_text" json:"selection_text"` SelectionText string `db:"selection_text" json:"selection_text"`
StartPosition pgtype.Text `db:"start_position" json:"start_position"` StartPosition pgtype.Text `db:"start_position" json:"start_position"`
EndPosition pgtype.Text `db:"end_position" json:"end_position"` EndPosition pgtype.Text `db:"end_position" json:"end_position"`
Color pgtype.Text `db:"color" json:"color"` Color pgtype.Text `db:"color" json:"color"`
NoteID pgtype.UUID `db:"note_id" json:"note_id"` NoteID pgtype.UUID `db:"note_id" json:"note_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"` PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"` PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"` CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"` CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"` EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"` EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"` ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"` ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"` PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
NoteText pgtype.Text `db:"note_text" json:"note_text"`
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
} }
type MediaItemFormats struct { type MediaItemFormats struct {
@@ -304,6 +319,11 @@ type MediaNotes struct {
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"` ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
} }
type MediaRatings struct { type MediaRatings struct {
@@ -380,6 +400,7 @@ type ReadingProgress struct {
Percentage pgtype.Float8 `db:"percentage" json:"percentage"` Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"` CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"` Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
ContextText pgtype.Text `db:"context_text" json:"context_text"`
Chapter pgtype.Int4 `db:"chapter" json:"chapter"` Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"` ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"` ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
+37
View File
@@ -30,9 +30,11 @@ type Querier interface {
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
CountAdmins(ctx context.Context) (int64, error)
// Count unlinked books for a device // Count unlinked books for a device
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
// COLLECTIONS QUERIES // COLLECTIONS QUERIES
// Create collection // Create collection
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error) CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
@@ -54,8 +56,10 @@ type Querier interface {
// Libraries queries // Libraries queries
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error) CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error) CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error)
// Media Highlights queries // Media Highlights queries
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error) CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
// Media Items queries // Media Items queries
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
// MEDIA ITEM FORMATS QUERIES // MEDIA ITEM FORMATS QUERIES
@@ -63,6 +67,7 @@ type Querier interface {
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error) CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
// Media Notes queries // Media Notes queries
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error) CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error) CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
// OPDS TOKENS QUERIES // OPDS TOKENS QUERIES
// Create OPDS token // Create OPDS token
@@ -125,6 +130,10 @@ type Querier interface {
DeleteUser(ctx context.Context, id pgtype.UUID) error DeleteUser(ctx context.Context, id pgtype.UUID) error
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error) GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
// ============================================
// ANNOTATION SERVE QUERIES
// ============================================
GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error)
// Get all system config // Get all system config
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error) GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error) GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
@@ -196,8 +205,17 @@ type Querier interface {
// LIBRARY WITH TYPE INFO QUERIES // LIBRARY WITH TYPE INFO QUERIES
// ============================================================================ // ============================================================================
GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error) GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error)
GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error)
// ============================================
// ANNOTATION SYNC QUERIES (bookmarks)
// ============================================
GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBookmarkByDedupKeyParams) (MediaBookmarks, error)
GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error) GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error)
GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error) GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error)
// ============================================
// ANNOTATION SYNC QUERIES (highlights)
// ============================================
GetMediaHighlightByDedupKey(ctx context.Context, arg GetMediaHighlightByDedupKeyParams) (MediaHighlights, error)
GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error) GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error)
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error) GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error)
@@ -220,6 +238,10 @@ type Querier interface {
// Get media item formats // Get media item formats
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error) GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error) GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
// ============================================
// ANNOTATION SYNC QUERIES (notes)
// ============================================
GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error)
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error) GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error) GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
@@ -257,6 +279,7 @@ type Querier interface {
// System Settings queries // System Settings queries
GetSystemSetting(ctx context.Context, settingKey string) (string, error) GetSystemSetting(ctx context.Context, settingKey string) (string, error)
GetSystemTimezone(ctx context.Context) (string, error) GetSystemTimezone(ctx context.Context) (string, error)
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
// Get universal progress for a book // Get universal progress for a book
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
// Get unlinked book by ContentId // Get unlinked book by ContentId
@@ -280,6 +303,8 @@ type Querier interface {
// Get user reading history for analytics // Get user reading history for analytics
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error) GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
// Check if book is in collection // Check if book is in collection
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error) IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
@@ -302,6 +327,9 @@ type Querier interface {
// List unresolved unlinked books with pagination // List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error)
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
// Query media items by multiple identifiers with confidence scoring // Query media items by multiple identifiers with confidence scoring
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error) QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
@@ -332,6 +360,12 @@ type Querier interface {
// Set system config // Set system config
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error) SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
// Update collection // Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error) UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
@@ -355,7 +389,9 @@ type Querier interface {
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error) UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error) UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, error) UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, error)
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error) UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error) UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
// Update media item format // Update media item format
@@ -373,6 +409,7 @@ type Querier interface {
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error) UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error) UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error)
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error) UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error) UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
File diff suppressed because it is too large Load Diff
+282 -6
View File
@@ -137,6 +137,14 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
ORDER BY l.created_at ASC; ORDER BY l.created_at ASC;
-- name: GetVisibleLibraryMediaCounts :many
SELECT l.id, COUNT(mi.id) as media_count
FROM libraries l
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
LEFT JOIN media_items mi ON mi.library_id = l.id
WHERE COALESCE(lv.is_visible, true) = true
GROUP BY l.id;
-- Media Items queries -- Media Items queries
-- name: CreateMediaItem :one -- 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) 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)
@@ -346,6 +354,9 @@ WHERE role = 'admin'
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT 1; LIMIT 1;
-- name: CountAdmins :one
SELECT COUNT(*) FROM users WHERE role = 'admin';
-- name: ReassignLibraries :exec -- name: ReassignLibraries :exec
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1; UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
@@ -693,7 +704,7 @@ RETURNING *;
SELECT * FROM media_notes WHERE id = $1; SELECT * FROM media_notes WHERE id = $1;
-- name: GetMediaNotes :many -- name: GetMediaNotes :many
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC; SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
-- name: UpdateMediaNote :one -- name: UpdateMediaNote :one
UPDATE media_notes SET UPDATE media_notes SET
@@ -716,7 +727,7 @@ RETURNING *;
SELECT * FROM media_highlights WHERE id = $1; SELECT * FROM media_highlights WHERE id = $1;
-- name: GetMediaHighlights :many -- name: GetMediaHighlights :many
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC; SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
-- name: UpdateMediaHighlight :one -- name: UpdateMediaHighlight :one
UPDATE media_highlights SET UPDATE media_highlights SET
@@ -732,6 +743,251 @@ RETURNING *;
-- name: DeleteMediaHighlight :exec -- name: DeleteMediaHighlight :exec
DELETE FROM media_highlights WHERE id = $1; DELETE FROM media_highlights WHERE id = $1;
-- ============================================
-- ANNOTATION SYNC QUERIES (highlights)
-- ============================================
-- name: GetMediaHighlightByDedupKey :one
SELECT * FROM media_highlights
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
LIMIT 1;
-- name: CreateMediaHighlightFull :one
INSERT INTO media_highlights (
media_item_id, user_id, selection_text,
start_position, end_position, color, note_text,
percentage_start, percentage_end,
epubcfi_start, epubcfi_end,
chapter_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
) RETURNING *;
-- name: UpdateMediaHighlightForSync :one
UPDATE media_highlights SET
selection_text = $2,
start_position = $3,
end_position = $4,
color = $5,
note_text = $6,
percentage_start = $7,
percentage_end = $8,
epubcfi_start = $9,
epubcfi_end = $10,
chapter_reference = $11,
last_modified_at = $12,
last_modified_source = $13,
device_sync_data = $14,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: TombstoneMediaHighlightByDedupKey :exec
UPDATE media_highlights SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
-- name: TombstoneMediaHighlightByID :exec
UPDATE media_highlights SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
-- name: PurgeExpiredHighlightTombstones :exec
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1;
-- ============================================
-- ANNOTATION SYNC QUERIES (notes)
-- ============================================
-- name: GetMediaNoteByDedupKey :one
SELECT * FROM media_notes
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
LIMIT 1;
-- name: CreateMediaNoteFull :one
INSERT INTO media_notes (
media_item_id, user_id, content, position,
percentage_location, character_start, character_end,
epubcfi_location, chapter_reference, paragraph_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
) RETURNING *;
-- name: UpdateMediaNoteForSync :one
UPDATE media_notes SET
content = $2,
position = $3,
percentage_location = $4,
character_start = $5,
character_end = $6,
epubcfi_location = $7,
chapter_reference = $8,
paragraph_reference = $9,
last_modified_at = $10,
last_modified_source = $11,
device_sync_data = $12,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: TombstoneMediaNoteByDedupKey :exec
UPDATE media_notes SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
-- name: TombstoneMediaNoteByID :exec
UPDATE media_notes SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
-- name: PurgeExpiredNoteTombstones :exec
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1;
-- ============================================
-- ANNOTATION SYNC QUERIES (bookmarks)
-- ============================================
-- name: GetMediaBookmarkByDedupKey :one
SELECT * 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;
-- name: CreateMediaBookmarkFull :one
INSERT INTO media_bookmarks (
media_item_id, user_id, page_number, chapter_number,
cfi_position, title, position, notes,
percentage_location, epubcfi_location, chapter_reference,
dedup_key, last_modified_at, last_modified_source,
device_sync_data
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
) RETURNING *;
-- name: UpdateMediaBookmarkForSync :one
UPDATE media_bookmarks SET
page_number = $2,
chapter_number = $3,
cfi_position = $4,
title = $5,
position = $6,
notes = $7,
percentage_location = $8,
epubcfi_location = $9,
chapter_reference = $10,
last_modified_at = $11,
last_modified_source = $12,
device_sync_data = $13,
created_at = created_at
WHERE id = $1
RETURNING *;
-- name: TombstoneMediaBookmarkByDedupKey :exec
UPDATE media_bookmarks SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
-- name: TombstoneMediaBookmarkByID :exec
UPDATE media_bookmarks SET
deleted = TRUE,
deleted_at = NOW(),
last_modified_at = NOW()
WHERE id = $1;
-- name: PurgeExpiredBookmarkTombstones :exec
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1;
-- ============================================
-- ANNOTATION SERVE QUERIES
-- ============================================
-- name: GetActiveAnnotationsForBook :many
SELECT
mh.id,
mh.selection_text,
mh.start_position,
mh.end_position,
mh.color,
mh.created_at,
mh.updated_at,
'highlight' as annotation_type,
mh.percentage_start,
mh.percentage_end,
mh.epubcfi_start,
mh.epubcfi_end,
mh.note_text,
mh.dedup_key,
mh.last_modified_at,
mh.last_modified_source
FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = FALSE
UNION ALL
SELECT
mn.id,
mn.content,
mn.position,
NULL as end_position,
NULL as color,
mn.created_at,
mn.updated_at,
'note' as annotation_type,
mn.percentage_location as percentage_start,
NULL as percentage_end,
mn.epubcfi_location as epubcfi_start,
NULL as epubcfi_end,
NULL as note_text,
mn.dedup_key,
mn.last_modified_at,
mn.last_modified_source
FROM media_notes mn
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = FALSE
ORDER BY created_at DESC;
-- name: GetTombstonedAnnotationsForBook :many
SELECT
mh.id,
mh.dedup_key,
'highlight' as annotation_type,
mh.device_sync_data,
mh.deleted_at
FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
UNION ALL
SELECT
mn.id,
mn.dedup_key,
'note' as annotation_type,
mn.device_sync_data,
mn.deleted_at
FROM media_notes mn
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
UNION ALL
SELECT
mb.id,
mb.dedup_key,
'bookmark' as annotation_type,
mb.device_sync_data,
mb.deleted_at
FROM media_bookmarks mb
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
ORDER BY deleted_at DESC;
-- Refresh Tokens queries -- Refresh Tokens queries
-- name: CreateRefreshToken :one -- name: CreateRefreshToken :one
INSERT INTO refresh_tokens (user_id, token, expires_at) INSERT INTO refresh_tokens (user_id, token, expires_at)
@@ -793,6 +1049,7 @@ SELECT
rp.percentage, rp.percentage,
rp.character_offset, rp.character_offset,
rp.epubcfi, rp.epubcfi,
rp.context_text,
rp.chapter, rp.chapter,
rp.chapter_progress, rp.chapter_progress,
rp.viewport_x, rp.viewport_x,
@@ -825,6 +1082,7 @@ INSERT INTO reading_progress (
percentage, percentage,
character_offset, character_offset,
epubcfi, epubcfi,
context_text,
chapter, chapter,
chapter_progress, chapter_progress,
viewport_x, viewport_x,
@@ -842,13 +1100,14 @@ INSERT INTO reading_progress (
last_read_at last_read_at
) )
VALUES ( VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, NOW() $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, NOW()
) )
ON CONFLICT (media_item_id, user_id) ON CONFLICT (media_item_id, user_id)
DO UPDATE SET DO UPDATE SET
percentage = EXCLUDED.percentage, percentage = EXCLUDED.percentage,
character_offset = EXCLUDED.character_offset, character_offset = EXCLUDED.character_offset,
epubcfi = EXCLUDED.epubcfi, epubcfi = EXCLUDED.epubcfi,
context_text = EXCLUDED.context_text,
chapter = EXCLUDED.chapter, chapter = EXCLUDED.chapter,
chapter_progress = EXCLUDED.chapter_progress, chapter_progress = EXCLUDED.chapter_progress,
viewport_x = EXCLUDED.viewport_x, viewport_x = EXCLUDED.viewport_x,
@@ -1120,6 +1379,11 @@ INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
RETURNING *; RETURNING *;
-- name: CreateAutoResolvedSyncConflict :one
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at)
VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW())
RETURNING *;
-- name: GetSyncConflict :one -- name: GetSyncConflict :one
SELECT * FROM sync_conflicts WHERE id = $1; SELECT * FROM sync_conflicts WHERE id = $1;
@@ -1162,6 +1426,15 @@ JOIN media_items mi ON sc.media_item_id = mi.id
WHERE sc.user_id = $1 WHERE sc.user_id = $1
ORDER BY sc.created_at DESC; ORDER BY sc.created_at DESC;
-- name: HasRecentConflictResolution :one
SELECT EXISTS(
SELECT 1 FROM sync_conflicts
WHERE media_item_id = $1
AND user_id = $2
AND resolution_status != 'unresolved'
AND resolved_at > NOW() - INTERVAL '10 minutes'
);
-- ============================================ -- ============================================
-- KOREADER SYNC PROTOCOL -- KOREADER SYNC PROTOCOL
-- ============================================ -- ============================================
@@ -1210,7 +1483,7 @@ SELECT
mh.epubcfi_start, mh.epubcfi_start,
mh.epubcfi_end mh.epubcfi_end
FROM media_highlights mh FROM media_highlights mh
WHERE mh.media_item_id = $1 AND mh.user_id = $2 WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
UNION ALL UNION ALL
SELECT SELECT
mn.id, mn.id,
@@ -1226,7 +1499,7 @@ SELECT
mn.epubcfi_location as epubcfi_start, mn.epubcfi_location as epubcfi_start,
NULL as epubcfi_end NULL as epubcfi_end
FROM media_notes mn FROM media_notes mn
WHERE mn.media_item_id = $1 AND mn.user_id = $2 WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
ORDER BY created_at DESC; ORDER BY created_at DESC;
-- name: UpdateDeviceSyncTimestamp :one -- name: UpdateDeviceSyncTimestamp :one
@@ -2097,9 +2370,12 @@ RETURNING *;
-- name: GetMediaBookmarks :many -- name: GetMediaBookmarks :many
SELECT * FROM media_bookmarks SELECT * FROM media_bookmarks
WHERE media_item_id = $1 AND user_id = $2 WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
ORDER BY created_at DESC; ORDER BY created_at DESC;
-- name: GetMediaBookmark :one
SELECT * FROM media_bookmarks WHERE id = $1;
-- name: CreateMediaBookmark :one -- name: CreateMediaBookmark :one
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes) 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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
+28 -17
View File
@@ -6,6 +6,7 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/middleware" "bookhoard/internal/middleware"
"bookhoard/internal/setupstatus"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@@ -82,22 +83,22 @@ type UserProfile struct {
} }
type UpdateProfileRequest struct { type UpdateProfileRequest struct {
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"` Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"` Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"` FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"` LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"` Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"` Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
} }
type AdminUpdateUserRequest struct { type AdminUpdateUserRequest struct {
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"` Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
Email string `json:"email,omitempty" validate:"omitempty,email"` Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"` FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"` LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
Theme string `json:"theme,omitempty" validate:"omitempty"` Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
Timezone string `json:"timezone,omitempty" validate:"omitempty"` Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"` Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
} }
// Register handles POST /api/auth/register // Register handles POST /api/auth/register
@@ -190,7 +191,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
} }
var userRole string var userRole string
if len(users) == 0 { if !adminExists {
userRole = "admin" userRole = "admin"
} else { } else {
userRole = req.Role userRole = req.Role
@@ -232,6 +233,10 @@ func (h *AuthHandler) Register(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
// A new user may have changed the admin count (e.g. first user becomes
// admin), so refresh the setup-status cache.
setupstatus.Invalidate()
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil { if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
if c.Request().Header.Get("HX-Request") == "true" { if c.Request().Header.Get("HX-Request") == "true" {
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`) return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
@@ -548,6 +553,9 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
// Role changes can affect the admin count, so refresh the setup-status cache.
setupstatus.Invalidate()
} }
// Update username (if provided) // Update username (if provided)
@@ -785,9 +793,9 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
} }
type PasswordRequest struct { type PasswordRequest struct {
CurrentPassword string `json:"current_password,omitempty"` CurrentPassword string `json:"current_password,omitempty" form:"current_password"`
NewPassword string `json:"new_password" validate:"required,passwordcomplex"` NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
ConfirmPassword string `json:"confirm_password" validate:"required"` ConfirmPassword string `json:"confirm_password" form:"confirm_password" validate:"required"`
} }
var req PasswordRequest var req PasswordRequest
@@ -934,6 +942,9 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
// Deletion may have changed the admin count, so refresh the setup-status cache.
setupstatus.Invalidate()
// Create success message based on context // Create success message based on context
var message string var message string
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes { if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
+154 -9
View File
@@ -28,7 +28,7 @@ func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManag
} }
type ConflictResolutionRequest struct { type ConflictResolutionRequest struct {
Winner string `json:"winner" validate:"required,oneof=koreader kobo web manual"` Winner string `json:"winner" validate:"required"`
ManualData map[string]interface{} `json:"manual_data"` ManualData map[string]interface{} `json:"manual_data"`
ApplyToAll bool `json:"apply_to_all_future_conflicts"` ApplyToAll bool `json:"apply_to_all_future_conflicts"`
Reason string `json:"reason"` Reason string `json:"reason"`
@@ -225,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
return echo.NewHTTPError(http.StatusForbidden, "access denied") return echo.NewHTTPError(http.StatusForbidden, "access denied")
} }
if conflict.ResolutionStatus.String != "unresolved" { if conflict.ResolutionStatus.String == "user_resolved" {
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved") return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
} }
@@ -235,8 +235,10 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
} }
winnerData := map[string]interface{}{} winnerData := map[string]interface{}{}
winnerSource := req.Winner
if req.Winner == "manual" { if req.Winner == "manual" {
winnerData = req.ManualData winnerData = req.ManualData
winnerSource = "manual"
} else { } else {
source, ok := conflictData[req.Winner] source, ok := conflictData[req.Winner]
if !ok { if !ok {
@@ -251,11 +253,17 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
} }
if conflict.ConflictType == "progress" { if conflict.ConflictType == "progress" {
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerData); err == nil { if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
appliedTo["progress"] = true appliedTo["progress"] = true
} }
} }
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
appliedTo["annotations"] = true
}
}
resolutionData := map[string]interface{}{ resolutionData := map[string]interface{}{
"winner": req.Winner, "winner": req.Winner,
"applied_to": appliedTo, "applied_to": appliedTo,
@@ -286,7 +294,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
return c.JSON(http.StatusOK, response) return c.JSON(http.StatusOK, response)
} }
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error { func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
ctx := context.Background() ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{ existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
@@ -342,7 +350,7 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
CurrentPage: currentPage, CurrentPage: currentPage,
TotalPages: totalPages, TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true}, LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: "manual", Valid: true}, LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
ViewportY: pgtype.Float8{}, ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{}, ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{}, ScrollPositionY: pgtype.Float8{},
@@ -354,6 +362,143 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
return err return err
} }
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
ctx := context.Background()
dedupKey, _ := winnerData["dedup_key"].(string)
if dedupKey == "" {
return errors.New("missing dedup_key in winner data")
}
switch conflictType {
case "annotation_highlight":
return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
case "annotation_bookmark":
return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
case "annotation_note":
return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
default:
return errors.New("unknown annotation conflict type")
}
}
func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return err
}
params := database.UpdateMediaHighlightForSyncParams{
ID: existing.ID,
SelectionText: existing.SelectionText,
StartPosition: existing.StartPosition,
EndPosition: existing.EndPosition,
Color: existing.Color,
NoteText: existing.NoteText,
PercentageStart: existing.PercentageStart,
PercentageEnd: existing.PercentageEnd,
EpubcfiStart: existing.EpubcfiStart,
EpubcfiEnd: existing.EpubcfiEnd,
ChapterReference: existing.ChapterReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
}
if v, ok := data["selection_text"].(string); ok {
params.SelectionText = v
}
if v, ok := data["color"].(string); ok {
params.Color = pgtype.Text{String: v, Valid: true}
}
if v, ok := data["note_text"].(string); ok {
params.NoteText = pgtype.Text{String: v, Valid: true}
}
if v, ok := data["start_position"].(string); ok {
params.StartPosition = pgtype.Text{String: v, Valid: true}
}
if v, ok := data["end_position"].(string); ok {
params.EndPosition = pgtype.Text{String: v, Valid: true}
}
_, err = h.db.UpdateMediaHighlightForSync(ctx, params)
return err
}
func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return err
}
params := database.UpdateMediaBookmarkForSyncParams{
ID: existing.ID,
PageNumber: existing.PageNumber,
ChapterNumber: existing.ChapterNumber,
CfiPosition: existing.CfiPosition,
Title: existing.Title,
Position: existing.Position,
Notes: existing.Notes,
PercentageLocation: existing.PercentageLocation,
EpubcfiLocation: existing.EpubcfiLocation,
ChapterReference: existing.ChapterReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
}
if v, ok := data["title"].(string); ok {
params.Title = v
}
if v, ok := data["notes"].(string); ok {
params.Notes = pgtype.Text{String: v, Valid: true}
}
_, err = h.db.UpdateMediaBookmarkForSync(ctx, params)
return err
}
func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return err
}
params := database.UpdateMediaNoteForSyncParams{
ID: existing.ID,
Content: existing.Content,
Position: existing.Position,
PercentageLocation: existing.PercentageLocation,
CharacterStart: existing.CharacterStart,
CharacterEnd: existing.CharacterEnd,
EpubcfiLocation: existing.EpubcfiLocation,
ChapterReference: existing.ChapterReference,
ParagraphReference: existing.ParagraphReference,
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
DeviceSyncData: existing.DeviceSyncData,
}
if v, ok := data["content"].(string); ok {
params.Content = v
}
if v, ok := data["position"].(string); ok {
params.Position = pgtype.Text{String: v, Valid: true}
}
_, err = h.db.UpdateMediaNoteForSync(ctx, params)
return err
}
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string { func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
devices, err := h.db.ListDevicesByType(context.Background(), "koreader") devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
if err != nil { if err != nil {
@@ -551,7 +696,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
continue continue
} }
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winnerData); err != nil { if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
results = append(results, ConflictResult{ results = append(results, ConflictResult{
ConflictID: conflictIDStr, ConflictID: conflictIDStr,
Status: "error", Status: "error",
@@ -635,7 +780,7 @@ func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]Confl
return highestSource, highestData return highestSource, highestData
} }
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error { func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
ctx := context.Background() ctx := context.Background()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{ existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
@@ -690,8 +835,8 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
CharacterOffset: characterOffset, CharacterOffset: characterOffset,
CurrentPage: currentPage, CurrentPage: currentPage,
TotalPages: totalPages, TotalPages: totalPages,
LastSyncDevice: pgtype.Text{String: "bulk_resolution", Valid: true}, LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
LastSyncSource: pgtype.Text{String: "bulk", Valid: true}, LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
ViewportY: pgtype.Float8{}, ViewportY: pgtype.Float8{},
ScrollPositionX: pgtype.Float8{}, ScrollPositionX: pgtype.Float8{},
ScrollPositionY: pgtype.Float8{}, ScrollPositionY: pgtype.Float8{},
+66 -58
View File
@@ -100,6 +100,10 @@ type PendingRegistration struct {
UserID uuid.UUID UserID uuid.UUID
ExpiresAt time.Time ExpiresAt time.Time
CreatedAt time.Time CreatedAt time.Time
Approved bool
AuthToken string
DeviceID [16]byte
SyncEndpoints map[string]string
} }
var pendingRegistrations = make(map[string]*PendingRegistration) var pendingRegistrations = make(map[string]*PendingRegistration)
@@ -173,60 +177,21 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"}) return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
} }
if registration.UserID == (uuid.UUID{}) { if registration.Approved {
delete(pendingRegistrations, req.RegistrationID)
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{ return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
Status: "pending", Status: "approved",
Message: "awaiting user approval", AuthToken: registration.AuthToken,
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()), DeviceID: registration.DeviceID,
SyncEndpoints: registration.SyncEndpoints,
}) })
} }
authToken, err := generateDeviceToken()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
}
userUUID := registration.UserID
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
autoSync := pgtype.Bool{Bool: true, Valid: true}
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: registration.DeviceName,
DeviceType: registration.DeviceType,
DeviceIdentifier: registration.DeviceIdentifier,
AuthToken: authToken,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequencyMinutes: syncFreq,
DeviceMetadata: []byte("{}"),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
}
delete(pendingRegistrations, req.RegistrationID)
syncEndpoints := map[string]string{}
switch registration.DeviceType {
case "koreader":
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
case "kobo":
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
}
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{ return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
Status: "approved", Status: "pending",
AuthToken: authToken, Message: "awaiting user approval",
DeviceID: device.ID.Bytes, ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
SyncEndpoints: syncEndpoints,
}) })
} }
@@ -597,14 +562,63 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"}) return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
} }
if registration.Approved {
return c.JSON(http.StatusOK, map[string]interface{}{
"message": "device already approved",
"device_name": registration.DeviceName,
"device_type": registration.DeviceType,
"approved": true,
})
}
authToken, err := generateDeviceToken()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
}
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
autoSync := pgtype.Bool{Bool: true, Valid: true}
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
UserID: pgUserID,
DeviceName: registration.DeviceName,
DeviceType: registration.DeviceType,
DeviceIdentifier: registration.DeviceIdentifier,
AuthToken: authToken,
SyncEnabled: syncEnabled,
AutoSync: autoSync,
SyncFrequencyMinutes: syncFreq,
DeviceMetadata: []byte("{}"),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
}
syncEndpoints := map[string]string{}
switch registration.DeviceType {
case "koreader":
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
case "kobo":
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
}
registration.UserID = userUUID registration.UserID = userUUID
registration.Approved = true
registration.AuthToken = authToken
registration.DeviceID = device.ID.Bytes
registration.SyncEndpoints = syncEndpoints
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"message": "device approved successfully", "message": "device approved successfully",
"device_name": registration.DeviceName, "device_name": registration.DeviceName,
"device_type": registration.DeviceType, "device_type": registration.DeviceType,
"registration_id": registrationID, "registration_id": registrationID,
"approved": true, // Fixed: Add confirmation field for test compatibility "approved": true,
}) })
} }
@@ -624,15 +638,9 @@ func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
} }
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) { func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
userID := c.Get("user_id").(string)
userUUID, err := uuid.Parse(userID)
if err != nil {
return nil, err
}
registrations := []map[string]interface{}{} registrations := []map[string]interface{}{}
for _, reg := range pendingRegistrations { for _, reg := range pendingRegistrations {
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) { if reg.UserID == (uuid.UUID{}) {
registrations = append(registrations, map[string]interface{}{ registrations = append(registrations, map[string]interface{}{
"registration_id": reg.RegistrationID, "registration_id": reg.RegistrationID,
"device_name": reg.DeviceName, "device_name": reg.DeviceName,
@@ -640,7 +648,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
"device_identifier": reg.DeviceIdentifier, "device_identifier": reg.DeviceIdentifier,
"expires_at": reg.ExpiresAt, "expires_at": reg.ExpiresAt,
"created_at": reg.CreatedAt, "created_at": reg.CreatedAt,
"is_approved": reg.UserID != (uuid.UUID{}), "is_approved": false,
}) })
} }
} }
+313 -64
View File
@@ -3,7 +3,9 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
wsync "bookhoard/internal/sync" wsync "bookhoard/internal/sync"
"encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
@@ -15,9 +17,11 @@ import (
) )
type KoboHandler struct { type KoboHandler struct {
db *database.Queries db *database.Queries
connManager *wsync.ConnectionManager connManager *wsync.ConnectionManager
progressSvc *wsync.ProgressService progressSvc *wsync.ProgressService
annotationSvc *wsync.AnnotationService
libraryService LibraryPathResolver
} }
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler { func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
@@ -28,6 +32,14 @@ func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
h.progressSvc = svc h.progressSvc = svc
} }
func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) {
h.annotationSvc = svc
}
func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) {
h.libraryService = svc
}
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies // mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
// Enhanced Kobo Sync - ContentId Mapping Logic // Enhanced Kobo Sync - ContentId Mapping Logic
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) { func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
@@ -239,9 +251,16 @@ type KoboInitResponse struct {
} }
type KoboSyncStatus struct { type KoboSyncStatus struct {
Status string `json:"Status"` Status string `json:"Status"`
MarkupsSynced int `json:"MarkupsSynced"` MarkupsSynced int `json:"MarkupsSynced"`
BookmarksSynced int `json:"BookmarksSynced"` BookmarksSynced int `json:"BookmarksSynced"`
DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"`
}
type KoboDeletedAnnotation struct {
ContentId string `json:"ContentId"`
BookmarkId string `json:"BookmarkId"`
Type string `json:"Type"`
} }
type KoboServerSyncData struct { type KoboServerSyncData struct {
@@ -305,7 +324,7 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
} }
bookmarkCount := 0 bookmarkCount := 0
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}, MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
UserID: pgUserID, UserID: pgUserID,
}) })
@@ -397,6 +416,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
markupsSynced := 0 markupsSynced := 0
bookmarksSynced := 0 bookmarksSynced := 0
unlinkedBooks := 0 unlinkedBooks := 0
processedBooks := make(map[pgtype.UUID]string)
for _, readingSync := range req.ReadingSync { for _, readingSync := range req.ReadingSync {
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID) bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
@@ -406,8 +426,23 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
} }
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
processedBooks[pgMediaUUID] = readingSync.ContentId
percentage := readingSync.PercentRead / 100.0 percentage := readingSync.PercentRead / 100.0
// Kobo only sends a percentage. For fixed-layout & comic formats the page
// index is the canonical locator, so derive it from the known page count.
var currentPage, totalPages *int
if mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID); mErr == nil {
if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) || mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
total := int(mediaItem.PageCount.Int32)
page := wsync.PercentageToPage(percentage, total)
currentPage = &page
totalPages = &total
}
}
}
if h.progressSvc != nil { if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{ _, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID, MediaItemID: pgMediaUUID,
@@ -415,6 +450,8 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
Source: "kobo", Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage, Percentage: &percentage,
CurrentPage: currentPage,
TotalPages: totalPages,
DeviceType: "kobo", DeviceType: "kobo",
DeviceName: device.DeviceName, DeviceName: device.DeviceName,
Broadcast: true, Broadcast: true,
@@ -443,29 +480,72 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
} }
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true} pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
processedBooks[pgMediaUUID] = bookmarkSync.ContentId
switch bookmarkSync.BookmarkType { switch bookmarkSync.BookmarkType {
case "annotation": case "annotation":
if bookmarkSync.BookmarkText != "" { if bookmarkSync.BookmarkText != "" {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ if h.annotationSvc != nil {
MediaItemID: pgMediaUUID, deviceData, _ := json.Marshal(map[string]interface{}{
UserID: pgUserID, "bookmark_id": bookmarkSync.BookmarkId,
SelectionText: bookmarkSync.BookmarkText, "date_created": bookmarkSync.DateCreated,
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, })
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true}, result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
}) MediaItemID: pgMediaUUID,
bookmarksSynced++ UserID: pgUserID,
SelectionText: bookmarkSync.BookmarkText,
StartPosition: bookmarkSync.BookmarkId,
EndPosition: bookmarkSync.BookmarkId,
Color: "#ffff00",
NoteText: bookmarkSync.BookmarkTitle,
Source: "kobo",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++
}
} else {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmarkSync.BookmarkText,
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
bookmarksSynced++
}
} }
case "bookmark": case "bookmark":
if bookmarkSync.BookmarkText != "" { if bookmarkSync.BookmarkText != "" {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ if h.annotationSvc != nil {
MediaItemID: pgMediaUUID, deviceData, _ := json.Marshal(map[string]interface{}{
UserID: pgUserID, "bookmark_id": bookmarkSync.BookmarkId,
Content: bookmarkSync.BookmarkText, "date_created": bookmarkSync.DateCreated,
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, })
})
bookmarksSynced++ result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Title: bookmarkSync.BookmarkText,
Position: bookmarkSync.BookmarkId,
ChapterNumber: int32(bookmarkSync.Chapter),
Source: "kobo",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++
}
} else {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Content: bookmarkSync.BookmarkText,
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
})
bookmarksSynced++
}
} }
case "last-read-place": case "last-read-place":
if bookmarkSync.BookmarkId != "" { if bookmarkSync.BookmarkId != "" {
@@ -479,6 +559,23 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
chapter := bookmarkSync.Chapter chapter := bookmarkSync.Chapter
chapterProgress := 0.5 chapterProgress := 0.5
var convertedCFI *string
var contextText *string
if epubcfi != "" && h.libraryService != nil {
mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID)
if mErr == nil {
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
if formatGroup != wsync.FormatGroupFixedLayout && formatGroup != wsync.FormatGroupComicArchive {
convertedCFI, contextText = h.convertKoboCFIToStandard(c, mediaItem, epubcfi)
}
}
}
if convertedCFI != nil {
epubcfi = *convertedCFI
}
if h.progressSvc != nil { if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{ _, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID, MediaItemID: pgMediaUUID,
@@ -486,6 +583,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
Source: "kobo", Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Epubcfi: &epubcfi, Epubcfi: &epubcfi,
ContextText: contextText,
Chapter: &chapter, Chapter: &chapter,
ChapterProgress: &chapterProgress, ChapterProgress: &chapterProgress,
DeviceType: "kobo", DeviceType: "kobo",
@@ -524,6 +622,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
BookmarksSynced: bookmarksSynced, BookmarksSynced: bookmarksSynced,
} }
if h.annotationSvc != nil && len(processedBooks) > 0 {
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
for mediaItemID, contentId := range processedBooks {
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
DeletedAt: cutoff,
})
for _, ts := range tombstones {
var dd map[string]interface{}
if len(ts.DeviceSyncData) > 0 {
json.Unmarshal(ts.DeviceSyncData, &dd)
}
bookmarkID, _ := dd["bookmark_id"].(string)
if bookmarkID == "" {
continue
}
response.DeletedAnnotations = append(response.DeletedAnnotations, KoboDeletedAnnotation{
ContentId: contentId,
BookmarkId: bookmarkID,
Type: ts.AnnotationType,
})
}
}
}
// Include unlinked books count if any // Include unlinked books count if any
if unlinkedBooks > 0 { if unlinkedBooks > 0 {
// For now, just log it. In production, this should trigger an alert // For now, just log it. In production, this should trigger an alert
@@ -566,25 +690,67 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
switch bookmarkSync.BookmarkType { switch bookmarkSync.BookmarkType {
case "annotation": case "annotation":
if bookmarkSync.BookmarkText != "" { if bookmarkSync.BookmarkText != "" {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ if h.annotationSvc != nil {
MediaItemID: pgMediaUUID, deviceData, _ := json.Marshal(map[string]interface{}{
UserID: pgUserID, "bookmark_id": bookmarkSync.BookmarkId,
SelectionText: bookmarkSync.BookmarkText, "date_created": bookmarkSync.DateCreated,
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, })
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true}, result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
}) MediaItemID: pgMediaUUID,
bookmarksSynced++ UserID: pgUserID,
SelectionText: bookmarkSync.BookmarkText,
StartPosition: bookmarkSync.BookmarkId,
EndPosition: bookmarkSync.BookmarkId,
Color: "#ffff00",
NoteText: bookmarkSync.BookmarkTitle,
Source: "kobo",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++
}
} else {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmarkSync.BookmarkText,
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
bookmarksSynced++
}
} }
case "bookmark": case "bookmark":
if bookmarkSync.BookmarkText != "" { if bookmarkSync.BookmarkText != "" {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ if h.annotationSvc != nil {
MediaItemID: pgMediaUUID, deviceData, _ := json.Marshal(map[string]interface{}{
UserID: pgUserID, "bookmark_id": bookmarkSync.BookmarkId,
Content: bookmarkSync.BookmarkText, "date_created": bookmarkSync.DateCreated,
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true}, })
})
bookmarksSynced++ result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Title: bookmarkSync.BookmarkText,
Position: bookmarkSync.BookmarkId,
ChapterNumber: int32(bookmarkSync.Chapter),
Source: "kobo",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++
}
} else {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Content: bookmarkSync.BookmarkText,
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
})
bookmarksSynced++
}
} }
} }
} }
@@ -716,36 +882,79 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
for _, bookmark := range syncData.Bookmarks { for _, bookmark := range syncData.Bookmarks {
if bookmark.BookmarkType == "bookmark" { if bookmark.BookmarkType == "bookmark" {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ if h.annotationSvc != nil {
MediaItemID: pgMediaUUID, result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
UserID: pgUserID, MediaItemID: pgMediaUUID,
Content: bookmark.BookmarkText, UserID: pgUserID,
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, Title: bookmark.BookmarkText,
}) Position: bookmark.BookmarkId,
bookmarksSent++ Source: "kobo",
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSent++
}
} else {
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Content: bookmark.BookmarkText,
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
})
bookmarksSent++
}
} else if bookmark.BookmarkType == "annotation" { } else if bookmark.BookmarkType == "annotation" {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ if h.annotationSvc != nil {
MediaItemID: pgMediaUUID, result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
UserID: pgUserID, MediaItemID: pgMediaUUID,
SelectionText: bookmark.BookmarkText, UserID: pgUserID,
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, SelectionText: bookmark.BookmarkText,
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true}, StartPosition: bookmark.BookmarkId,
Color: pgtype.Text{String: "#ffff00", Valid: true}, EndPosition: bookmark.BookmarkId,
}) Color: "#ffff00",
highlightsSent++ Source: "kobo",
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
highlightsSent++
}
} else {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: bookmark.BookmarkText,
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
highlightsSent++
}
} }
} }
for _, highlight := range syncData.Highlights { for _, highlight := range syncData.Highlights {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ if h.annotationSvc != nil {
MediaItemID: pgMediaUUID, result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
UserID: pgUserID, MediaItemID: pgMediaUUID,
SelectionText: highlight.BookmarkText, UserID: pgUserID,
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true}, SelectionText: highlight.BookmarkText,
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true}, StartPosition: highlight.BookmarkId,
Color: pgtype.Text{String: "#ffff00", Valid: true}, EndPosition: highlight.BookmarkId,
}) Color: "#ffff00",
highlightsSent++ Source: "kobo",
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
highlightsSent++
}
} else {
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
SelectionText: highlight.BookmarkText,
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
Color: pgtype.Text{String: "#ffff00", Valid: true},
})
highlightsSent++
}
} }
} }
@@ -762,3 +971,43 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
HighlightsSent: highlightsSent, HighlightsSent: highlightsSent,
}) })
} }
func (h *KoboHandler) convertKoboCFIToStandard(c *echo.Context, mediaItem database.MediaItems, kepubCFI string) (*string, *string) {
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil {
log.Printf("Bookhoard: KEPUB→CFI failed to resolve EPUB path: %v", err)
return nil, nil
}
if epubPath == "" {
log.Printf("Bookhoard: KEPUB→CFI resolved empty EPUB path for %s", mediaItem.FilePath)
return nil, nil
}
kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
FormatType: "kepub",
})
if err != nil || !kepubFormat.FilePath.Valid {
return nil, nil
}
converter := wsync.NewKEPUBCFIConverter(epubPath, kepubFormat.FilePath.String)
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.0, "")
if err != nil {
log.Printf("Bookhoard: KEPUB→CFI conversion error: %v", err)
return nil, nil
}
var cfi *string
if result.CFI != "" {
cfi = &result.CFI
log.Printf("Bookhoard: KEPUB→CFI converted (precision=%s)", result.Precision)
}
var ctx *string
if result.ExtractedContext != "" {
ctx = &result.ExtractedContext
}
return cfi, ctx
}
+491 -67
View File
@@ -3,7 +3,10 @@ package handlers
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
wsync "bookhoard/internal/sync" wsync "bookhoard/internal/sync"
"context"
"encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"time" "time"
@@ -13,10 +16,16 @@ import (
) )
type KOReaderHandler struct { type KOReaderHandler struct {
db *database.Queries db *database.Queries
connManager *wsync.ConnectionManager connManager *wsync.ConnectionManager
queue *wsync.SyncQueueProcessor queue *wsync.SyncQueueProcessor
progressSvc *wsync.ProgressService progressSvc *wsync.ProgressService
annotationSvc *wsync.AnnotationService
libraryService LibraryPathResolver
}
type LibraryPathResolver interface {
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
} }
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler { func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
@@ -27,6 +36,31 @@ func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
h.progressSvc = svc h.progressSvc = svc
} }
func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
h.annotationSvc = svc
}
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1 string) (string, string) {
if pos0 == "" || h.libraryService == nil {
return "", ""
}
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
return "", ""
}
epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
if err != nil || epubPath == "" {
return "", ""
}
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, "", mediaItem.FormatGroup, epubPath, "")
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
return startLoc.CFI, endLoc.CFI
}
func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) {
h.libraryService = svc
}
type KOReaderProgressRequest struct { type KOReaderProgressRequest struct {
LibraryID *string `json:"library_id,omitempty"` LibraryID *string `json:"library_id,omitempty"`
Books []KOReaderBookProgress `json:"books" validate:"required"` Books []KOReaderBookProgress `json:"books" validate:"required"`
@@ -46,11 +80,12 @@ type KOReaderBookProgress struct {
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
Highlights []KOReaderHighlight `json:"highlights,omitempty"` Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"` Notes []KOReaderNote `json:"notes,omitempty"`
Chapter *int `json:"chapter,omitempty"` Chapter *int `json:"chapter,omitempty"`
Character *int64 `json:"character,omitempty"` Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"` Epubcfi *string `json:"epubcfi,omitempty"`
Page *int `json:"page,omitempty"` ContextText *string `json:"context_text,omitempty"`
TotalPages *int `json:"total_pages,omitempty"` Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
} }
type KOReaderDeviceInfo struct { type KOReaderDeviceInfo struct {
@@ -99,11 +134,18 @@ type KOReaderNote struct {
} }
type KOReaderSyncResponse struct { type KOReaderSyncResponse struct {
SyncStatus string `json:"sync_status"` SyncStatus string `json:"sync_status"`
BooksSynced int `json:"books_synced"` BooksSynced int `json:"books_synced"`
Conflicts []KOReaderConflict `json:"conflicts,omitempty"` BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
Timestamp string `json:"timestamp"` Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
DeviceUpdated bool `json:"device_updated"` Timestamp string `json:"timestamp"`
DeviceUpdated bool `json:"device_updated"`
}
type KOReaderBookSyncResult struct {
SHA256 string `json:"sha256"`
BookUUID string `json:"book_uuid"`
Synced bool `json:"synced"`
} }
type KOReaderConflict struct { type KOReaderConflict struct {
@@ -124,19 +166,22 @@ type KOReaderMetadata struct {
} }
type KOReaderProgressData struct { type KOReaderProgressData struct {
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
Character *int64 `json:"character,omitempty"` Character *int64 `json:"character,omitempty"`
Epubcfi *string `json:"epubcfi,omitempty"` Epubcfi *string `json:"epubcfi,omitempty"`
Chapter *int `json:"chapter,omitempty"` KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
ChapterProgress *float64 `json:"chapter_progress,omitempty"` Chapter *int `json:"chapter,omitempty"`
Page *int `json:"page,omitempty"` ChapterProgress *float64 `json:"chapter_progress,omitempty"`
TotalPages *int `json:"total_pages,omitempty"` Page *int `json:"page,omitempty"`
TotalPages *int `json:"total_pages,omitempty"`
} }
type KOReaderAnnotations struct { type KOReaderAnnotations struct {
Highlights []KOReaderHighlight `json:"highlights,omitempty"` Highlights []KOReaderHighlight `json:"highlights,omitempty"`
Notes []KOReaderNote `json:"notes,omitempty"` Notes []KOReaderNote `json:"notes,omitempty"`
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"` Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"`
DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"`
} }
type KOReaderLibraryResponse struct { type KOReaderLibraryResponse struct {
@@ -187,17 +232,28 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
booksSynced := 0 booksSynced := 0
conflicts := []KOReaderConflict{} conflicts := []KOReaderConflict{}
bookResults := []KOReaderBookSyncResult{}
for _, book := range req.Books { for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book) mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
if !mediaItemID.Valid { if !mediaItemID.Valid {
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
})
continue continue
} }
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book) err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
if err == nil { synced := err == nil
if synced {
booksSynced++ booksSynced++
} }
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
Synced: synced,
})
} }
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID) _, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
@@ -211,6 +267,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{ return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
SyncStatus: "accepted", SyncStatus: "accepted",
BooksSynced: booksSynced, BooksSynced: booksSynced,
BookResults: bookResults,
Conflicts: conflicts, Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339), Timestamp: time.Now().Format(time.RFC3339),
DeviceUpdated: true, DeviceUpdated: true,
@@ -220,6 +277,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
return c.JSON(http.StatusOK, KOReaderSyncResponse{ return c.JSON(http.StatusOK, KOReaderSyncResponse{
SyncStatus: "completed", SyncStatus: "completed",
BooksSynced: booksSynced, BooksSynced: booksSynced,
BookResults: bookResults,
Conflicts: conflicts, Conflicts: conflicts,
Timestamp: time.Now().Format(time.RFC3339), Timestamp: time.Now().Format(time.RFC3339),
DeviceUpdated: true, DeviceUpdated: true,
@@ -346,17 +404,29 @@ func (h *KOReaderHandler) createDeviceFileAlias(c *echo.Context, deviceID pgtype
func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error { func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error {
booksEnqueued := 0 booksEnqueued := 0
bookResults := []KOReaderBookSyncResult{}
for _, book := range req.Books { for _, book := range req.Books {
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book) mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
if !mediaItemID.Valid { if !mediaItemID.Valid {
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
Synced: false,
})
continue continue
} }
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book) err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
if err == nil { synced := err == nil
if synced {
booksEnqueued++ booksEnqueued++
} }
h.processBookAnnotations(c.Request().Context(), device.ID, userID, mediaItemID, book)
bookResults = append(bookResults, KOReaderBookSyncResult{
SHA256: book.SHA256,
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
Synced: synced,
})
} }
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID) _, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
@@ -366,11 +436,11 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.
}) })
} }
return c.JSON(http.StatusAccepted, map[string]interface{}{ return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
"sync_status": "checkpoint_enqueued", SyncStatus: "checkpoint_enqueued",
"books_enqueued": booksEnqueued, BooksSynced: booksEnqueued,
"message": "Sync will be processed in the background", BookResults: bookResults,
"timestamp": time.Now().Format(time.RFC3339), Timestamp: time.Now().Format(time.RFC3339),
}) })
} }
@@ -385,6 +455,7 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
UserID: userID, UserID: userID,
Percentage: book.Percentage, Percentage: book.Percentage,
Epubcfi: book.Epubcfi, Epubcfi: book.Epubcfi,
ContextText: book.ContextText,
Chapter: book.Chapter, Chapter: book.Chapter,
Character: book.Character, Character: book.Character,
Page: book.Page, Page: book.Page,
@@ -396,6 +467,102 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
return h.queue.EnqueueProgress(update) return h.queue.EnqueueProgress(update)
} }
func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, userID, mediaItemID pgtype.UUID, book KOReaderBookProgress) {
if h.annotationSvc == nil {
return
}
for _, hl := range book.Highlights {
startPos := hl.Pos0
endPos := hl.Pos1
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
pctStart := 0.0
if hl.Percentage != nil {
pctStart = *hl.Percentage
}
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": hl.Datetime,
"pos0": hl.Pos0,
"pos1": hl.Pos1,
"page": hl.Page,
})
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
MediaItemID: mediaItemID,
UserID: userID,
SelectionText: hl.Text,
StartPosition: startPos,
EndPosition: endPos,
Color: hl.Color,
NoteText: hl.Notes,
PercentageStart: pctStart,
EpubcfiStart: epubcfiStart,
EpubcfiEnd: epubcfiEnd,
Source: "koreader",
DeviceSyncData: deviceData,
})
}
for _, note := range book.Notes {
startPos := note.Pos0
endPos := note.Pos1
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
pctStart := 0.0
if note.Percentage != nil {
pctStart = *note.Percentage
}
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": note.Datetime,
"pos0": note.Pos0,
"pos1": note.Pos1,
"page": note.Page,
})
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
MediaItemID: mediaItemID,
UserID: userID,
SelectionText: note.Text,
StartPosition: startPos,
EndPosition: endPos,
NoteText: note.Notes,
PercentageStart: pctStart,
EpubcfiStart: epubcfiStart,
EpubcfiEnd: epubcfiEnd,
Source: "koreader",
DeviceSyncData: deviceData,
})
}
for _, bookmark := range book.Bookmarks {
position := ""
if bookmark.Pos0 != "" {
position = bookmark.Pos0
} else if bookmark.Page > 0 {
position = fmt.Sprintf("page:%d", bookmark.Page)
}
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": bookmark.Datetime,
"pos0": bookmark.Pos0,
"page": bookmark.Page,
})
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
MediaItemID: mediaItemID,
UserID: userID,
Title: bookmark.Text,
Position: position,
ChapterNumber: int32(bookmark.Chapter),
Source: "koreader",
DeviceSyncData: deviceData,
})
}
}
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error { func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
ctx := c.Request().Context() ctx := c.Request().Context()
@@ -406,13 +573,67 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
} }
if h.progressSvc != nil { 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)
}
}
}
}
}
saveReq := wsync.SaveProgressRequest{ saveReq := wsync.SaveProgressRequest{
MediaItemID: mediaItemID, MediaItemID: mediaItemID,
UserID: userID, UserID: userID,
Source: "koreader", Source: "koreader",
DeviceID: deviceID, DeviceID: deviceID,
Percentage: &book.Percentage, Percentage: &book.Percentage,
Epubcfi: book.Epubcfi, Epubcfi: epubcfi,
ContextText: book.ContextText,
Chapter: book.Chapter, Chapter: book.Chapter,
CharacterOffset: book.Character, CharacterOffset: book.Character,
CurrentPage: book.Page, CurrentPage: book.Page,
@@ -423,7 +644,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
} }
_, err := h.progressSvc.SaveProgress(ctx, saveReq) _, err := h.progressSvc.SaveProgress(ctx, saveReq)
return err if err != nil {
return err
}
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
return nil
} }
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{ _, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
@@ -459,6 +684,8 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
}, },
) )
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
return nil return nil
} }
@@ -523,8 +750,19 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
Percentage: progress.Percentage.Float64, Percentage: progress.Percentage.Float64,
} }
if progress.Epubcfi.Valid { // CFI/xpointer are meaningless for image-based fixed-layout content; the
progressData.Epubcfi = &progress.Epubcfi.String // page index is the canonical locator. Only return them for reflowable docs.
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
isFixed := formatGroup == wsync.FormatGroupFixedLayout ||
formatGroup == wsync.FormatGroupComicArchive
if !isFixed {
if progress.Epubcfi.Valid {
progressData.Epubcfi = &progress.Epubcfi.String
}
if progress.Epubcfi.Valid && wsync.IsStandardEPUBCFI(progress.Epubcfi.String) {
h.convertCFIToXPointer(c, mediaItem, progress, &progressData)
}
} }
if progress.Chapter.Valid { if progress.Chapter.Valid {
progress := int(progress.Chapter.Int32) progress := int(progress.Chapter.Int32)
@@ -547,7 +785,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
progressData.TotalPages = &progress progressData.TotalPages = &progress
} }
annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ annotations, err := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
MediaItemID: pgBookUUID, MediaItemID: pgBookUUID,
UserID: pgUserID, UserID: pgUserID,
}) })
@@ -560,13 +798,29 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
for _, ann := range annotations { for _, ann := range annotations {
if ann.AnnotationType == "highlight" { if ann.AnnotationType == "highlight" {
annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{ pos0 := ann.StartPosition.String
pos1 := ann.EndPosition.String
if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" {
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" {
pos0 = converted
}
}
if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" {
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" {
pos1 = converted
}
}
highlight := KOReaderHighlight{
Text: ann.SelectionText, Text: ann.SelectionText,
Pos0: ann.StartPosition.String, Pos0: pos0,
Pos1: ann.EndPosition.String, Pos1: pos1,
Color: ann.Color.String, Color: ann.Color.String,
Datetime: ann.CreatedAt.Time.Format(time.RFC3339), Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
}) }
if ann.NoteText.Valid && ann.NoteText.String != "" {
highlight.Notes = ann.NoteText.String
}
annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight)
} else if ann.AnnotationType == "note" { } else if ann.AnnotationType == "note" {
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{ annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
Text: ann.SelectionText, Text: ann.SelectionText,
@@ -576,6 +830,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
} }
} }
bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
})
for _, bm := range bookmarks {
pos0 := bm.Position.String
if pos0 == "" && bm.CfiPosition.Valid {
pos0 = bm.CfiPosition.String
}
koreaderBookmark := KOReaderBookmark{
Text: bm.Title,
Pos0: pos0,
Pos1: pos0,
Datetime: bm.CreatedAt.Time.Format(time.RFC3339),
}
if bm.Notes.Valid && bm.Notes.String != "" {
koreaderBookmark.Notes = bm.Notes.String
}
if bm.ChapterNumber.Valid {
koreaderBookmark.Chapter = int(bm.ChapterNumber.Int32)
}
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
}
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
MediaItemID: pgBookUUID,
UserID: pgUserID,
DeletedAt: cutoff,
})
for _, ts := range tombstones {
var dd map[string]interface{}
if len(ts.DeviceSyncData) > 0 {
json.Unmarshal(ts.DeviceSyncData, &dd)
}
if dd == nil {
dd = map[string]interface{}{}
}
dd["dedup_key"] = ts.DedupKey.String
if ts.AnnotationType == "highlight" {
annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd)
} else if ts.AnnotationType == "bookmark" {
annotationsResponse.DeletedBookmarks = append(annotationsResponse.DeletedBookmarks, dd)
}
}
lastSync := "never" lastSync := "never"
if progress.LastSyncTimestamp.Valid { if progress.LastSyncTimestamp.Valid {
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339) lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
@@ -593,6 +893,55 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
return c.JSON(http.StatusOK, metadata) return c.JSON(http.StatusOK, metadata)
} }
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)
}
}
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string) string {
if h.libraryService == nil || epubcfi == "" {
return ""
}
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
if err != nil || epubPath == "" {
return ""
}
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, "", mediaItem.FormatGroup, epubPath, "")
if loc.Position != "" && loc.Position != epubcfi {
return loc.Position
}
return ""
}
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error { func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
device := c.Get("device").(database.Devices) device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes userID := device.UserID.Bytes
@@ -632,7 +981,7 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
} }
} }
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{ annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
MediaItemID: pgItemUUID, MediaItemID: pgItemUUID,
UserID: pgUserID, UserID: pgUserID,
}) })
@@ -732,15 +1081,36 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
position = fmt.Sprintf("page:%d", bookmark.Page) position = fmt.Sprintf("page:%d", bookmark.Page)
} }
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{ if h.annotationSvc != nil {
MediaItemID: mediaItemID, deviceData, _ := json.Marshal(map[string]interface{}{
UserID: pgUserID, "datetime": bookmark.Datetime,
Content: bookmark.Text, "pos0": bookmark.Pos0,
Position: pgtype.Text{String: position, Valid: position != ""}, "page": bookmark.Page,
}) })
if err == nil { result, err := h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
bookmarksSynced++ MediaItemID: mediaItemID,
UserID: pgUserID,
Title: bookmark.Text,
Position: position,
ChapterNumber: int32(bookmark.Chapter),
Source: "koreader",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
bookmarksSynced++
}
} else {
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
Content: bookmark.Text,
Position: pgtype.Text{String: position, Valid: position != ""},
})
if err == nil {
bookmarksSynced++
}
} }
} }
@@ -762,15 +1132,35 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
position = fmt.Sprintf("page:%d", note.Page) position = fmt.Sprintf("page:%d", note.Page)
} }
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{ if h.annotationSvc != nil {
MediaItemID: mediaItemID, deviceData, _ := json.Marshal(map[string]interface{}{
UserID: pgUserID, "datetime": note.Datetime,
Content: note.Notes, "pos0": note.Pos0,
Position: pgtype.Text{String: position, Valid: position != ""}, "page": note.Page,
}) })
if err == nil { result, err := h.annotationSvc.SaveNote(ctx, wsync.SaveNoteRequest{
notesSynced++ MediaItemID: mediaItemID,
UserID: pgUserID,
Content: note.Notes,
Position: position,
Source: "koreader",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
notesSynced++
}
} else {
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
Content: note.Notes,
Position: pgtype.Text{String: position, Valid: position != ""},
})
if err == nil {
notesSynced++
}
} }
} }
@@ -797,17 +1187,51 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
color = highlight.Color color = highlight.Color
} }
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{ if h.annotationSvc != nil {
MediaItemID: mediaItemID, epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1)
UserID: pgUserID,
SelectionText: highlight.Text,
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""},
Color: pgtype.Text{String: color, Valid: true},
})
if err == nil { pctStart := 0.0
highlightsSynced++ if highlight.Percentage != nil {
pctStart = *highlight.Percentage
}
deviceData, _ := json.Marshal(map[string]interface{}{
"datetime": highlight.Datetime,
"pos0": highlight.Pos0,
"pos1": highlight.Pos1,
"page": highlight.Page,
})
result, err := h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
MediaItemID: mediaItemID,
UserID: pgUserID,
SelectionText: highlight.Text,
StartPosition: startPos,
EndPosition: endPos,
Color: color,
NoteText: highlight.Notes,
PercentageStart: pctStart,
EpubcfiStart: epubcfiStart,
EpubcfiEnd: epubcfiEnd,
Source: "koreader",
DeviceSyncData: deviceData,
})
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
highlightsSynced++
}
} else {
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
MediaItemID: mediaItemID,
UserID: pgUserID,
SelectionText: highlight.Text,
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""},
Color: pgtype.Text{String: color, Valid: true},
})
if err == nil {
highlightsSynced++
}
} }
} }
+84 -12
View File
@@ -19,6 +19,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@@ -136,6 +137,7 @@ type MediaHandler struct {
libraryService *services.LibraryService libraryService *services.LibraryService
searchService *services.SearchService searchService *services.SearchService
progressSvc *wsync.ProgressService progressSvc *wsync.ProgressService
annotationSvc *wsync.AnnotationService
} }
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler { func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
@@ -154,6 +156,10 @@ func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
mh.progressSvc = svc mh.progressSvc = svc
} }
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
mh.annotationSvc = svc
}
func (h *MediaHandler) DownloadBook(c *echo.Context) error { func (h *MediaHandler) DownloadBook(c *echo.Context) error {
bookUUID, err := uuid.Parse(c.Param("uuid")) bookUUID, err := uuid.Parse(c.Param("uuid"))
if err != nil { if err != nil {
@@ -975,6 +981,7 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
CurrentPage *int32 `json:"current_page"` CurrentPage *int32 `json:"current_page"`
TotalPages *int32 `json:"total_pages"` TotalPages *int32 `json:"total_pages"`
Epubcfi *string `json:"epubcfi"` Epubcfi *string `json:"epubcfi"`
ContextText *string `json:"context_text"`
Percentage *float64 `json:"percentage"` Percentage *float64 `json:"percentage"`
Chapter *int `json:"chapter"` Chapter *int `json:"chapter"`
ChapterProgress *float64 `json:"chapter_progress"` ChapterProgress *float64 `json:"chapter_progress"`
@@ -996,6 +1003,7 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true}, DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
Percentage: req.Percentage, Percentage: req.Percentage,
Epubcfi: req.Epubcfi, Epubcfi: req.Epubcfi,
ContextText: req.ContextText,
CharacterOffset: req.CharacterOffset, CharacterOffset: req.CharacterOffset,
Chapter: req.Chapter, Chapter: req.Chapter,
ChapterProgress: req.ChapterProgress, ChapterProgress: req.ChapterProgress,
@@ -1018,6 +1026,16 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
saveReq.TotalPages = &tp saveReq.TotalPages = &tp
} }
if req.Percentage != nil && *req.Percentage < 0.005 {
existing, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
if err == nil && existing.Percentage.Valid && existing.Percentage.Float64 > 0.01 {
return c.JSON(http.StatusOK, map[string]string{"status": "ignored"})
}
}
result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq) result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
@@ -1376,14 +1394,31 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
} }
note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{ var note database.MediaNotes
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, if mh.annotationSvc != nil {
UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
Content: req.Content, MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""}, UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
}) Content: req.Content,
if err != nil { Position: req.Position,
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) Source: "web",
ModifiedAt: time.Now(),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
note = result.Note
} else {
var err error
note, err = mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Content: req.Content,
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
} }
return c.JSON(http.StatusCreated, note) return c.JSON(http.StatusCreated, note)
@@ -1444,7 +1479,11 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"}) return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
} }
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true}) if mh.annotationSvc != nil {
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
} else {
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
}
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
@@ -1513,9 +1552,29 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
color = req.Color color = req.Color
} }
pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
if mh.annotationSvc != nil {
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
MediaItemID: pgMediaID,
UserID: pgUserID,
SelectionText: req.SelectionText,
StartPosition: req.StartPosition,
EndPosition: req.EndPosition,
Color: color,
Source: "web",
ModifiedAt: time.Now(),
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, result.Highlight)
}
highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{ highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true}, MediaItemID: pgMediaID,
UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, UserID: pgUserID,
SelectionText: req.SelectionText, SelectionText: req.SelectionText,
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true}, StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true}, EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
@@ -1601,7 +1660,16 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"}) return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
} }
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true}) pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
if mh.annotationSvc != nil {
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
if err != nil { if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
} }
@@ -1723,6 +1791,10 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
"results": []interface{}{}, "results": []interface{}{},
}) })
} }
for i := range results {
resolved := utils.ResolveMediaURL(results[i].LibraryID, results[i].CoverImagePath)
results[i].CoverImagePath = pgtype.Text{String: resolved, Valid: resolved != ""}
}
return c.JSON(http.StatusOK, results) return c.JSON(http.StatusOK, results)
} }
+171 -33
View File
@@ -49,6 +49,109 @@ func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
return baseURL.Value, opdsBaseURL, nil return baseURL.Value, opdsBaseURL, nil
} }
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
token := c.QueryParam("token")
if token == "" {
token = strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
}
return token
}
func appendToken(url, token string) string {
if token == "" {
return url
}
if strings.Contains(url, "?") {
return url + "&token=" + token
}
return url + "?token=" + token
}
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
// previous, next, last) and OpenSearch paging metadata (totalResults,
// itemsPerPage, startIndex) to a feed based on the current page position.
// catalogBase is the device catalog URL without query parameters. The token
// (device auth) is appended to every generated link.
func addCatalogPaginationLinks(feed *opds.Feed, catalogBase string, pageNum, perPageNum, totalItems int, token string) {
totalPages := 0
if totalItems > 0 {
totalPages = (totalItems + perPageNum - 1) / perPageNum
}
startIdx := (pageNum - 1) * perPageNum
pagedURL := func(page int) string {
return appendToken(fmt.Sprintf("%s?page=%d&per_page=%d", catalogBase, page, perPageNum), token)
}
// self reflects the current page; start/first point to the first page
feed.AddLink(pagedURL(pageNum), catalogMediaType, "self")
feed.AddLink(pagedURL(1), catalogMediaType, "start")
feed.AddLink(pagedURL(1), catalogMediaType, "first")
if totalPages > 0 {
feed.AddLink(pagedURL(totalPages), catalogMediaType, "last")
}
if pageNum > 1 {
feed.AddLink(pagedURL(pageNum-1), catalogMediaType, "previous")
}
if pageNum < totalPages {
feed.AddLink(pagedURL(pageNum+1), catalogMediaType, "next")
}
feed.SetPagination(totalItems, perPageNum, startIdx+1)
}
// resolveMimeType returns the mime type for a media item, preferring the stored
// mime_type, then format_mimetype, and finally falling back to EPUB.
func resolveMimeType(mime, formatMime pgtype.Text) string {
if mime.Valid && mime.String != "" {
return mime.String
}
if formatMime.Valid && formatMime.String != "" {
return formatMime.String
}
return "application/epub+zip"
}
// isComicArchive reports whether a format group represents a comic/manga
// archive (cbz/cbr/cb7/cbt). Comic archives are served in their native format
// and should not be offered as EPUB/KEPUB/PDF conversions.
func isComicArchive(formatGroup string) bool {
return strings.EqualFold(formatGroup, "comic_archive")
}
// formatLabelFromPath derives a short format label (e.g. "epub", "cbz") from a
// file path's extension, defaulting to "epub" when it cannot be determined.
func formatLabelFromPath(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".epub":
return "epub"
case ".pdf":
return "pdf"
case ".cbz":
return "cbz"
case ".cbr":
return "cbr"
case ".cb7":
return "cb7"
case ".cbt":
return "cbt"
case ".mobi":
return "mobi"
case ".azw", ".azw3":
return "azw3"
case ".txt":
return "txt"
case "":
return "epub"
default:
return strings.TrimPrefix(ext, ".")
}
}
// GetDeviceCatalog returns the OPDS catalog feed for a device // GetDeviceCatalog returns the OPDS catalog feed for a device
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
deviceID := c.Param("deviceId") deviceID := c.Param("deviceId")
@@ -139,13 +242,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
"Bookhoard Library", "Bookhoard Library",
) )
// Add feed links // Feed links, including OPDS pagination links (first/previous/next/last) and
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID) // OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self") token := h.getAuthToken(c)
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start") catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
searchURL := fmt.Sprintf("%s/opds/devices/%s/search", opdsBaseURL, deviceID) // OpenSearch: the search link points to an OpenSearch description document
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search") // (served by the same /search endpoint when no query is supplied) so that
// OPDS clients like KOReader can discover how to formulate search requests.
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
// Add entries // Add entries
for _, item := range allItems { for _, item := range allItems {
@@ -170,16 +277,21 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
entry.SetSummary(item.Description.String) entry.SetSummary(item.Description.String)
} }
// Add acquisition links // Add acquisition link using the item's real mime type
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID) downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
entry.AddAcquisitionLink(downloadURL, "application/epub+zip") entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
// Add format variants // Only offer reflowable conversions (kepub/pdf) for ebooks; comic
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL) // archives are served as-is in their native format.
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip") if !isComicArchive(item.FormatGroup) {
if device.DeviceType == "kobo" {
kepubURL := downloadURL + "&format=kepub"
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
}
pdfURL := fmt.Sprintf("%s?format=pdf", downloadURL) pdfURL := downloadURL + "&format=pdf"
entry.AddAlternateLink(pdfURL, "application/pdf") entry.AddAlternateLink(pdfURL, "application/pdf")
}
// Add canonical identifier // Add canonical identifier
entry.SetIdentifier(bookUUID) entry.SetIdentifier(bookUUID)
@@ -213,16 +325,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
return c.String(http.StatusOK, xmlString) return c.String(http.StatusOK, xmlString)
} }
// SearchDeviceCatalog searches the OPDS catalog for a device // SearchDeviceCatalog searches the OPDS catalog for a device.
//
// When no "q" query parameter is supplied it returns an OpenSearch description
// document (application/opensearchdescription+xml) so that OPDS clients such as
// KOReader can discover the search URL template (which contains the
// {searchTerms} placeholder). When "q" is supplied it returns an OPDS
// acquisition feed of matching books.
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error { func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
deviceID := c.Param("deviceId") deviceID := c.Param("deviceId")
query := c.QueryParam("q") query := c.QueryParam("q")
if query == "" {
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
}
// Get base URLs // Get base URLs
baseURL, opdsBaseURL, err := h.getBaseURLs(c) baseURL, opdsBaseURL, err := h.getBaseURLs(c)
if err != nil { if err != nil {
@@ -243,12 +356,30 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
// Get user's visible libraries // Get user's visible libraries
userID := device.UserID.Bytes userID := device.UserID.Bytes
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}) _, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
if err != nil { if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries")) return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
} }
token := h.getAuthToken(c)
// No query: serve the OpenSearch description document so clients can learn
// the search template (contains the {searchTerms} placeholder).
if query == "" {
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
desc := opds.NewSearchDescription(
"Bookhoard",
"Search the Bookhoard library",
searchURL,
)
xmlString, err := desc.GenerateXMLString()
if err != nil {
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate search description"))
}
c.Response().Header().Set("Content-Type", "application/opensearchdescription+xml")
return c.String(http.StatusOK, xmlString)
}
// Search media items // Search media items
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{ allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true}, UserID: pgtype.UUID{Bytes: userID, Valid: true},
@@ -267,11 +398,14 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
) )
// Add feed links // Add feed links
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID) catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start") feed.AddLink(catalogURL, catalogMediaType, "start")
searchURL := fmt.Sprintf("%s/opds/devices/%s/search?q=%s", opdsBaseURL, deviceID, query) searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self") feed.AddLink(searchURL, catalogMediaType, "self")
// OpenSearch paging metadata (search results are a single page)
feed.SetPagination(len(allItems), len(allItems), 1)
// Add entries (same as catalog) // Add entries (same as catalog)
userUUID := uuid.UUID(userID) userUUID := uuid.UUID(userID)
@@ -296,11 +430,15 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
entry.SetSummary(item.Description.String) entry.SetSummary(item.Description.String)
} }
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID) downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
entry.AddAcquisitionLink(downloadURL, "application/epub+zip") entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL) // Only offer kepub conversion for ebooks; comic archives are served
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip") // as-is in their native format.
if !isComicArchive(item.FormatGroup) && device.DeviceType == "kobo" {
kepubURL := downloadURL + "&format=kepub"
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
}
entry.SetIdentifier(bookUUID) entry.SetIdentifier(bookUUID)
@@ -599,7 +737,7 @@ func (h *OPDSHandler) GetDeviceNavigation(c *echo.Context) error {
) )
// Add feed links // Add feed links
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID) catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start") feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self") feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
@@ -682,14 +820,14 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
formatList := []FormatInfo{} formatList := []FormatInfo{}
// Add EPUB format (always available if media item exists) // Add the primary/native format (always available if media item exists)
fileSize := int64(0) fileSize := int64(0)
if mediaItem.FileSize.Valid { if mediaItem.FileSize.Valid {
fileSize = mediaItem.FileSize.Int64 fileSize = mediaItem.FileSize.Int64
} }
formatList = append(formatList, FormatInfo{ formatList = append(formatList, FormatInfo{
FormatType: "epub", FormatType: formatLabelFromPath(mediaItem.FilePath),
FilePath: mediaItem.FilePath, FilePath: mediaItem.FilePath,
FileSha256: func() string { FileSha256: func() string {
if mediaItem.FileSha256.Valid { if mediaItem.FileSha256.Valid {
@@ -791,7 +929,7 @@ func (h *OPDSHandler) RegisterOPDS(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"}) return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
} }
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID) catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
return c.JSON(http.StatusOK, map[string]interface{}{ return c.JSON(http.StatusOK, map[string]interface{}{
"opds_token": map[string]interface{}{ "opds_token": map[string]interface{}{
+119
View File
@@ -0,0 +1,119 @@
package handlers
import (
"bookhoard/internal/opds"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// rels collects the rel attributes of all links currently on the feed.
func rels(feed *opds.Feed) []string {
out := make([]string, 0, len(feed.Links))
for _, l := range feed.Links {
out = append(out, l.Rel)
}
return out
}
func containsRel(feed *opds.Feed, rel string) bool {
for _, l := range feed.Links {
if l.Rel == rel {
return true
}
}
return false
}
func TestAddCatalogPaginationLinks_MiddlePage(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
// 1814 items, 50 per page => 37 pages; on page 2
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 2, 50, 1814, "tok")
assert.True(t, containsRel(feed, "self"))
assert.True(t, containsRel(feed, "start"))
assert.True(t, containsRel(feed, "first"))
assert.True(t, containsRel(feed, "last"))
assert.True(t, containsRel(feed, "previous"), "middle page must have previous")
assert.True(t, containsRel(feed, "next"), "middle page must have next")
// self must point to the current page
var selfHref string
for _, l := range feed.Links {
if l.Rel == "self" {
selfHref = l.Href
}
}
assert.Contains(t, selfHref, "page=2&per_page=50")
assert.Contains(t, selfHref, "token=tok")
// next must advance the page
var nextHref string
for _, l := range feed.Links {
if l.Rel == "next" {
nextHref = l.Href
}
}
assert.Contains(t, nextHref, "page=3")
// OpenSearch metadata
require.NotNil(t, feed.TotalResults)
assert.Equal(t, 1814, *feed.TotalResults)
require.NotNil(t, feed.ItemsPerPage)
assert.Equal(t, 50, *feed.ItemsPerPage)
require.NotNil(t, feed.StartIndex)
assert.Equal(t, 51, *feed.StartIndex, "startIndex should be 1-based offset of first item on page 2")
}
func TestAddCatalogPaginationLinks_FirstPage_NoPrevious(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 1814, "")
rels := rels(feed)
assert.NotContains(t, rels, "previous", "first page must not have previous")
assert.Contains(t, rels, "next")
}
func TestAddCatalogPaginationLinks_LastPage_NoNext(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 37, 50, 1814, "")
rels := rels(feed)
assert.NotContains(t, rels, "next", "last page must not have next")
assert.Contains(t, rels, "previous")
}
func TestAddCatalogPaginationLinks_SinglePage(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 10, "")
rels := rels(feed)
assert.NotContains(t, rels, "previous")
assert.NotContains(t, rels, "next")
// still emits self/start/first/last
assert.Contains(t, rels, "self")
assert.Contains(t, rels, "last")
}
func TestAddCatalogPaginationLinks_EmptyCatalog(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 0, "")
rels := rels(feed)
assert.NotContains(t, rels, "next")
assert.NotContains(t, rels, "previous")
assert.NotContains(t, rels, "last", "empty catalog should not advertise a last page")
require.NotNil(t, feed.TotalResults)
assert.Equal(t, 0, *feed.TotalResults)
}
func TestAddCatalogPaginationLinks_TokenAppended(t *testing.T) {
feed := opds.NewFeed("urn:uuid:dev", "Library")
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 100, "abc")
xml, err := feed.GenerateXMLString()
require.NoError(t, err)
assert.True(t, strings.Count(xml, "token=abc") >= 3, "token should be appended to generated links")
}
+19
View File
@@ -417,6 +417,25 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
} }
} }
if newBaseURL, ok := req["base_url"]; ok && newBaseURL != "" {
derivedConfigs := map[string]string{
"opds_base_url": newBaseURL + "/opds",
"api_base_url": newBaseURL + "/api",
}
for derivedKey, derivedValue := range derivedConfigs {
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
Key: derivedKey,
Value: derivedValue,
UpdatedBy: pgUserID,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{
"error": fmt.Sprintf("failed to update derived config key: %s", derivedKey),
})
}
}
}
// Check for HTMX request // Check for HTMX request
if c.Request().Header.Get("HX-Request") == "true" { if c.Request().Header.Get("HX-Request") == "true" {
// Fetch updated base_url for template // Fetch updated base_url for template
+102 -21
View File
@@ -9,21 +9,25 @@ import (
// OPDS 1.2 Feed Structures // OPDS 1.2 Feed Structures
type Feed struct { type Feed struct {
XMLName xml.Name `xml:"feed"` XMLName xml.Name `xml:"feed"`
Xmlns string `xml:"xmlns,attr"` Xmlns string `xml:"xmlns,attr"`
OpdsNS string `xml:"xmlns:opds,attr"` OpdsNS string `xml:"xmlns:opds,attr"`
DcNS string `xml:"xmlns:dc,attr"` DcNS string `xml:"xmlns:dc,attr"`
ID string `xml:"id"` OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"`
Title string `xml:"title"` ID string `xml:"id"`
Updated string `xml:"updated"` Title string `xml:"title"`
Links []Link `xml:"link"` Updated string `xml:"updated"`
Entries []Entry `xml:"entry"` Links []Link `xml:"link"`
TotalResults *int `xml:"opensearch:totalResults,omitempty"`
ItemsPerPage *int `xml:"opensearch:itemsPerPage,omitempty"`
StartIndex *int `xml:"opensearch:startIndex,omitempty"`
Entries []Entry `xml:"entry"`
} }
type Entry struct { type Entry struct {
ID string `xml:"id"` ID string `xml:"id"`
Title string `xml:"dc:title"` Title string `xml:"title"`
Creator string `xml:"dc:creator,omitempty"` Author *Author `xml:"author,omitempty"`
Updated string `xml:"updated"` Updated string `xml:"updated"`
Summary string `xml:"summary,omitempty"` Summary string `xml:"summary,omitempty"`
Links []Link `xml:"link"` Links []Link `xml:"link"`
@@ -32,6 +36,12 @@ type Entry struct {
Categories []Category `xml:"category,omitempty"` Categories []Category `xml:"category,omitempty"`
} }
type Author struct {
XMLName xml.Name `xml:"author"`
Name string `xml:"name"`
URI string `xml:"uri,omitempty"`
}
type Link struct { type Link struct {
Href string `xml:"href,attr"` Href string `xml:"href,attr"`
Type string `xml:"type,attr"` Type string `xml:"type,attr"`
@@ -58,17 +68,29 @@ type Category struct {
func NewFeed(feedID, title string) *Feed { func NewFeed(feedID, title string) *Feed {
now := time.Now().Format(time.RFC3339) now := time.Now().Format(time.RFC3339)
return &Feed{ return &Feed{
Xmlns: "http://www.w3.org/2005/Atom", Xmlns: "http://www.w3.org/2005/Atom",
OpdsNS: "http://opds-spec.org/2010/", OpdsNS: "http://opds-spec.org/2010/",
DcNS: "http://purl.org/dc/elements/1.1/", DcNS: "http://purl.org/dc/elements/1.1/",
ID: feedID, OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/",
Title: title, ID: feedID,
Updated: now, Title: title,
Links: []Link{}, Updated: now,
Entries: []Entry{}, Links: []Link{},
Entries: []Entry{},
} }
} }
// SetPagination populates the OpenSearch paging metadata (totalResults,
// itemsPerPage, startIndex). startIndex is 1-based to match the page model.
func (f *Feed) SetPagination(totalResults, itemsPerPage, startIndex int) {
tr := totalResults
ipp := itemsPerPage
si := startIndex
f.TotalResults = &tr
f.ItemsPerPage = &ipp
f.StartIndex = &si
}
// AddLink adds a link to the feed // AddLink adds a link to the feed
func (f *Feed) AddLink(href, linkType, rel string) { func (f *Feed) AddLink(href, linkType, rel string) {
f.Links = append(f.Links, Link{ f.Links = append(f.Links, Link{
@@ -85,14 +107,17 @@ func (f *Feed) AddEntry(entry Entry) {
// NewEntry creates a new OPDS entry // NewEntry creates a new OPDS entry
func NewEntry(id, title, creator, updated string) Entry { func NewEntry(id, title, creator, updated string) Entry {
return Entry{ e := Entry{
ID: id, ID: id,
Title: title, Title: title,
Creator: creator,
Updated: updated, Updated: updated,
Links: []Link{}, Links: []Link{},
Metadata: []Meta{}, Metadata: []Meta{},
} }
if creator != "" {
e.Author = &Author{Name: creator}
}
return e
} }
// AddAcquisitionLink adds an acquisition link to the entry // AddAcquisitionLink adds an acquisition link to the entry
@@ -169,6 +194,62 @@ func (f *Feed) GenerateXMLString() (string, error) {
return xml.Header + string(output), nil return xml.Header + string(output), nil
} }
// OpenSearchUrl is a single <Url> element in an OpenSearch description.
type OpenSearchUrl struct {
XMLName xml.Name `xml:"Url"`
Type string `xml:"type,attr"`
Template string `xml:"template,attr"`
}
// OpenSearchDescription is an OpenSearch description document used by OPDS
// clients (e.g. KOReader) to discover how to perform catalog searches. Clients
// fetch this document at the catalog's rel="search" link, then substitute
// {searchTerms} in the Url template to execute a query.
type OpenSearchDescription struct {
XMLName xml.Name `xml:"OpenSearchDescription"`
Xmlns string `xml:"xmlns,attr"`
ShortName string `xml:"ShortName"`
Description string `xml:"Description"`
InputEncoding string `xml:"InputEncoding"`
OutputEncoding string `xml:"OutputEncoding"`
Url OpenSearchUrl `xml:"Url"`
}
// NewSearchDescription creates an OpenSearch description document whose Url
// template points clients back to the search results endpoint. The template
// must contain the {searchTerms} placeholder.
func NewSearchDescription(shortName, description, template string) *OpenSearchDescription {
return &OpenSearchDescription{
Xmlns: "http://a9.com/-/spec/opensearch/1.1/",
ShortName: shortName,
Description: description,
InputEncoding: "UTF-8",
OutputEncoding: "UTF-8",
Url: OpenSearchUrl{
Type: "application/atom+xml;profile=opds-catalog;kind=acquisition",
Template: template,
},
}
}
// GenerateXML generates the OpenSearch description XML
func (d *OpenSearchDescription) GenerateXML() ([]byte, error) {
output, err := xml.MarshalIndent(d, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal OpenSearch description: %w", err)
}
return output, nil
}
// GenerateXMLString generates the OpenSearch description XML as a string
func (d *OpenSearchDescription) GenerateXMLString() (string, error) {
output, err := d.GenerateXML()
if err != nil {
return "", err
}
return xml.Header + string(output), nil
}
// NewErrorFeed creates an error feed // NewErrorFeed creates an error feed
func NewErrorFeed(message string) *Feed { func NewErrorFeed(message string) *Feed {
feed := NewFeed( feed := NewFeed(
+105 -4
View File
@@ -71,8 +71,8 @@ func TestNewEntry(t *testing.T) {
t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title) t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title)
} }
if entry.Creator != "Test Author" { if entry.Author == nil || entry.Author.Name != "Test Author" {
t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator) t.Errorf("expected Author.Name to be 'Test Author', got %v", entry.Author)
} }
if entry.Updated != "2023-01-01T00:00:00Z" { if entry.Updated != "2023-01-01T00:00:00Z" {
@@ -201,8 +201,8 @@ func TestFeedGenerateXML(t *testing.T) {
`<title>Test Feed</title>`, `<title>Test Feed</title>`,
`<entry>`, `<entry>`,
`<id>urn:uuid:book-id</id>`, `<id>urn:uuid:book-id</id>`,
`<dc:title>Test Book</dc:title>`, `<title>Test Book</title>`,
`<dc:creator>Test Author</dc:creator>`, `<name>Test Author</name>`,
`<link href="http://example.com/book.epub"`, `<link href="http://example.com/book.epub"`,
`rel="http://opds-spec.org/acquisition/open-access"`, `rel="http://opds-spec.org/acquisition/open-access"`,
`<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`, `<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`,
@@ -232,6 +232,107 @@ func TestNewErrorFeed(t *testing.T) {
} }
} }
func TestFeedSetPagination(t *testing.T) {
feed := NewFeed("urn:uuid:test-id", "Test Feed")
feed.SetPagination(1814, 50, 51)
if feed.TotalResults == nil || *feed.TotalResults != 1814 {
t.Errorf("expected TotalResults to be 1814, got %v", feed.TotalResults)
}
if feed.ItemsPerPage == nil || *feed.ItemsPerPage != 50 {
t.Errorf("expected ItemsPerPage to be 50, got %v", feed.ItemsPerPage)
}
if feed.StartIndex == nil || *feed.StartIndex != 51 {
t.Errorf("expected StartIndex to be 51, got %v", feed.StartIndex)
}
}
func TestFeedGenerateXMLPagination(t *testing.T) {
feed := NewFeed("urn:uuid:test-id", "Test Feed")
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "first")
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "previous")
feed.AddLink("http://example.com/catalog?page=2", "application/atom+xml", "self")
feed.AddLink("http://example.com/catalog?page=3", "application/atom+xml", "next")
feed.AddLink("http://example.com/catalog?page=37", "application/atom+xml", "last")
feed.SetPagination(1814, 50, 51)
output, err := feed.GenerateXML()
if err != nil {
t.Fatalf("failed to generate XML: %v", err)
}
outputStr := string(output)
requiredStrings := []string{
`xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"`,
`<opensearch:totalResults>1814</opensearch:totalResults>`,
`<opensearch:itemsPerPage>50</opensearch:itemsPerPage>`,
`<opensearch:startIndex>51</opensearch:startIndex>`,
`rel="first"`,
`rel="previous"`,
`rel="next"`,
`rel="last"`,
`page=3`,
}
for _, required := range requiredStrings {
if !contains(outputStr, required) {
t.Errorf("generated XML missing required string: %s", required)
}
}
}
func TestFeedGenerateXMLOmitsPaginationWhenUnset(t *testing.T) {
feed := NewFeed("urn:uuid:test-id", "Test Feed")
output, err := feed.GenerateXML()
if err != nil {
t.Fatalf("failed to generate XML: %v", err)
}
outputStr := string(output)
if contains(outputStr, "opensearch:totalResults") {
t.Errorf("expected no totalResults when pagination unset, but found it")
}
if contains(outputStr, "opensearch:itemsPerPage") {
t.Errorf("expected no itemsPerPage when pagination unset, but found it")
}
}
func TestNewSearchDescription(t *testing.T) {
template := "http://example.com/opds/devices/abc/search?q={searchTerms}&token=xyz"
desc := NewSearchDescription("Bookhoard", "Search the library", template)
if desc.ShortName != "Bookhoard" {
t.Errorf("expected ShortName 'Bookhoard', got '%s'", desc.ShortName)
}
if desc.Url.Template != template {
t.Errorf("expected template '%s', got '%s'", template, desc.Url.Template)
}
}
func TestSearchDescriptionGenerateXML(t *testing.T) {
template := "http://example.com/opds/devices/abc/search?q={searchTerms}"
desc := NewSearchDescription("Bookhoard", "Search the library", template)
output, err := desc.GenerateXMLString()
if err != nil {
t.Fatalf("failed to generate XML: %v", err)
}
requiredStrings := []string{
`<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">`,
`<ShortName>Bookhoard</ShortName>`,
`<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"`,
`template="http://example.com/opds/devices/abc/search?q={searchTerms}"`,
}
for _, required := range requiredStrings {
if !contains(output, required) {
t.Errorf("generated OpenSearch XML missing required string: %s", required)
}
}
}
func contains(s, substr string) bool { func contains(s, substr string) bool {
return len(s) >= len(substr) && indexOf(s, substr) >= 0 return len(s) >= len(substr) && indexOf(s, substr) >= 0
} }
+14 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"log" "log"
"net/url" "net/url"
"time"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/templates" "bookhoard/templates"
@@ -66,7 +67,7 @@ func convertPending(pending []map[string]interface{}) []templates.PendingRegistr
RegistrationID: p["registration_id"].(string), RegistrationID: p["registration_id"].(string),
DeviceName: p["device_name"].(string), DeviceName: p["device_name"].(string),
DeviceType: p["device_type"].(string), DeviceType: p["device_type"].(string),
ExpiresAt: p["expires_at"].(string), ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339),
} }
} }
return result return result
@@ -117,6 +118,17 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
libraries = []database.GetUserVisibleLibrariesRow{} libraries = []database.GetUserVisibleLibrariesRow{}
} }
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
if countErr != nil {
log.Printf("GetVisibleLibraryMediaCounts failed: %v", countErr)
counts = []database.GetVisibleLibraryMediaCountsRow{}
}
countMap := make(map[string]int64, len(counts))
for _, mc := range counts {
mcUUID, _ := uuid.FromBytes(mc.ID.Bytes[0:16])
countMap[mcUUID.String()] = mc.MediaCount
}
res.Libraries = make([]templates.LibraryData, len(libraries)) res.Libraries = make([]templates.LibraryData, len(libraries))
for i, lib := range libraries { for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
@@ -125,6 +137,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
Name: lib.Name, Name: lib.Name,
Description: getText(lib.Description), Description: getText(lib.Description),
TypeName: lib.TypeName, TypeName: lib.TypeName,
MediaCount: countMap[libUUID.String()],
} }
} }
+7 -1
View File
@@ -62,12 +62,14 @@ type Config struct {
ConnManager *sync.ConnectionManager ConnManager *sync.ConnectionManager
QueueProcessor *sync.SyncQueueProcessor QueueProcessor *sync.SyncQueueProcessor
ProgressService *sync.ProgressService ProgressService *sync.ProgressService
AnnotationService *sync.AnnotationService
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
LoginTracker *ratelimit.LoginAttemptTracker LoginTracker *ratelimit.LoginAttemptTracker
ScannerHandler *handlers.Handler ScannerHandler *handlers.Handler
JobsHandler *handlers.JobsHandler JobsHandler *handlers.JobsHandler
SidecarHandler *handlers.SidecarHandler SidecarHandler *handlers.SidecarHandler
ReaderHandler *handlers.ReaderHandler ReaderHandler *handlers.ReaderHandler
LibraryService *services.LibraryService
} }
// createJWTMiddleware creates a JWT middleware with proper user context setup // createJWTMiddleware creates a JWT middleware with proper user context setup
@@ -188,6 +190,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
} }
e.Validator = &CustomValidator{validator: v} e.Validator = &CustomValidator{validator: v}
// Setup redirect middleware - must run before all routes
e.Pre(setupRedirectMiddleware(cfg))
// Rate limiter // Rate limiter
rateLimiterConfig := ratelimit.RateLimiterConfig{ rateLimiterConfig := ratelimit.RateLimiterConfig{
Enabled: cfg.Cfg.RateLimitEnabled, Enabled: cfg.Cfg.RateLimitEnabled,
@@ -206,6 +211,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
cfg.ScannerHandler = scannerHandler cfg.ScannerHandler = scannerHandler
// Register route groups // Register route groups
registerSetupRoutes(cfg)
registerAuthRoutes(cfg, rateLimitMiddleware) registerAuthRoutes(cfg, rateLimitMiddleware)
registerLibraryRoutes(cfg) registerLibraryRoutes(cfg)
registerDeviceRoutes(cfg) registerDeviceRoutes(cfg)
+60
View File
@@ -0,0 +1,60 @@
package router
import (
"bytes"
"context"
"log"
"net/http"
"strings"
"bookhoard/internal/setupstatus"
"bookhoard/templates"
"github.com/labstack/echo/v5"
)
func isSetupComplete(cfg *Config) bool {
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries)
}
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
path := c.Request().URL.Path
if path == "/setup" || path == "/setup/" {
return next(c)
}
if strings.HasPrefix(path, "/api/") {
return next(c)
}
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
return next(c)
}
if !isSetupComplete(cfg) {
return c.Redirect(http.StatusFound, "/setup")
}
return next(c)
}
}
}
func registerSetupRoutes(cfg *Config) {
e := cfg.Echo
e.GET("/setup", func(c *echo.Context) error {
if isSetupComplete(cfg) {
return c.Redirect(http.StatusFound, "/")
}
var buf bytes.Buffer
if err := templates.Setup().Render(c.Request().Context(), &buf); err != nil {
log.Printf("Failed to render setup template: %v", err)
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
}
return c.HTML(http.StatusOK, buf.String())
})
}
+2
View File
@@ -33,6 +33,8 @@ func registerSyncRoutes(cfg *Config) {
// API clients can use Authorization header: Authorization: Bearer {token} // API clients can use Authorization header: Authorization: Bearer {token}
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager) koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
koboHandler.SetProgressService(cfg.ProgressService) koboHandler.SetProgressService(cfg.ProgressService)
koboHandler.SetAnnotationService(cfg.AnnotationService)
koboHandler.SetLibraryService(cfg.LibraryService)
koboSync := e.Group("/api/sync/kobo/:token") koboSync := e.Group("/api/sync/kobo/:token")
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup)) koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark)) koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
+62
View File
@@ -0,0 +1,62 @@
// Package setupstatus reports whether the application's initial setup has been
// completed. Setup is considered complete as soon as at least one admin user
// exists, regardless of how that user was created (setup wizard, API, or a
// future CLI). This keeps the setup gate a derived property of real data
// rather than a manually-flipped flag that can drift out of sync.
package setupstatus
import (
"context"
"sync"
"time"
)
// AdminCounter is satisfied by *database.Queries. It is defined as an interface
// here so this package does not import the database package, keeping the
// dependency graph flat and avoiding import cycles.
type AdminCounter interface {
CountAdmins(ctx context.Context) (int64, error)
}
var (
cacheMu sync.RWMutex
cacheComplete bool = true
cacheExpiry time.Time
cacheTTL = 10 * time.Second
)
// IsSetupComplete reports whether setup is complete. Setup is complete when at
// least one admin user exists. A short in-memory cache avoids hammering the
// database on every request. On a database error the function fails open
// (returns true) so a transient outage does not lock users out of the app.
func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
cacheMu.RLock()
if time.Now().Before(cacheExpiry) {
complete := cacheComplete
cacheMu.RUnlock()
return complete
}
cacheMu.RUnlock()
count, err := q.CountAdmins(ctx)
complete := true
if err == nil {
complete = count > 0
}
cacheMu.Lock()
cacheComplete = complete
cacheExpiry = time.Now().Add(cacheTTL)
cacheMu.Unlock()
return complete
}
// Invalidate clears the cached setup status so the next call to IsSetupComplete
// re-reads from the database. Call this after any write that could change the
// admin user count (user creation, role promotion/demotion, user deletion).
func Invalidate() {
cacheMu.Lock()
cacheComplete = true
cacheExpiry = time.Time{}
cacheMu.Unlock()
}
+754
View File
@@ -0,0 +1,754 @@
package sync
import (
"bookhoard/internal/database"
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"math"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
const TombstoneTTL = 30 * 24 * time.Hour
type SaveOutcome string
const (
SaveOutcomeCreated SaveOutcome = "created"
SaveOutcomeUpdated SaveOutcome = "updated"
SaveOutcomeSkipped SaveOutcome = "skipped"
SaveOutcomeDeleted SaveOutcome = "deleted"
)
type AnnotationService struct {
db *database.Queries
connMgr *ConnectionManager
}
func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService {
return &AnnotationService{db: db, connMgr: connMgr}
}
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
}
type SaveHighlightResult struct {
Highlight database.MediaHighlights
Outcome SaveOutcome
Conflict bool
}
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
dedupKey := ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
existing, err := s.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
UserID: req.UserID,
MediaItemID: req.MediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("query existing highlight: %w", err)
}
if errors.Is(err, pgx.ErrNoRows) {
return s.createHighlight(ctx, req, dedupKey)
}
if existing.Deleted.Bool {
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
}
return s.createHighlight(ctx, req, dedupKey)
}
return s.applyLWW(ctx, req, existing, dedupKey)
}
func (s *AnnotationService) createHighlight(
ctx context.Context,
req SaveHighlightRequest,
dedupKey string,
) (*SaveHighlightResult, error) {
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{
MediaItemID: req.MediaItemID,
UserID: req.UserID,
SelectionText: req.SelectionText,
StartPosition: pgText(req.StartPosition),
EndPosition: pgText(req.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),
ChapterReference: pgInt4(req.ChapterReference),
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
})
if err != nil {
return nil, fmt.Errorf("create highlight: %w", err)
}
s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeCreated}, nil
}
func (s *AnnotationService) applyLWW(
ctx context.Context,
req SaveHighlightRequest,
existing database.MediaHighlights,
dedupKey string,
) (*SaveHighlightResult, error) {
incomingNewer, contentChanged := s.compareIncoming(req, existing)
if !incomingNewer && !contentChanged {
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "existing")
}
return &SaveHighlightResult{
Highlight: existing,
Outcome: SaveOutcomeSkipped,
Conflict: conflict,
}, nil
}
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
ID: existing.ID,
SelectionText: req.SelectionText,
StartPosition: pgText(req.StartPosition),
EndPosition: pgText(req.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),
ChapterReference: pgInt4(req.ChapterReference),
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
})
if err != nil {
return nil, fmt.Errorf("update highlight: %w", err)
}
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "incoming")
}
s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
}
func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing database.MediaHighlights) (incomingNewer bool, contentChanged bool) {
if req.ModifiedAt.IsZero() {
contentSame := strings.EqualFold(req.SelectionText, existing.SelectionText) &&
textEq(req.Color, existing.Color) &&
textEq(req.NoteText, existing.NoteText) &&
floatEq(req.PercentageStart, existing.PercentageStart) &&
floatEq(req.PercentageEnd, existing.PercentageEnd)
return !contentSame, !contentSame
}
existingMod := existing.LastModifiedAt
if !existingMod.Valid {
existingMod = existing.UpdatedAt
}
return req.ModifiedAt.After(existingMod.Time), true
}
func (s *AnnotationService) TombstoneHighlight(
ctx context.Context,
userID, mediaItemID pgtype.UUID,
dedupKey string,
source string,
) error {
err := s.db.TombstoneMediaHighlightByDedupKey(ctx, database.TombstoneMediaHighlightByDedupKeyParams{
UserID: userID,
MediaItemID: mediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
return fmt.Errorf("tombstone highlight: %w", err)
}
s.broadcast(pgtype.UUID{}, userID, mediaItemID, "highlight_delete", source)
return nil
}
func (s *AnnotationService) TombstoneHighlightByID(
ctx context.Context,
highlightID pgtype.UUID,
source string,
) error {
h, err := s.db.GetMediaHighlight(ctx, highlightID)
if err != nil {
return fmt.Errorf("get highlight for tombstone: %w", err)
}
err = s.db.TombstoneMediaHighlightByID(ctx, highlightID)
if err != nil {
return fmt.Errorf("tombstone highlight by ID: %w", err)
}
s.broadcast(pgtype.UUID{}, h.UserID, h.MediaItemID, "highlight_delete", source)
return nil
}
func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-TombstoneTTL), Valid: true}
if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {
return fmt.Errorf("purge highlight tombstones: %w", err)
}
if err := s.db.PurgeExpiredNoteTombstones(ctx, cutoff); err != nil {
return fmt.Errorf("purge note tombstones: %w", err)
}
if err := s.db.PurgeExpiredBookmarkTombstones(ctx, cutoff); err != nil {
return fmt.Errorf("purge bookmark tombstones: %w", err)
}
return nil
}
func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
ticker := time.NewTicker(24 * time.Hour)
ctx, cancel := context.WithCancel(context.Background())
go func() {
for {
select {
case <-ctx.Done():
ticker.Stop()
return
case <-ticker.C:
if err := s.PurgeExpiredTombstones(ctx); err != nil {
log.Printf("AnnotationService: tombstone purge failed: %v", err)
}
}
}
}()
return cancel
}
type SaveNoteRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
Content string
Position string
PercentageLocation float64
CharacterStart int32
CharacterEnd int32
EpubcfiLocation string
ChapterReference int32
ParagraphReference int32
Source string
ModifiedAt time.Time
DeviceSyncData []byte
}
type SaveNoteResult struct {
Note database.MediaNotes
Outcome SaveOutcome
Conflict bool
}
func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (*SaveNoteResult, error) {
if !req.UserID.Valid || !req.MediaItemID.Valid {
return nil, errors.New("invalid user_id or media_item_id")
}
dedupKey := ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position)
existing, err := s.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
UserID: req.UserID,
MediaItemID: req.MediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("get note by dedup key: %w", err)
}
return s.createNote(ctx, req, dedupKey)
}
if existing.Deleted.Valid && existing.Deleted.Bool {
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil
}
return s.applyNoteLWW(ctx, req, existing, dedupKey)
}
func (s *AnnotationService) createNote(ctx context.Context, req SaveNoteRequest, dedupKey string) (*SaveNoteResult, error) {
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
note, err := s.db.CreateMediaNoteFull(ctx, database.CreateMediaNoteFullParams{
MediaItemID: req.MediaItemID,
UserID: req.UserID,
Content: req.Content,
Position: pgText(req.Position),
PercentageLocation: pgFloat8(req.PercentageLocation),
CharacterStart: pgInt4(req.CharacterStart),
CharacterEnd: pgInt4(req.CharacterEnd),
EpubcfiLocation: pgText(req.EpubcfiLocation),
ChapterReference: pgInt4(req.ChapterReference),
ParagraphReference: pgInt4(req.ParagraphReference),
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: req.DeviceSyncData,
})
if err != nil {
return nil, fmt.Errorf("create note: %w", err)
}
s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
return &SaveNoteResult{Note: note, Outcome: SaveOutcomeCreated}, nil
}
func (s *AnnotationService) applyNoteLWW(ctx context.Context, req SaveNoteRequest, existing database.MediaNotes, dedupKey string) (*SaveNoteResult, error) {
incomingNewer, contentChanged := s.compareIncomingNote(req, existing)
if !incomingNewer && !contentChanged {
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "existing")
}
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
}
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
note, err := s.db.UpdateMediaNoteForSync(ctx, database.UpdateMediaNoteForSyncParams{
ID: existing.ID,
Content: req.Content,
Position: pgText(req.Position),
PercentageLocation: pgFloat8(req.PercentageLocation),
CharacterStart: pgInt4(req.CharacterStart),
CharacterEnd: pgInt4(req.CharacterEnd),
EpubcfiLocation: pgText(req.EpubcfiLocation),
ChapterReference: pgInt4(req.ChapterReference),
ParagraphReference: pgInt4(req.ParagraphReference),
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
})
if err != nil {
return nil, fmt.Errorf("update note: %w", err)
}
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "incoming")
}
s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
return &SaveNoteResult{Note: note, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
}
func (s *AnnotationService) compareIncomingNote(req SaveNoteRequest, existing database.MediaNotes) (incomingNewer bool, contentChanged bool) {
if req.ModifiedAt.IsZero() {
contentSame := strings.EqualFold(req.Content, existing.Content) &&
textEq(req.Position, existing.Position)
return !contentSame, !contentSame
}
existingMod := existing.LastModifiedAt
if !existingMod.Valid {
existingMod = existing.UpdatedAt
}
if !existingMod.Valid {
return true, true
}
return req.ModifiedAt.After(existingMod.Time), true
}
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id pgtype.UUID) error {
return s.db.TombstoneMediaNoteByID(ctx, id)
}
type SaveBookmarkRequest struct {
MediaItemID pgtype.UUID
UserID pgtype.UUID
Title string
Position string
Notes string
PageNumber int32
ChapterNumber int32
CFIPosition string
PercentageLoc float64
EpubcfiLocation string
ChapterReference int32
Source string
ModifiedAt time.Time
DeviceSyncData json.RawMessage
}
type SaveBookmarkResult struct {
Bookmark database.MediaBookmarks
Outcome SaveOutcome
Conflict bool
}
func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) {
dedupKey := ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position)
existing, err := s.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
UserID: req.UserID,
MediaItemID: req.MediaItemID,
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
})
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("query existing bookmark: %w", err)
}
if errors.Is(err, pgx.ErrNoRows) {
return s.createBookmark(ctx, req, dedupKey)
}
if existing.Deleted.Bool {
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
}
return s.createBookmark(ctx, req, dedupKey)
}
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
}
func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmarkRequest, dedupKey string) (*SaveBookmarkResult, error) {
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{
MediaItemID: req.MediaItemID,
UserID: req.UserID,
PageNumber: pgInt4(req.PageNumber),
ChapterNumber: pgInt4(req.ChapterNumber),
CfiPosition: pgText(req.CFIPosition),
Title: req.Title,
Position: pgText(req.Position),
Notes: pgText(req.Notes),
PercentageLocation: pgFloat8(req.PercentageLoc),
EpubcfiLocation: pgText(req.EpubcfiLocation),
ChapterReference: pgInt4(req.ChapterReference),
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
})
if err != nil {
return nil, fmt.Errorf("create bookmark: %w", err)
}
s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeCreated}, nil
}
func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookmarkRequest, existing database.MediaBookmarks, dedupKey string) (*SaveBookmarkResult, error) {
incomingNewer, contentChanged := s.compareIncomingBookmark(req, existing)
if !incomingNewer && !contentChanged {
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "existing")
}
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
}
modifiedAt := req.ModifiedAt
if modifiedAt.IsZero() {
modifiedAt = time.Now()
}
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
ID: existing.ID,
PageNumber: pgInt4(req.PageNumber),
ChapterNumber: pgInt4(req.ChapterNumber),
CfiPosition: pgText(req.CFIPosition),
Title: req.Title,
Position: pgText(req.Position),
Notes: pgText(req.Notes),
PercentageLocation: pgFloat8(req.PercentageLoc),
EpubcfiLocation: pgText(req.EpubcfiLocation),
ChapterReference: pgInt4(req.ChapterReference),
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
DeviceSyncData: deviceData,
})
if err != nil {
return nil, fmt.Errorf("update bookmark: %w", err)
}
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
if conflict {
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
req, existing, "incoming")
}
s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
}
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)
return !contentSame, !contentSame
}
existingMod := existing.LastModifiedAt
if !existingMod.Valid {
existingMod = existing.CreatedAt
}
return req.ModifiedAt.After(existingMod.Time), true
}
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, bookmarkID pgtype.UUID, source string) error {
bm, err := s.db.GetMediaBookmark(ctx, bookmarkID)
if err != nil {
return fmt.Errorf("get bookmark for tombstone: %w", err)
}
err = s.db.TombstoneMediaBookmarkByID(ctx, bookmarkID)
if err != nil {
return fmt.Errorf("tombstone bookmark by ID: %w", err)
}
s.broadcast(pgtype.UUID{}, bm.UserID, bm.MediaItemID, "bookmark_delete", source)
return nil
}
func (s *AnnotationService) recordConflict(
ctx context.Context,
userID, mediaItemID pgtype.UUID,
conflictType, dedupKey string,
incomingSource, existingSource string,
incoming any,
existing any,
winner string,
) {
if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
return
}
incomingJSON, _ := json.Marshal(incoming)
existingJSON, _ := json.Marshal(existing)
var incomingMap, existingMap map[string]interface{}
json.Unmarshal(incomingJSON, &incomingMap)
json.Unmarshal(existingJSON, &existingMap)
if incomingMap == nil {
incomingMap = map[string]interface{}{}
}
if existingMap == nil {
existingMap = map[string]interface{}{}
}
incomingMap["dedup_key"] = dedupKey
existingMap["dedup_key"] = dedupKey
conflictData, _ := json.Marshal(map[string]interface{}{
"incoming": map[string]interface{}{
"source": incomingSource,
"data": incomingMap,
},
"existing": map[string]interface{}{
"source": existingSource,
"data": existingMap,
},
})
resolutionData, _ := json.Marshal(map[string]interface{}{
"winner": winner,
"reason": "last_modified_at_wins",
})
conflict, err := s.db.CreateAutoResolvedSyncConflict(ctx, database.CreateAutoResolvedSyncConflictParams{
MediaItemID: mediaItemID,
UserID: userID,
ConflictType: conflictType,
ConflictData: conflictData,
ResolutionData: resolutionData,
})
if err != nil {
log.Printf("AnnotationService: failed to record conflict: %v", err)
return
}
var conflictIDStr string
if conflict.ID.Valid {
conflictIDStr = uuid.UUID(conflict.ID.Bytes).String()
}
s.connMgr.BroadcastConflictNotification(
uuid.UUID(mediaItemID.Bytes),
"annotation_conflict",
conflictIDStr,
)
}
func (s *AnnotationService) broadcast(
highlightID, userID, mediaItemID pgtype.UUID,
annotationType string,
source string,
) {
if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
return
}
src := SourceDevice{Type: source}
s.connMgr.BroadcastAnnotationUpdate(
uuid.UUID(mediaItemID.Bytes),
annotationType,
map[string]interface{}{
"highlight_id": uuid.UUID(highlightID.Bytes),
},
src,
)
}
func ComputeDedupKey(selectionText, epubcfiStart, startPosition string) string {
normalized := normalizeText(selectionText)
posBucket := bucketPosition(epubcfiStart)
if posBucket == "" {
posBucket = bucketPosition(startPosition)
}
h := sha1.New()
h.Write([]byte(normalized))
h.Write([]byte{0})
h.Write([]byte(posBucket))
return hex.EncodeToString(h.Sum(nil))
}
func normalizeText(s string) string {
fields := strings.Fields(strings.ToLower(s))
return strings.Join(fields, " ")
}
func bucketPosition(pos string) string {
if pos == "" {
return ""
}
if strings.HasPrefix(pos, "epubcfi(") {
if idx := strings.LastIndex(pos, ":"); idx > 0 {
return pos[:idx]
}
}
if len(pos) > 50 {
return pos[:50]
}
return pos
}
func mergeDeviceSyncData(existing []byte, source string, data json.RawMessage) []byte {
if source == "" && len(data) == 0 {
return existing
}
m := make(map[string]interface{})
if len(existing) > 0 {
_ = json.Unmarshal(existing, &m)
}
if source != "" {
if len(data) > 0 {
var val interface{}
_ = json.Unmarshal(data, &val)
m[source] = val
} else {
m[source] = map[string]interface{}{"synced_at": time.Now().UTC().Format(time.RFC3339)}
}
}
result, _ := json.Marshal(m)
return result
}
func isCrossSource(incoming string, existing pgtype.Text) bool {
if incoming == "" || !existing.Valid {
return false
}
return incoming != existing.String
}
func pgText(s string) pgtype.Text {
if s == "" {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: s, Valid: true}
}
func pgFloat8(f float64) pgtype.Float8 {
if f == 0 {
return pgtype.Float8{Valid: false}
}
return pgtype.Float8{Float64: f, Valid: true}
}
func pgInt4(i int32) pgtype.Int4 {
if i == 0 {
return pgtype.Int4{Valid: false}
}
return pgtype.Int4{Int32: i, Valid: true}
}
func textEq(a string, b pgtype.Text) bool {
if !b.Valid {
return a == ""
}
return a == b.String
}
func floatEq(a float64, b pgtype.Float8) bool {
if !b.Valid {
return a == 0
}
return math.Abs(a-b.Float64) < 0.001
}
+343
View File
@@ -0,0 +1,343 @@
package sync
import (
"bookhoard/internal/database"
"encoding/json"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
)
func TestComputeDedupKey_Deterministic(t *testing.T) {
k1 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "")
k2 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "")
if k1 != k2 {
t.Errorf("same input should produce same key: %q vs %q", k1, k2)
}
}
func TestComputeDedupKey_Normalization(t *testing.T) {
cases := [][]string{
{"Hello World", " Hello World "},
{"HELLO WORLD", "hello world"},
{"Hello World", "Hello World"},
{"Hello\t\nWorld", "Hello World"},
}
cfi := "epubcfi(/6/4!/4/10/3:100)"
for _, c := range cases {
k1 := ComputeDedupKey(c[0], cfi, "")
k2 := ComputeDedupKey(c[1], cfi, "")
if k1 != k2 {
t.Errorf("normalized texts should match: %q vs %q → %q vs %q", c[0], c[1], k1, k2)
}
}
}
func TestComputeDedupKey_PositionSensitivity(t *testing.T) {
text := "same text"
k1 := ComputeDedupKey(text, "epubcfi(/6/4!/4/10/3:100)", "")
k2 := ComputeDedupKey(text, "epubcfi(/6/4!/4/20/3:100)", "")
if k1 == k2 {
t.Error("different element paths should produce different keys")
}
}
func TestComputeDedupKey_OffsetInsensitive(t *testing.T) {
text := "same text"
base := "epubcfi(/6/4!/4/10/3:100)"
offsetShift := "epubcfi(/6/4!/4/10/3:200)"
k1 := ComputeDedupKey(text, base, "")
k2 := ComputeDedupKey(text, offsetShift, "")
if k1 != k2 {
t.Error("same element path with different char offsets should produce same key (bucket)")
}
}
func TestComputeDedupKey_FallbackToRawPosition(t *testing.T) {
text := "same text"
k1 := ComputeDedupKey(text, "", "page:42")
k2 := ComputeDedupKey(text, "", "page:42")
if k1 != k2 {
t.Error("same raw position should produce same key")
}
k3 := ComputeDedupKey(text, "", "page:99")
if k1 == k3 {
t.Error("different raw positions should produce different keys")
}
}
func TestComputeDedupKey_DifferentTextSamePosition(t *testing.T) {
cfi := "epubcfi(/6/4!/4/10/3:100)"
k1 := ComputeDedupKey("first highlight", cfi, "")
k2 := ComputeDedupKey("second highlight", cfi, "")
if k1 == k2 {
t.Error("different selection text should produce different keys")
}
}
func TestNormalizeText(t *testing.T) {
cases := []struct{ in, want string }{
{"Hello World", "hello world"},
{" Hello World ", "hello world"},
{"Hello\t\nWorld", "hello world"},
{"", ""},
{" ", ""},
}
for _, c := range cases {
got := normalizeText(c.in)
if got != c.want {
t.Errorf("normalizeText(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestBucketPosition(t *testing.T) {
cases := []struct{ in, want string }{
{"epubcfi(/6/4!/4/10/3:100)", "epubcfi(/6/4!/4/10/3"},
{"epubcfi(/6/4!/4/10/3:0)", "epubcfi(/6/4!/4/10/3"},
{"page:42", "page:42"},
{"short", "short"},
{"", ""},
}
for _, c := range cases {
got := bucketPosition(c.in)
if got != c.want {
t.Errorf("bucketPosition(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestBucketPosition_LongString(t *testing.T) {
long := "this_is_a_very_long_position_string_that_exceeds_fifty_characters_total"
got := bucketPosition(long)
if len(got) > 50 {
t.Errorf("bucketPosition should truncate to <=50 chars, got %d", len(got))
}
if got != long[:50] {
t.Errorf("bucketPosition truncated wrong: got %q", got)
}
}
func TestMergeDeviceSyncData_NewEntry(t *testing.T) {
result := mergeDeviceSyncData(nil, "koreader", json.RawMessage(`{"datetime":"2024-01-01"}`))
var m map[string]interface{}
if err := json.Unmarshal(result, &m); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
entry, ok := m["koreader"]
if !ok {
t.Fatal("expected koreader entry")
}
entryMap := entry.(map[string]interface{})
if entryMap["datetime"] != "2024-01-01" {
t.Errorf("unexpected datetime: %v", entryMap["datetime"])
}
}
func TestMergeDeviceSyncData_PreservesExisting(t *testing.T) {
existing := []byte(`{"koreader":{"datetime":"2024-01-01"}}`)
result := mergeDeviceSyncData(existing, "kobo", json.RawMessage(`{"bookmark_id":"abc"}`))
var m map[string]interface{}
if err := json.Unmarshal(result, &m); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if _, ok := m["koreader"]; !ok {
t.Error("koreader entry should be preserved")
}
if _, ok := m["kobo"]; !ok {
t.Error("kobo entry should be added")
}
}
func TestMergeDeviceSyncData_OverwritesSameSource(t *testing.T) {
existing := []byte(`{"koreader":{"datetime":"old"}}`)
result := mergeDeviceSyncData(existing, "koreader", json.RawMessage(`{"datetime":"new"}`))
var m map[string]interface{}
json.Unmarshal(result, &m)
entry := m["koreader"].(map[string]interface{})
if entry["datetime"] != "new" {
t.Errorf("expected overwritten datetime 'new', got %v", entry["datetime"])
}
}
func TestIsCrossSource(t *testing.T) {
if isCrossSource("koreader", pgtype.Text{String: "kobo", Valid: true}) != true {
t.Error("different sources should be cross-source")
}
if isCrossSource("koreader", pgtype.Text{String: "koreader", Valid: true}) != false {
t.Error("same sources should not be cross-source")
}
if isCrossSource("", pgtype.Text{String: "koreader", Valid: true}) != false {
t.Error("empty incoming source should not be cross-source")
}
if isCrossSource("koreader", pgtype.Text{Valid: false}) != false {
t.Error("invalid existing source should not be cross-source")
}
}
func TestCompareIncoming_FieldDiff_Identical(t *testing.T) {
svc := &AnnotationService{}
req := SaveHighlightRequest{
SelectionText: "hello",
Color: "#ffff00",
NoteText: "a note",
PercentageStart: 10.5,
PercentageEnd: 11.0,
}
existing := pgHighlights("hello", "#ffff00", "a note", 10.5, 11.0)
newer, changed := svc.compareIncoming(req, existing)
if newer {
t.Error("identical content should not be newer")
}
if changed {
t.Error("identical content should not be changed")
}
}
func TestCompareIncoming_FieldDiff_DifferentText(t *testing.T) {
svc := &AnnotationService{}
req := SaveHighlightRequest{
SelectionText: "edited text",
}
existing := pgHighlights("original text", "#ffff00", "", 0, 0)
newer, changed := svc.compareIncoming(req, existing)
if !newer {
t.Error("different content should be newer")
}
if !changed {
t.Error("different content should be changed")
}
}
func TestCompareIncoming_FieldDiff_DifferentColor(t *testing.T) {
svc := &AnnotationService{}
req := SaveHighlightRequest{
SelectionText: "same",
Color: "#ff0000",
}
existing := pgHighlights("same", "#ffff00", "", 0, 0)
_, changed := svc.compareIncoming(req, existing)
if !changed {
t.Error("different color should be detected as changed")
}
}
func TestCompareIncoming_LWW_NewerWins(t *testing.T) {
svc := &AnnotationService{}
now := time.Now()
req := SaveHighlightRequest{
SelectionText: "same",
ModifiedAt: now.Add(1 * time.Hour),
}
existing := pgHighlights("same", "", "", 0, 0)
existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true}
newer, changed := svc.compareIncoming(req, existing)
if !newer {
t.Error("future timestamp should be newer")
}
if !changed {
t.Error("LWW mode should always report changed=true")
}
}
func TestCompareIncoming_LWW_OlderSkipped(t *testing.T) {
svc := &AnnotationService{}
now := time.Now()
req := SaveHighlightRequest{
SelectionText: "same",
ModifiedAt: now.Add(-1 * time.Hour),
}
existing := pgHighlights("same", "", "", 0, 0)
existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true}
newer, _ := svc.compareIncoming(req, existing)
if newer {
t.Error("past timestamp should not be newer")
}
}
func TestCompareIncoming_LWW_FallsBackToUpdatedAt(t *testing.T) {
svc := &AnnotationService{}
now := time.Now()
req := SaveHighlightRequest{
SelectionText: "same",
ModifiedAt: now.Add(1 * time.Hour),
}
existing := pgHighlights("same", "", "", 0, 0)
existing.LastModifiedAt = pgtype.Timestamptz{Valid: false}
existing.UpdatedAt = pgtype.Timestamptz{Time: now, Valid: true}
newer, _ := svc.compareIncoming(req, existing)
if !newer {
t.Error("should fall back to updated_at when last_modified_at is invalid")
}
}
func TestPgText(t *testing.T) {
if pgText("").Valid {
t.Error("empty string should produce invalid pgtype.Text")
}
v := pgText("hello")
if !v.Valid || v.String != "hello" {
t.Errorf("expected valid 'hello', got %+v", v)
}
}
func TestPgFloat8(t *testing.T) {
if pgFloat8(0).Valid {
t.Error("zero should produce invalid pgtype.Float8")
}
v := pgFloat8(1.5)
if !v.Valid || v.Float64 != 1.5 {
t.Errorf("expected valid 1.5, got %+v", v)
}
}
func TestPgInt4(t *testing.T) {
if pgInt4(0).Valid {
t.Error("zero should produce invalid pgtype.Int4")
}
v := pgInt4(3)
if !v.Valid || v.Int32 != 3 {
t.Errorf("expected valid 3, got %+v", v)
}
}
func TestFloatEq(t *testing.T) {
if !floatEq(0, pgtype.Float8{Valid: false}) {
t.Error("0 vs invalid should be equal")
}
if !floatEq(10.5, pgtype.Float8{Float64: 10.5, Valid: true}) {
t.Error("10.5 vs 10.5 should be equal")
}
if floatEq(10.6, pgtype.Float8{Float64: 10.5, Valid: true}) {
t.Error("10.6 vs 10.5 should not be equal")
}
}
func TestTextEq(t *testing.T) {
if !textEq("", pgtype.Text{Valid: false}) {
t.Error("empty vs invalid should be equal")
}
if !textEq("hi", pgtype.Text{String: "hi", Valid: true}) {
t.Error("same strings should be equal")
}
if textEq("hi", pgtype.Text{String: "bye", Valid: true}) {
t.Error("different strings should not be equal")
}
}
func TestTombstoneTTL(t *testing.T) {
if TombstoneTTL != 30*24*time.Hour {
t.Errorf("expected 30 days, got %v", TombstoneTTL)
}
}
func pgHighlights(text, color, note string, pctStart, pctEnd float64) database.MediaHighlights {
return database.MediaHighlights{
SelectionText: text,
Color: pgtype.Text{String: color, Valid: color != ""},
NoteText: pgtype.Text{String: note, Valid: note != ""},
PercentageStart: pgtype.Float8{Float64: pctStart, Valid: pctStart != 0},
PercentageEnd: pgtype.Float8{Float64: pctEnd, Valid: pctEnd != 0},
}
}
File diff suppressed because it is too large Load Diff
+449
View File
@@ -0,0 +1,449 @@
package sync
import (
"strings"
"testing"
"golang.org/x/net/html"
)
func TestParseCREXPointer(t *testing.T) {
tests := []struct {
input string
wantFrag int
wantPath int
wantChar int
}{
{"/body/DocFragment[0]/body/div[4]/p[38]/text().541", 0, 2, 541},
{"/body/DocFragment[2]/body/div/p[5]/text()[2].16", 2, 2, 16},
{"/body/DocFragment[1]/body", 1, 0, 0},
{"/body/DocFragment[5]/body/div[3]/p[28]", 5, 2, 0},
}
for _, tt := range tests {
xp, err := ParseCREXPointer(tt.input)
if err != nil {
t.Errorf("ParseCREXPointer(%q) error: %v", tt.input, err)
continue
}
if xp.FragmentIndex != tt.wantFrag {
t.Errorf("FragmentIndex = %d, want %d", xp.FragmentIndex, tt.wantFrag)
}
if len(xp.ElementPath) != tt.wantPath {
t.Errorf("len(ElementPath) = %d, want %d (got %+v)", len(xp.ElementPath), tt.wantPath, xp.ElementPath)
}
if xp.CharOffset != tt.wantChar {
t.Errorf("CharOffset = %d, want %d", xp.CharOffset, tt.wantChar)
}
}
}
func TestParseCREXPointerInvalid(t *testing.T) {
_, err := ParseCREXPointer("epubcfi(/6/4!/4/2/1:0)")
if err == nil {
t.Error("expected error for standard epubcfi")
}
_, err = ParseCREXPointer("")
if err == nil {
t.Error("expected error for empty string")
}
}
func TestIsCREXPointer(t *testing.T) {
if !IsCREXPointer("/body/DocFragment[0]/body/div/p") {
t.Error("should recognize CRE XPointer")
}
if !IsCREXPointer("#_doc_fragment_5_ link2HCH0002") {
t.Error("should recognize CRE fragment ID")
}
if IsCREXPointer("epubcfi(/6/4!/4/2/1:0)") {
t.Error("should not recognize standard epubcfi as CRE")
}
}
func TestIsCREFragmentID(t *testing.T) {
if !IsCREFragmentID("#_doc_fragment_5_ link2HCH0002") {
t.Error("should recognize fragment ID")
}
if IsCREFragmentID("/body/DocFragment[2]/body") {
t.Error("should not recognize XPointer as fragment ID")
}
}
func TestParseCREFragmentID(t *testing.T) {
tests := []struct {
input string
wantSpine int
wantAnchor string
}{
{"#_doc_fragment_5_ link2HCH0002", 5, "link2HCH0002"},
{"#_doc_fragment_0_", 0, ""},
{"#_doc_fragment_12_someid123", 12, "someid123"},
}
for _, tt := range tests {
frag, err := ParseCREFragmentID(tt.input)
if err != nil {
t.Errorf("ParseCREFragmentID(%q) error: %v", tt.input, err)
continue
}
if frag.SpineIndex != tt.wantSpine {
t.Errorf("SpineIndex = %d, want %d", frag.SpineIndex, tt.wantSpine)
}
if frag.Anchor != tt.wantAnchor {
t.Errorf("Anchor = %q, want %q", frag.Anchor, tt.wantAnchor)
}
}
}
func TestParseCREFragmentIDInvalid(t *testing.T) {
_, err := ParseCREFragmentID("/body/DocFragment[2]/body")
if err == nil {
t.Error("expected error for XPointer input")
}
_, err = ParseCREFragmentID("#_doc_fragment_")
if err == nil {
t.Error("expected error for missing index")
}
}
func TestIsStandardEPUBCFI(t *testing.T) {
if !IsStandardEPUBCFI("epubcfi(/6/4!/4/2/1:0)") {
t.Error("should recognize standard epubcfi")
}
if IsStandardEPUBCFI("/body/DocFragment[0]/body") {
t.Error("should not recognize CRE as standard")
}
}
func TestConvert1984(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
c := NewCFIConverter(epubPath)
xp := "/body/DocFragment[2]/body/div/p[5]/text().500"
result, err := c.ConvertCREToStandard(xp, 0.01, "")
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("Input: %s", xp)
t.Logf("EPUBCFI: %s", result.EPUBCFI)
t.Logf("Href: %s", result.Href)
t.Logf("Precision: %s", result.Precision)
t.Logf("Percentage: %.4f", result.Percentage)
if result.Precision == "percentage" {
t.Error("expected better than percentage precision")
}
}
func TestConvertCrimeAndPunishmentFragmentID(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
c := NewCFIConverter(epubPath)
xp := "#_doc_fragment_5_ link2HCH0002"
result, err := c.ConvertCREToStandard(xp, 0.0303, "")
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("Input: %s", xp)
t.Logf("EPUBCFI: %s", result.EPUBCFI)
t.Logf("Href: %s", result.Href)
t.Logf("Precision: %s", result.Precision)
t.Logf("Percentage: %.4f", result.Percentage)
if result.Precision == "percentage" {
t.Error("expected better than percentage precision")
}
if result.Href == "" {
t.Error("expected non-empty href")
}
if result.Precision != "element" {
t.Errorf("expected element precision, got %s", result.Precision)
}
}
func TestParseEPUBCFI(t *testing.T) {
tests := []struct {
input string
wantSpine int
wantSteps int
}{
{"epubcfi(/6/12!/4/2/90/1:7)", 5, 4},
{"epubcfi(/6/4!/4/2/1:0)", 1, 3},
{"epubcfi(/6/2!/4)", 0, 1},
}
for _, tt := range tests {
spineIndex, steps, err := parseEPUBCFI(tt.input)
if err != nil {
t.Errorf("parseEPUBCFI(%q) error: %v", tt.input, err)
continue
}
if spineIndex != tt.wantSpine {
t.Errorf("spineIndex = %d, want %d", spineIndex, tt.wantSpine)
}
if len(steps) != tt.wantSteps {
t.Errorf("len(steps) = %d, want %d", len(steps), tt.wantSteps)
}
}
}
func TestParseEPUBCFIRange(t *testing.T) {
spineIndex, steps, err := parseEPUBCFI("epubcfi(/6/40!/4,/24/20,/40/5:61)")
if err != nil {
t.Fatalf("parseEPUBCFI range error: %v", err)
}
if spineIndex != 19 {
t.Errorf("spineIndex = %d, want 19", spineIndex)
}
t.Logf("Range CFI steps: %d", len(steps))
for i, s := range steps {
t.Logf(" step %d: index=%d offset=%d hasOffset=%v", i, s.Index, s.Offset, s.HasOffset)
}
}
func TestParseEPUBCFIInvalid(t *testing.T) {
_, _, err := parseEPUBCFI("not-a-cfi")
if err == nil {
t.Error("expected error for invalid CFI")
}
_, _, err = parseEPUBCFI("epubcfi(/6/12)")
if err == nil {
t.Error("expected error for CFI without indirection")
}
}
func TestRoundTrip1984(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
c := NewCFIConverter(epubPath)
originalXP := "/body/DocFragment[2]/body/div/p[5]/text().500"
forward, err := c.ConvertCREToStandard(originalXP, 0.01, "")
if err != nil {
t.Fatalf("forward conversion error: %v", err)
}
if forward.EPUBCFI == "" {
t.Fatal("forward conversion produced empty epubcfi")
}
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, "")
if err != nil {
t.Fatalf("reverse conversion error: %v", err)
}
if reverse.XPointer == "" {
t.Fatal("reverse conversion produced empty XPointer")
}
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
t.Logf("Reverse precision: %s", reverse.Precision)
if reverse.Precision != "exact" {
t.Errorf("expected exact precision, got %s", reverse.Precision)
}
}
func TestRoundTripCP(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
c := NewCFIConverter(epubPath)
originalXP := "/body/DocFragment[6]/body/div/p[47]/text().2399"
contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l"
forward, err := c.ConvertCREToStandard(originalXP, 0.0579, contextText)
if err != nil {
t.Fatalf("forward conversion error: %v", err)
}
if forward.EPUBCFI == "" {
t.Fatal("forward conversion produced empty epubcfi")
}
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, contextText)
if err != nil {
t.Fatalf("reverse conversion error: %v", err)
}
if reverse.XPointer == "" {
t.Fatal("reverse conversion produced empty XPointer")
}
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
t.Logf("Reverse precision: %s", reverse.Precision)
if reverse.Precision != "exact" {
t.Errorf("expected exact precision, got %s", reverse.Precision)
}
}
func TestReverseTextSearchFallback(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
c := NewCFIConverter(epubPath)
contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l"
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.0579, contextText)
if err != nil {
t.Fatalf("reverse conversion error: %v", err)
}
t.Logf("Text search fallback XPointer: %s", reverse.XPointer)
t.Logf("Precision: %s", reverse.Precision)
if reverse.Precision != "exact" {
t.Errorf("expected exact precision from text search, got %s", reverse.Precision)
}
if reverse.XPointer == "" {
t.Error("expected non-empty XPointer from text search")
}
}
func TestReversePercentageFallback(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
c := NewCFIConverter(epubPath)
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.5, "")
if err != nil {
t.Fatalf("reverse conversion error: %v", err)
}
t.Logf("Percentage fallback precision: %s", reverse.Precision)
if reverse.Precision != "percentage" {
t.Errorf("expected percentage precision, got %s with XPointer %s", reverse.Precision, reverse.XPointer)
}
if reverse.XPointer != "" {
t.Error("expected empty XPointer for percentage fallback")
}
}
func TestFindTextInNode_SingleTextNode(t *testing.T) {
doc := parseTestHTML(`<html><body><p>Hello world this is a test</p></body></html>`)
body := findBody(doc)
node, offset := findTextInNode(body, "Hello world")
if node == nil {
t.Fatal("expected to find text")
}
if offset != 0 {
t.Errorf("offset = %d, want 0", offset)
}
}
func TestFindTextInNode_CrossEmElement(t *testing.T) {
doc := parseTestHTML(`<html><body><p>the countries <em>Vokalia</em> and <em>Consonantia</em> live here</p></body></html>`)
body := findBody(doc)
node, offset := findTextInNode(body, "Vokalia and Consonantia")
if node == nil {
t.Fatal("expected to find text across <em> elements")
}
if node.Data != "Vokalia" {
t.Errorf("expected match in 'Vokalia' text node, got %q", node.Data)
}
if offset != 0 {
t.Errorf("offset = %d, want 0", offset)
}
}
func TestFindTextInNode_CrossStrongElement(t *testing.T) {
doc := parseTestHTML(`<html><body><p>Some <strong>bold and italic</strong> text here</p></body></html>`)
body := findBody(doc)
node, _ := findTextInNode(body, "bold and italic text")
if node == nil {
t.Fatal("expected to find text across <strong> boundary")
}
if node.Data != "bold and italic" {
t.Errorf("expected match in 'bold and italic' text node, got %q", node.Data)
}
}
func TestFindTextInNode_DoesNotCrossParagraphs(t *testing.T) {
doc := parseTestHTML(`<html><body><p>first paragraph</p><p>second paragraph</p></body></html>`)
body := findBody(doc)
node, _ := findTextInNode(body, "paragraph second")
if node != nil {
t.Error("should not match text across <p> boundaries")
}
}
func TestFindTextInNode_NestedFormatting(t *testing.T) {
doc := parseTestHTML(`<html><body><p>before <em><strong>bold italic</strong></em> after</p></body></html>`)
body := findBody(doc)
node, offset := findTextInNode(body, "bold italic after")
if node == nil {
t.Fatal("expected to find text across nested formatting")
}
if node.Data != "bold italic" {
t.Errorf("expected match in 'bold italic' text node, got %q", node.Data)
}
_ = offset
}
func TestFindBlockParent(t *testing.T) {
doc := parseTestHTML(`<html><body><p>text <em>inside <strong>deep</strong></em></p></body></html>`)
body := findBody(doc)
var deepNode *html.Node
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode && n.Data == "deep" {
deepNode = n
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(body)
if deepNode == nil {
t.Fatal("could not find 'deep' text node")
}
block := findBlockParent(deepNode)
if block == nil {
t.Fatal("expected block parent")
}
if block.Data != "p" {
t.Errorf("block parent = %q, want 'p'", block.Data)
}
}
func TestCollectInlineText(t *testing.T) {
doc := parseTestHTML(`<html><body><p>the countries <em>Vokalia</em> and <em>Consonantia</em> live</p></body></html>`)
body := findBody(doc)
var p *html.Node
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "p" {
p = n
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(body)
if p == nil {
t.Fatal("could not find <p> element")
}
segments := collectInlineText(p)
var collected []rune
for _, seg := range segments {
collected = append(collected, seg.runes...)
}
flattened := strings.TrimSpace(string(collected))
if flattened != "the countries Vokalia and Consonantia live" {
t.Errorf("collected text = %q", flattened)
}
}
func parseTestHTML(s string) *html.Node {
doc, err := html.Parse(strings.NewReader(s))
if err != nil {
panic(err)
}
return doc
}
+321
View File
@@ -0,0 +1,321 @@
package sync
import (
"fmt"
"log"
"golang.org/x/net/html"
)
type KEPUBCFIConverter struct {
epubConverter *CFIConverter
kepubConverter *CFIConverter
}
type KEPUBConversionResult struct {
CFI string
ExtractedContext string
Percentage float64
Precision string
}
func NewKEPUBCFIConverter(epubPath, kepubPath string) *KEPUBCFIConverter {
return &KEPUBCFIConverter{
epubConverter: NewCFIConverter(epubPath),
kepubConverter: NewCFIConverter(kepubPath),
}
}
func (k *KEPUBCFIConverter) ConvertKEPUBCFIToStandard(kepubCFI string, percentage float64, contextText string) (*KEPUBConversionResult, error) {
if !IsStandardEPUBCFI(kepubCFI) {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
spineIndex, steps, err := parseEPUBCFI(kepubCFI)
if err != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
if docErr != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
kepubTextNode, kepubOffset, resolveErr := resolveCFIToNode(kepubDoc, steps)
if resolveErr != nil {
return k.kepubToStandardByPercentage(spineIndex, percentage)
}
var searchText string
if contextText != "" {
searchText = normalizeWhitespace(contextText)
} else if kepubTextNode != nil && kepubTextNode.Type == html.TextNode {
searchText = extractSurroundingText(kepubTextNode, kepubOffset, 80)
}
if searchText != "" {
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
if docErr == nil {
epubBody := findBody(epubDoc)
if epubBody != nil {
matchNode, matchOffset := findTextInNode(epubBody, searchText)
if matchNode != nil {
cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset)
if buildErr == nil && cfi != "" {
return &KEPUBConversionResult{
CFI: cfi,
ExtractedContext: searchText,
Percentage: percentage,
Precision: "exact",
}, nil
}
}
}
}
}
result, _ := k.kepubToStandardByPercentage(spineIndex, percentage)
result.ExtractedContext = searchText
return result, nil
}
func (k *KEPUBCFIConverter) ConvertStandardCFIToKEPUB(standardCFI string, percentage float64, contextText string) (*KEPUBConversionResult, error) {
if !IsStandardEPUBCFI(standardCFI) {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
spineIndex, steps, err := parseEPUBCFI(standardCFI)
if err != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
if docErr != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
epubTextNode, epubOffset, resolveErr := resolveCFIToNode(epubDoc, steps)
if resolveErr != nil {
return k.standardToKEPUBByPercentage(spineIndex, percentage)
}
var searchText string
if contextText != "" {
searchText = normalizeWhitespace(contextText)
} else if epubTextNode != nil && epubTextNode.Type == html.TextNode {
searchText = extractSurroundingText(epubTextNode, epubOffset, 80)
}
if searchText != "" {
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
if docErr == nil {
kepubBody := findBody(kepubDoc)
if kepubBody != nil {
matchNode, matchOffset := findTextInNode(kepubBody, searchText)
if matchNode != nil {
cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset)
if buildErr == nil && cfi != "" {
return &KEPUBConversionResult{
CFI: cfi,
ExtractedContext: searchText,
Percentage: percentage,
Precision: "exact",
}, nil
}
}
}
}
}
result, _ := k.standardToKEPUBByPercentage(spineIndex, percentage)
result.ExtractedContext = searchText
return result, nil
}
func (k *KEPUBCFIConverter) kepubToStandardByPercentage(spineIndex int, percentage float64) (*KEPUBConversionResult, error) {
if percentage <= 0 {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
if docErr != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
epubBody := findBody(epubDoc)
if epubBody == nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
totalChars := countTextChars(epubBody)
if totalChars <= 0 {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
targetOffset := int(float64(totalChars) * percentage)
targetNode, foundOffset := findNodeAtCharOffset(epubBody, targetOffset)
if targetNode == nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
charInNode := targetOffset - foundOffset
cfi, err := buildCFI(spineIndex, targetNode, charInNode)
if err != nil || cfi == "" {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
return &KEPUBConversionResult{
CFI: cfi,
Percentage: percentage,
Precision: "percentage",
}, nil
}
func (k *KEPUBCFIConverter) standardToKEPUBByPercentage(spineIndex int, percentage float64) (*KEPUBConversionResult, error) {
if percentage <= 0 {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
if docErr != nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
kepubBody := findBody(kepubDoc)
if kepubBody == nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
totalChars := countTextChars(kepubBody)
if totalChars <= 0 {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
targetOffset := int(float64(totalChars) * percentage)
targetNode, foundOffset := findNodeAtCharOffset(kepubBody, targetOffset)
if targetNode == nil {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
charInNode := targetOffset - foundOffset
cfi, err := buildCFI(spineIndex, targetNode, charInNode)
if err != nil || cfi == "" {
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
}
return &KEPUBConversionResult{
CFI: cfi,
Percentage: percentage,
Precision: "percentage",
}, nil
}
func extractSurroundingText(textNode *html.Node, offset int, window int) string {
if textNode == nil || textNode.Type != html.TextNode {
return ""
}
block := findBlockParent(textNode)
if block == nil {
text := textNode.Data
runes := []rune(text)
start := offset - window
if start < 0 {
start = 0
}
end := offset + window
if end > len(runes) {
end = len(runes)
}
if start >= end {
return normalizeWhitespace(text)
}
return normalizeWhitespace(string(runes[start:end]))
}
segments := collectInlineText(block)
globalOffset := 0
for _, seg := range segments {
if seg.node == textNode {
globalOffset += offset
break
}
globalOffset += len(seg.runes)
}
var allRunes []rune
for _, seg := range segments {
allRunes = append(allRunes, seg.runes...)
}
start := globalOffset - window
if start < 0 {
start = 0
}
end := globalOffset + window
if end > len(allRunes) {
end = len(allRunes)
}
if start >= end {
return normalizeWhitespace(string(allRunes))
}
return normalizeWhitespace(string(allRunes[start:end]))
}
func (k *KEPUBCFIConverter) ComputeKEPUBPercentage(kepubCFI string) (float64, error) {
if !IsStandardEPUBCFI(kepubCFI) {
return -1, fmt.Errorf("not a standard epubcfi")
}
spineIndex, steps, err := parseEPUBCFI(kepubCFI)
if err != nil {
return -1, err
}
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
if docErr != nil {
return -1, docErr
}
kepubBody := findBody(kepubDoc)
if kepubBody == nil {
return -1, fmt.Errorf("no body in kepub doc")
}
textNode, textOffset, resolveErr := resolveCFIToNode(kepubBody, steps)
if resolveErr != nil {
return -1, resolveErr
}
charOffset := countTextCharsBefore(textNode) + textOffset
total := 0
kepubSpine, spineErr := k.kepubConverter.loadSpine()
if spineErr != nil {
return -1, spineErr
}
for i := range kepubSpine.items {
doc, _, dErr := k.kepubConverter.getContentDoc(i + 1)
if dErr != nil {
continue
}
b := findBody(doc)
if b != nil {
total += countTextChars(b)
}
}
if total <= 0 {
return -1, fmt.Errorf("no text in kepub")
}
return float64(charOffset) / float64(total), nil
}
func init() {
_ = log.Printf
}
+884
View File
@@ -0,0 +1,884 @@
package sync
import (
"archive/zip"
"io"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/net/html"
)
const epubChapter1 = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Test Book</title></head>
<body>
<h1 id="ch1">Chapter One</h1>
<p>It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it was the winter of despair.</p>
<p id="p2">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump.</p>
<p>Far far away, behind the word mountains, far from the countries <em>Vokalia</em> and <em>Consonantia</em>, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean.</p>
<p>A wonderful serenity has taken possession of my entire soul, like these sweet mornings of spring which I enjoy with my whole heart. I am alone, and feel the charm of existence in this spot, which was created for the bliss of souls like mine.</p>
<p>I should be incapable of drawing a single stroke at the present moment; and yet I feel that I never was a greater artist than now. When, while the lovely valley teems with vapour around me.</p>
</body>
</html>`
const epubChapter2 = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Test Book</title></head>
<body>
<h1 id="ch2">Chapter Two</h1>
<p>Call me Ishmael. Some years agonever mind how long preciselyhaving little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.</p>
<p>It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul.</p>
</body>
</html>`
const kepubChapter1 = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Test Book</title></head>
<body>
<h1 id="ch1">Chapter One</h1>
<p><span class="koboSpan" id="kobo.1.1">It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it was the winter of despair.</span></p>
<p id="p2"><span class="koboSpan" id="kobo.2.1">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump.</span></p>
<p><span class="koboSpan" id="kobo.3.1">Far far away, behind the word mountains, far from the countries </span><em><span class="koboSpan" id="kobo.3.2">Vokalia</span></em><span class="koboSpan" id="kobo.3.3"> and </span><em><span class="koboSpan" id="kobo.3.4">Consonantia</span></em><span class="koboSpan" id="kobo.3.5">, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean.</span></p>
<p><span class="koboSpan" id="kobo.4.1">A wonderful serenity has taken possession of my entire soul, like these sweet mornings of spring which I enjoy with my whole heart. I am alone, and feel the charm of existence in this spot, which was created for the bliss of souls like mine.</span></p>
<p><span class="koboSpan" id="kobo.5.1">I should be incapable of drawing a single stroke at the present moment; and yet I feel that I never was a greater artist than now. When, while the lovely valley teems with vapour around me.</span></p>
</body>
</html>`
const kepubChapter2 = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Test Book</title></head>
<body>
<h1 id="ch2">Chapter Two</h1>
<p><span class="koboSpan" id="kobo.6.1">Call me Ishmael. Some years agonever mind how long preciselyhaving little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.</span></p>
<p><span class="koboSpan" id="kobo.7.1">It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul.</span></p>
</body>
</html>`
const containerXML = `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`
const contentOPF = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">urn:uuid:test-book-00000001</dc:identifier>
<dc:title>Test Book</dc:title>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="ch2" href="chapter2.xhtml" media-type="application/xhtml+xml"/>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
</manifest>
<spine>
<itemref idref="ch1"/>
<itemref idref="ch2"/>
</spine>
</package>`
const navXHTML = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>TOC</title></head>
<body>
<nav epub:type="toc"><ol><li><a href="chapter1.xhtml">Chapter 1</a></li><li><a href="chapter2.xhtml">Chapter 2</a></li></ol></nav>
</body>
</html>`
func createTestEPUB(t *testing.T, dir, name, ch1Content, ch2Content string) string {
t.Helper()
epubPath := filepath.Join(dir, name)
f, err := os.Create(epubPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
w := zip.NewWriter(f)
files := map[string]string{
"mimetype": "application/epub+zip",
"META-INF/container.xml": containerXML,
"OEBPS/content.opf": contentOPF,
"OEBPS/nav.xhtml": navXHTML,
"OEBPS/chapter1.xhtml": ch1Content,
"OEBPS/chapter2.xhtml": ch2Content,
}
mimetype, _ := w.Create("mimetype")
mimetype.Write([]byte("application/epub+zip"))
for path, content := range files {
if path == "mimetype" {
continue
}
fw, err := w.Create(path)
if err != nil {
t.Fatal(err)
}
fw.Write([]byte(content))
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return epubPath
}
func setupKEPUBTestEnv(t *testing.T) (epubPath, kepubPath string) {
t.Helper()
dir := t.TempDir()
epubPath = createTestEPUB(t, dir, "test.epub", epubChapter1, epubChapter2)
kepubPath = createTestEPUB(t, dir, "test.kepub.epub", kepubChapter1, kepubChapter2)
return epubPath, kepubPath
}
func TestKEPUBConvertKEPUBToStandard_TextSearch(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
epubOnly := NewCFIConverter(epubPath)
epubSpine, err := epubOnly.loadSpine()
if err != nil {
t.Fatalf("load epub spine: %v", err)
}
if len(epubSpine.items) < 1 {
t.Fatal("expected at least 1 spine item")
}
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub content doc: %v", err)
}
epubBody := findBody(epubDoc)
if epubBody == nil {
t.Fatal("no body in epub doc")
}
searchText := normalizeWhitespace("it was the worst of times")
epubNode, epubOffset := findTextInNode(epubBody, searchText)
if epubNode == nil {
t.Fatal("could not find target text in EPUB")
}
standardCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build standard CFI: %v", err)
}
t.Logf("Standard EPUB CFI: %s", standardCFI)
kepubOnly := NewCFIConverter(kepubPath)
kepubDoc, _, err := kepubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get kepub content doc: %v", err)
}
kepubBody := findBody(kepubDoc)
if kepubBody == nil {
t.Fatal("no body in kepub doc")
}
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
if kepubNode == nil {
t.Fatal("could not find target text in KEPUB")
}
kepubCFI, err := buildCFI(0, kepubNode, kepubOffset)
if err != nil {
t.Fatalf("build kepub CFI: %v", err)
}
t.Logf("KEPUB CFI: %s", kepubCFI)
if standardCFI == kepubCFI {
t.Error("KEPUB and standard CFIs should differ due to koboSpan wrappers")
}
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.05, searchText)
if err != nil {
t.Fatalf("ConvertKEPUBCFIToStandard error: %v", err)
}
t.Logf("Converted CFI: %s (precision: %s)", result.CFI, result.Precision)
if result.Precision != "exact" {
t.Errorf("expected exact precision, got %s", result.Precision)
}
if result.CFI == "" {
t.Fatal("expected non-empty CFI")
}
spineIdx1, steps1, _ := parseEPUBCFI(result.CFI)
spineIdx2, steps2, _ := parseEPUBCFI(standardCFI)
if spineIdx1 != spineIdx2 {
t.Errorf("spine indices differ: %d vs %d", spineIdx1, spineIdx2)
}
resultNode, _, err := resolveCFIToNode(epubDoc, steps1)
if err != nil {
t.Fatalf("resolve converted CFI: %v", err)
}
expectedNode, _, _ := resolveCFIToNode(epubDoc, steps2)
if resultNode != expectedNode {
t.Errorf("resolved to different text nodes: got %q, want %q",
truncateText(resultNode.Data, 40),
truncateText(expectedNode.Data, 40))
}
}
func TestKEPUBConvertStandardToKEPUB_TextSearch(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub doc: %v", err)
}
epubBody := findBody(epubDoc)
searchText := normalizeWhitespace("The quick brown fox jumps over the lazy dog")
epubNode, epubOffset := findTextInNode(epubBody, searchText)
if epubNode == nil {
t.Fatal("could not find target text in EPUB")
}
standardCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build CFI: %v", err)
}
t.Logf("Standard CFI: %s", standardCFI)
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.1, searchText)
if err != nil {
t.Fatalf("ConvertStandardCFIToKEPUB error: %v", err)
}
t.Logf("KEPUB CFI: %s (precision: %s)", result.CFI, result.Precision)
if result.Precision != "exact" {
t.Errorf("expected exact precision, got %s", result.Precision)
}
if result.CFI == "" {
t.Fatal("expected non-empty CFI")
}
if !strings.Contains(result.CFI, "kobo") {
t.Errorf("KEPUB CFI should contain koboSpan step: %s", result.CFI)
}
backResult, err := converter.ConvertKEPUBCFIToStandard(result.CFI, 0.1, searchText)
if err != nil {
t.Fatalf("KEPUB→standard round-trip: %v", err)
}
if backResult.CFI != standardCFI {
t.Errorf("round-trip mismatch: got %s, want %s", backResult.CFI, standardCFI)
}
}
func TestKEPUBRoundTrip(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub doc: %v", err)
}
epubBody := findBody(epubDoc)
phrases := []string{
"It was the best of times",
"The quick brown fox jumps",
"A wonderful serenity has taken possession",
}
for _, phrase := range phrases {
t.Run(phrase, func(t *testing.T) {
searchText := normalizeWhitespace(phrase)
epubNode, epubOffset := findTextInNode(epubBody, searchText)
if epubNode == nil {
t.Fatalf("could not find %q in epub", phrase)
}
originalCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build CFI: %v", err)
}
t.Logf("Original standard CFI: %s", originalCFI)
toKEPUB, err := converter.ConvertStandardCFIToKEPUB(originalCFI, 0.1, searchText)
if err != nil {
t.Fatalf("standard→KEPUB: %v", err)
}
if toKEPUB.Precision != "exact" {
t.Fatalf("expected exact precision in standard→KEPUB, got %s", toKEPUB.Precision)
}
t.Logf("KEPUB CFI: %s", toKEPUB.CFI)
backToStandard, err := converter.ConvertKEPUBCFIToStandard(toKEPUB.CFI, 0.1, searchText)
if err != nil {
t.Fatalf("KEPUB→standard: %v", err)
}
if backToStandard.Precision != "exact" {
t.Fatalf("expected exact precision in KEPUB→standard, got %s", backToStandard.Precision)
}
t.Logf("Round-trip standard CFI: %s", backToStandard.CFI)
origSpine, origSteps, _ := parseEPUBCFI(originalCFI)
rtSpine, rtSteps, _ := parseEPUBCFI(backToStandard.CFI)
if origSpine != rtSpine {
t.Errorf("spine mismatch: original=%d roundtrip=%d", origSpine, rtSpine)
}
origNode, _, _ := resolveCFIToNode(epubDoc, origSteps)
rtNode, _, _ := resolveCFIToNode(epubDoc, rtSteps)
if origNode != rtNode {
t.Errorf("resolved to different nodes:\n original: %q\n roundtrip: %q",
truncateText(origNode.Data, 50),
truncateText(rtNode.Data, 50))
}
})
}
}
func TestKEPUBConvertKEPUBToStandard_Chapter2(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
searchText := normalizeWhitespace("Call me Ishmael")
kepubOnly := NewCFIConverter(kepubPath)
kepubDoc, _, err := kepubOnly.getContentDoc(2)
if err != nil {
t.Fatalf("get kepub doc ch2: %v", err)
}
kepubBody := findBody(kepubDoc)
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
if kepubNode == nil {
t.Fatal("could not find text in kepub ch2")
}
kepubCFI, err := buildCFI(1, kepubNode, kepubOffset)
if err != nil {
t.Fatalf("build kepub CFI: %v", err)
}
t.Logf("KEPUB CFI (ch2): %s", kepubCFI)
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.55, searchText)
if err != nil {
t.Fatalf("ConvertKEPUBCFIToStandard: %v", err)
}
if result.Precision != "exact" {
t.Errorf("expected exact precision, got %s", result.Precision)
}
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, _ := epubOnly.getContentDoc(2)
epubBody := findBody(epubDoc)
epubNode, epubOffset := findTextInNode(epubBody, searchText)
expectedCFI, _ := buildCFI(1, epubNode, epubOffset)
t.Logf("Expected standard CFI: %s", expectedCFI)
t.Logf("Got standard CFI: %s", result.CFI)
_, resultSteps, _ := parseEPUBCFI(result.CFI)
resultNode, _, resolveErr := resolveCFIToNode(epubDoc, resultSteps)
if resolveErr != nil {
t.Fatalf("resolve result CFI: %v", resolveErr)
}
if resultNode == nil {
t.Fatal("resolved to nil node")
}
if !strings.Contains(strings.ToLower(resultNode.Data), "call me ishmael") {
t.Errorf("resolved to wrong text: %q", truncateText(resultNode.Data, 50))
}
}
func TestKEPUBConvertWithEmElements(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
searchText := normalizeWhitespace("Vokalia and Consonantia")
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub doc: %v", err)
}
epubBody := findBody(epubDoc)
epubNode, epubOffset := findTextInNode(epubBody, searchText)
if epubNode == nil {
t.Fatalf("findTextInNode should find text spanning <em> elements: %q", searchText)
}
standardCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build CFI: %v", err)
}
t.Logf("Standard CFI (em): %s", standardCFI)
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.2, searchText)
if err != nil {
t.Fatalf("ConvertStandardCFIToKEPUB: %v", err)
}
t.Logf("KEPUB CFI (em elements): %s (precision: %s)", result.CFI, result.Precision)
if result.CFI == "" {
t.Error("expected non-empty CFI")
}
if result.Precision != "exact" {
t.Errorf("expected exact precision for cross-element text, got %s", result.Precision)
}
backResult, err := converter.ConvertKEPUBCFIToStandard(result.CFI, 0.2, searchText)
if err != nil {
t.Fatalf("KEPUB→standard round-trip: %v", err)
}
if backResult.Precision != "exact" {
t.Errorf("expected exact precision on round-trip, got %s", backResult.Precision)
}
}
func TestKEPUBConvertInvalidCFI(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
result, err := converter.ConvertKEPUBCFIToStandard("not-a-cfi", 0.5, "some text")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Precision != "percentage" {
t.Errorf("expected percentage fallback, got %s", result.Precision)
}
result, err = converter.ConvertStandardCFIToKEPUB("not-a-cfi", 0.5, "some text")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Precision != "percentage" {
t.Errorf("expected percentage fallback, got %s", result.Precision)
}
}
func TestKEPUBConvertNoContextText(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub doc: %v", err)
}
epubBody := findBody(epubDoc)
epubNode, epubOffset := findTextInNode(epubBody, normalizeWhitespace("It was the best of times"))
if epubNode == nil {
t.Fatal("could not find text")
}
standardCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build CFI: %v", err)
}
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.1, "")
if err != nil {
t.Fatalf("ConvertStandardCFIToKEPUB (no context): %v", err)
}
t.Logf("No-context result: CFI=%s precision=%s", result.CFI, result.Precision)
if result.CFI == "" {
t.Error("expected non-empty CFI even without context text")
}
}
func TestKEPUBConvertKEPUBToStandard_NoContextText(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
kepubOnly := NewCFIConverter(kepubPath)
kepubDoc, _, err := kepubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get kepub doc: %v", err)
}
kepubBody := findBody(kepubDoc)
kepubNode, kepubOffset := findTextInNode(kepubBody, normalizeWhitespace("It was the best of times"))
if kepubNode == nil {
t.Fatal("could not find text in kepub")
}
kepubCFI, err := buildCFI(0, kepubNode, kepubOffset)
if err != nil {
t.Fatalf("build CFI: %v", err)
}
t.Logf("KEPUB CFI: %s", kepubCFI)
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.05, "")
if err != nil {
t.Fatalf("ConvertKEPUBCFIToStandard (no context): %v", err)
}
t.Logf("No-context result: CFI=%s precision=%s", result.CFI, result.Precision)
if result.CFI == "" {
t.Error("expected non-empty CFI even without context text")
}
if result.Precision == "exact" {
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, _ := epubOnly.getContentDoc(1)
epubBody := findBody(epubDoc)
expectedNode, _ := findTextInNode(epubBody, normalizeWhitespace("It was the best of times"))
_, resultSteps, _ := parseEPUBCFI(result.CFI)
actualNode, _, _ := resolveCFIToNode(epubDoc, resultSteps)
if actualNode != expectedNode {
t.Errorf("resolved to wrong node: got %q, want text containing 'It was the best of times'",
truncateText(actualNode.Data, 40))
}
}
}
func TestKEPUBPercentageFallback(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
badCFI := "epubcfi(/6/2!/4/99999/1:0)"
result, err := converter.ConvertKEPUBCFIToStandard(badCFI, 0.5, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Precision != "percentage" {
t.Errorf("expected percentage precision for unresolvable CFI, got %s", result.Precision)
}
result2, err := converter.ConvertStandardCFIToKEPUB(badCFI, 0.5, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result2.Precision != "percentage" {
t.Errorf("expected percentage precision for unresolvable CFI, got %s", result2.Precision)
}
}
func TestExtractSurroundingText(t *testing.T) {
text := "Hello world, this is a test of the surrounding text extraction function."
runes := []rune(text)
tests := []struct {
offset int
window int
want string
}{
{30, 10, "a test of the surro"},
{0, 5, "Hello"},
{len(runes) - 1, 5, "ction."},
{15, 100, text},
}
for _, tt := range tests {
node := &html.Node{Type: html.TextNode, Data: text}
got := extractSurroundingText(node, tt.offset, tt.window)
if got != tt.want {
t.Errorf("extractSurroundingText(offset=%d, window=%d) = %q, want %q", tt.offset, tt.window, got, tt.want)
}
}
}
func TestKEPUBMultipleParagraphsRoundTrip(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, _ := epubOnly.getContentDoc(1)
epubBody := findBody(epubDoc)
type testCase struct {
name string
searchText string
percentage float64
}
cases := []testCase{
{"first paragraph", "It was the best of times", 0.01},
{"second paragraph", "Pack my box with five dozen", 0.15},
{"third paragraph", "Far far away, behind the word mountains", 0.25},
{"fourth paragraph", "A wonderful serenity has taken possession", 0.55},
{"fifth paragraph", "I should be incapable of drawing", 0.80},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
text := normalizeWhitespace(tc.searchText)
epubNode, epubOffset := findTextInNode(epubBody, text)
if epubNode == nil {
t.Fatalf("could not find %q in epub", tc.searchText)
}
standardCFI, _ := buildCFI(0, epubNode, epubOffset)
kepubResult, err := converter.ConvertStandardCFIToKEPUB(standardCFI, tc.percentage, text)
if err != nil {
t.Fatalf("standard→KEPUB: %v", err)
}
backResult, err := converter.ConvertKEPUBCFIToStandard(kepubResult.CFI, tc.percentage, text)
if err != nil {
t.Fatalf("KEPUB→standard: %v", err)
}
if backResult.Precision != "exact" {
t.Errorf("round-trip precision = %s, want exact (CFI: %s)", backResult.Precision, backResult.CFI)
}
_, origSteps, _ := parseEPUBCFI(standardCFI)
_, rtSteps, _ := parseEPUBCFI(backResult.CFI)
origNode, _, _ := resolveCFIToNode(epubDoc, origSteps)
rtNode, _, _ := resolveCFIToNode(epubDoc, rtSteps)
if origNode != rtNode {
t.Errorf("round-trip resolved to different nodes:\n orig: %q\n rt: %q",
truncateText(origNode.Data, 40),
truncateText(rtNode.Data, 40))
}
})
}
}
func TestKEPUBCFIsDifferFromStandard(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, _ := epubOnly.getContentDoc(1)
epubBody := findBody(epubDoc)
kepubOnly := NewCFIConverter(kepubPath)
kepubDoc, _, _ := kepubOnly.getContentDoc(1)
kepubBody := findBody(kepubDoc)
text := normalizeWhitespace("It was the best of times")
epubNode, epubOffset := findTextInNode(epubBody, text)
kepubNode, kepubOffset := findTextInNode(kepubBody, text)
if epubNode == nil || kepubNode == nil {
t.Fatal("could not find text in one or both docs")
}
epubCFI, _ := buildCFI(0, epubNode, epubOffset)
kepubCFI, _ := buildCFI(0, kepubNode, kepubOffset)
t.Logf("EPUB CFI: %s", epubCFI)
t.Logf("KEPUB CFI: %s", kepubCFI)
if epubCFI == kepubCFI {
t.Error("EPUB and KEPUB CFIs should differ due to koboSpan wrappers")
}
_, epubSteps, _ := parseEPUBCFI(epubCFI)
_, kepubSteps, _ := parseEPUBCFI(kepubCFI)
if len(kepubSteps) <= len(epubSteps) {
t.Errorf("KEPUB CFI should have more steps than EPUB CFI (koboSpan adds nesting): epub=%d kepub=%d",
len(epubSteps), len(kepubSteps))
}
}
func truncateText(s string, maxRunes int) string {
runes := []rune(s)
if len(runes) <= maxRunes {
return s
}
return string(runes[:maxRunes]) + "..."
}
func TestKEPUBChapterSpineIndices(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
epubOnly := NewCFIConverter(epubPath)
kepubOnly := NewCFIConverter(kepubPath)
epubSpine, err := epubOnly.loadSpine()
if err != nil {
t.Fatalf("load epub spine: %v", err)
}
kepubSpine, err := kepubOnly.loadSpine()
if err != nil {
t.Fatalf("load kepub spine: %v", err)
}
if len(epubSpine.items) != len(kepubSpine.items) {
t.Errorf("spine count mismatch: epub=%d kepub=%d", len(epubSpine.items), len(kepubSpine.items))
}
for i := range epubSpine.items {
if epubSpine.items[i].href != kepubSpine.items[i].href {
t.Errorf("spine item %d href mismatch: epub=%s kepub=%s",
i, epubSpine.items[i].href, kepubSpine.items[i].href)
}
}
t.Logf("Both files have %d spine items, matching correctly", len(epubSpine.items))
}
func TestKEPUBRealBookConvert(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Charles Dickens/A Tale of Two Cities (111)/A Tale of Two Cities - Charles Dickens.epub"
if _, err := os.Stat(epubPath); err != nil {
t.Skipf("EPUB not found: %s", epubPath)
}
dir := t.TempDir()
kepubPath := filepath.Join(dir, "test.kepub.epub")
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub doc: %v", err)
}
epubBody := findBody(epubDoc)
if epubBody == nil {
t.Fatal("no body")
}
createMinimalKEPUBFromEPUB(t, epubPath, kepubPath)
kepubOnly := NewCFIConverter(kepubPath)
kepubDoc, _, err := kepubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get kepub doc: %v", err)
}
kepubBody := findBody(kepubDoc)
searchText := normalizeWhitespace("It was the best of times")
epubNode, epubOffset := findTextInNode(epubBody, searchText)
if epubNode == nil {
t.Skip("phrase not found in epub")
}
standardCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build standard CFI: %v", err)
}
t.Logf("Standard CFI: %s", standardCFI)
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
if kepubNode == nil {
t.Skip("phrase not found in kepub")
}
kepubCFI, _ := buildCFI(0, kepubNode, kepubOffset)
t.Logf("KEPUB CFI: %s", kepubCFI)
if standardCFI == kepubCFI {
t.Log("Note: CFIs are same (koboSpan wrappers may not have changed structure in this position)")
}
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.01, searchText)
if err != nil {
t.Fatalf("ConvertKEPUBCFIToStandard: %v", err)
}
t.Logf("Converted: CFI=%s precision=%s", result.CFI, result.Precision)
if result.Precision != "exact" {
t.Errorf("expected exact precision, got %s", result.Precision)
}
}
func createMinimalKEPUBFromEPUB(t *testing.T, epubPath, kepubPath string) {
t.Helper()
r, err := zip.OpenReader(epubPath)
if err != nil {
t.Fatalf("open epub: %v", err)
}
defer r.Close()
f, err := os.Create(kepubPath)
if err != nil {
t.Fatalf("create kepub: %v", err)
}
defer f.Close()
w := zip.NewWriter(f)
for _, file := range r.File {
rc, err := file.Open()
if err != nil {
t.Fatalf("open %s: %v", file.Name, err)
}
fw, err := w.Create(file.Name)
if err != nil {
rc.Close()
t.Fatalf("create %s: %v", file.Name, err)
}
if isXHTML(file.Name) {
var buf []byte
buf, err = readZipFileData(rc)
if err != nil {
rc.Close()
t.Fatalf("read %s: %v", file.Name, err)
}
kepubContent := addKoboSpans(string(buf))
fw.Write([]byte(kepubContent))
} else {
buf := make([]byte, 4096)
for {
n, err := rc.Read(buf)
if n > 0 {
fw.Write(buf[:n])
}
if err != nil {
break
}
}
}
rc.Close()
}
if err := w.Close(); err != nil {
t.Fatalf("close kepub writer: %v", err)
}
}
func isXHTML(name string) bool {
return strings.HasSuffix(strings.ToLower(name), ".xhtml") ||
strings.HasSuffix(strings.ToLower(name), ".html") ||
strings.HasSuffix(strings.ToLower(name), ".htm")
}
func addKoboSpans(xhtmlContent string) string {
result := xhtmlContent
result = strings.ReplaceAll(result, "</p>", "</span></p>")
result = strings.ReplaceAll(result, "<p>", "<p><span class=\"koboSpan\">")
return result
}
func readZipFileData(rc io.ReadCloser) ([]byte, error) {
defer rc.Close()
return io.ReadAll(rc)
}
var _ = io.ReadAll
+133
View File
@@ -0,0 +1,133 @@
package sync
import "log"
type LocatorSource string
const (
LocatorSourceKOReader LocatorSource = "koreader"
LocatorSourceKobo LocatorSource = "kobo"
LocatorSourceWeb LocatorSource = "web"
)
type CanonicalLocator struct {
CFI string
Precision string
Percentage float64
}
type DeviceLocator struct {
Position string
Precision string
Percentage float64
}
func isConvertible(formatGroup string) bool {
return formatGroup == string(FormatGroupReflowable)
}
func ConvertToCanonical(
source LocatorSource,
devicePos string,
percentage float64,
contextText string,
formatGroup string,
epubPath string,
kepubPath string,
) CanonicalLocator {
if !isConvertible(formatGroup) || epubPath == "" {
return CanonicalLocator{
CFI: devicePos,
Precision: "passthrough",
Percentage: percentage,
}
}
switch source {
case LocatorSourceKOReader:
if !IsCREXPointer(devicePos) {
return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage}
}
converter := NewCFIConverter(epubPath)
result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText)
if err != nil || result == nil {
log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err)
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
}
if result.EPUBCFI != "" {
return CanonicalLocator{CFI: result.EPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
}
if result.Href != "" {
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
}
return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage}
case LocatorSourceKobo:
if kepubPath == "" {
return CanonicalLocator{CFI: devicePos, Precision: "no-kepub", Percentage: percentage}
}
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
result, err := converter.ConvertKEPUBCFIToStandard(devicePos, percentage, contextText)
if err != nil || result == nil {
log.Printf("Bookhoard: locator KEPUB→CFI conversion failed: %v", err)
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
}
if result.CFI != "" {
return CanonicalLocator{CFI: result.CFI, Precision: result.Precision, Percentage: result.Percentage}
}
return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage}
default:
return CanonicalLocator{CFI: devicePos, Precision: "passthrough", Percentage: percentage}
}
}
func ConvertFromCanonical(
source LocatorSource,
canonicalCFI string,
percentage float64,
contextText string,
formatGroup string,
epubPath string,
kepubPath string,
) DeviceLocator {
if !isConvertible(formatGroup) || epubPath == "" || canonicalCFI == "" {
return DeviceLocator{
Position: canonicalCFI,
Precision: "passthrough",
Percentage: percentage,
}
}
switch source {
case LocatorSourceKOReader:
converter := NewCFIConverter(epubPath)
result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText)
if err != nil || result == nil {
log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err)
return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage}
}
if result.XPointer != "" {
return DeviceLocator{Position: result.XPointer, Precision: result.Precision, Percentage: result.Percentage}
}
return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage}
case LocatorSourceKobo:
if kepubPath == "" {
return DeviceLocator{Position: canonicalCFI, Precision: "no-kepub", Percentage: percentage}
}
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
result, err := converter.ConvertStandardCFIToKEPUB(canonicalCFI, percentage, contextText)
if err != nil || result == nil {
log.Printf("Bookhoard: locator CFI→KEPUB conversion failed: %v", err)
return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage}
}
if result.CFI != "" {
return DeviceLocator{Position: result.CFI, Precision: result.Precision, Percentage: result.Percentage}
}
return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage}
default:
return DeviceLocator{Position: canonicalCFI, Precision: "passthrough", Percentage: percentage}
}
}
+65 -35
View File
@@ -288,6 +288,7 @@ type SaveProgressRequest struct {
Percentage *float64 Percentage *float64
Epubcfi *string Epubcfi *string
ContextText *string
CharacterOffset *int64 CharacterOffset *int64
Chapter *int Chapter *int
ChapterProgress *float64 ChapterProgress *float64
@@ -334,6 +335,7 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
params.Percentage = existing.Percentage params.Percentage = existing.Percentage
params.CharacterOffset = existing.CharacterOffset params.CharacterOffset = existing.CharacterOffset
params.Epubcfi = existing.Epubcfi params.Epubcfi = existing.Epubcfi
params.ContextText = existing.ContextText
params.Chapter = existing.Chapter params.Chapter = existing.Chapter
params.ChapterProgress = existing.ChapterProgress params.ChapterProgress = existing.ChapterProgress
params.ViewportX = existing.ViewportX params.ViewportX = existing.ViewportX
@@ -355,6 +357,9 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
if req.Epubcfi != nil { if req.Epubcfi != nil {
params.Epubcfi = pgtype.Text{String: *req.Epubcfi, Valid: *req.Epubcfi != ""} params.Epubcfi = pgtype.Text{String: *req.Epubcfi, Valid: *req.Epubcfi != ""}
} }
if req.ContextText != nil {
params.ContextText = pgtype.Text{String: *req.ContextText, Valid: *req.ContextText != ""}
}
if req.CharacterOffset != nil { if req.CharacterOffset != nil {
params.CharacterOffset = pgtype.Int8{Int64: *req.CharacterOffset, Valid: true} params.CharacterOffset = pgtype.Int8{Int64: *req.CharacterOffset, Valid: true}
} }
@@ -395,21 +400,39 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
params.LastSyncDevice = pgtype.Text{String: req.Source, Valid: true} params.LastSyncDevice = pgtype.Text{String: req.Source, Valid: true}
params.LastSyncSource = pgtype.Text{String: req.Source, Valid: true} params.LastSyncSource = pgtype.Text{String: req.Source, Valid: true}
if params.Percentage.Valid && !params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 { formatGroup := FormatGroup(mediaItem.FormatGroup)
charOff := PercentageToCharacter(params.Percentage.Float64, mediaItem.TotalCharacters.Int64) isFixed := formatGroup == FormatGroupFixedLayout || formatGroup == FormatGroupComicArchive
params.CharacterOffset = pgtype.Int8{Int64: charOff, Valid: true} if isFixed {
} // Fixed-layout & comic formats: the page index is the canonical locator.
if params.Percentage.Valid && !params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 { // CFI/character-offset are meaningless for image-based content, so clear
page := PercentageToPage(params.Percentage.Float64, int(params.TotalPages.Int32)) // any stale value that may have been stored by the web reader (which
params.CurrentPage = pgtype.Int4{Int32: int32(page), Valid: true} // generates fake CFIs for comics).
} params.Epubcfi = pgtype.Text{}
if params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 && !params.Percentage.Valid { params.CharacterOffset = pgtype.Int8{}
pct := PageToPercentage(int(params.CurrentPage.Int32), int(params.TotalPages.Int32)) if params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 {
params.Percentage = pgtype.Float8{Float64: pct, Valid: true} pct := PageToPercentage(int(params.CurrentPage.Int32), int(params.TotalPages.Int32))
} params.Percentage = pgtype.Float8{Float64: pct, Valid: true}
if params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 && !params.Percentage.Valid { } else if params.Percentage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 && !params.CurrentPage.Valid {
pct := CharacterToPercentage(params.CharacterOffset.Int64, mediaItem.TotalCharacters.Int64) page := PercentageToPage(params.Percentage.Float64, int(params.TotalPages.Int32))
params.Percentage = pgtype.Float8{Float64: pct, Valid: true} params.CurrentPage = pgtype.Int4{Int32: int32(page), Valid: true}
}
} else {
if params.Percentage.Valid && !params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 {
charOff := PercentageToCharacter(params.Percentage.Float64, mediaItem.TotalCharacters.Int64)
params.CharacterOffset = pgtype.Int8{Int64: charOff, Valid: true}
}
if params.Percentage.Valid && !params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 {
page := PercentageToPage(params.Percentage.Float64, int(params.TotalPages.Int32))
params.CurrentPage = pgtype.Int4{Int32: int32(page), Valid: true}
}
if params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 && !params.Percentage.Valid {
pct := PageToPercentage(int(params.CurrentPage.Int32), int(params.TotalPages.Int32))
params.Percentage = pgtype.Float8{Float64: pct, Valid: true}
}
if params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 && !params.Percentage.Valid {
pct := CharacterToPercentage(params.CharacterOffset.Int64, mediaItem.TotalCharacters.Int64)
params.Percentage = pgtype.Float8{Float64: pct, Valid: true}
}
} }
conflictDetected := false conflictDetected := false
@@ -430,7 +453,13 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
diff = -diff diff = -diff
} }
if diff > 0.01 { if diff > 0.01 {
conflictDetected = true recentlyResolved, _ := s.db.HasRecentConflictResolution(ctx, database.HasRecentConflictResolutionParams{
MediaItemID: req.MediaItemID,
UserID: req.UserID,
})
if !recentlyResolved {
conflictDetected = true
}
} }
} }
} }
@@ -442,26 +471,24 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
} }
if conflictDetected { if conflictDetected {
newData := map[string]interface{}{
"source": req.Source,
"timestamp": time.Now().Format(time.RFC3339),
"data": buildProgressSnapshot(req),
}
existingData := map[string]interface{}{
"source": existing.LastSyncSource.String,
"timestamp": existing.LastSyncTimestamp.Time.Format(time.RFC3339),
"data": map[string]interface{}{
"percentage": float64Ptr(existing.Percentage),
"epubcfi": textPtr(existing.Epubcfi),
"chapter": int32Ptr(existing.Chapter),
"character": int64Ptr(existing.CharacterOffset),
"page": int32Ptr(existing.CurrentPage),
"total_pages": int32Ptr(existing.TotalPages),
},
}
conflictData := map[string]interface{}{ conflictData := map[string]interface{}{
"new": newData, req.Source: map[string]interface{}{
"existing": existingData, "source": req.Source,
"timestamp": time.Now().Format(time.RFC3339),
"data": buildProgressSnapshot(req),
},
existing.LastSyncSource.String: map[string]interface{}{
"source": existing.LastSyncSource.String,
"timestamp": existing.LastSyncTimestamp.Time.Format(time.RFC3339),
"data": map[string]interface{}{
"percentage": float64Ptr(existing.Percentage),
"epubcfi": textPtr(existing.Epubcfi),
"chapter": int32Ptr(existing.Chapter),
"character": int64Ptr(existing.CharacterOffset),
"page": int32Ptr(existing.CurrentPage),
"total_pages": int32Ptr(existing.TotalPages),
},
},
} }
conflictJSON, _ := json.Marshal(conflictData) conflictJSON, _ := json.Marshal(conflictData)
_, err := s.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{ _, err := s.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
@@ -508,6 +535,9 @@ func buildProgressSnapshot(req SaveProgressRequest) map[string]interface{} {
if req.Epubcfi != nil { if req.Epubcfi != nil {
data["epubcfi"] = *req.Epubcfi data["epubcfi"] = *req.Epubcfi
} }
if req.ContextText != nil {
data["context_text"] = *req.ContextText
}
if req.Chapter != nil { if req.Chapter != nil {
data["chapter"] = *req.Chapter data["chapter"] = *req.Chapter
} }
+242 -7
View File
@@ -35,11 +35,12 @@ const (
) )
type SyncQueueProcessor struct { type SyncQueueProcessor struct {
db *database.Queries db *database.Queries
progressSvc *ProgressService progressSvc *ProgressService
progressChan chan *ProgressUpdate annotationSvc *AnnotationService
interval time.Duration progressChan chan *ProgressUpdate
batchSize int interval time.Duration
batchSize int
} }
type ProgressUpdate struct { type ProgressUpdate struct {
@@ -48,6 +49,7 @@ type ProgressUpdate struct {
UserID pgtype.UUID UserID pgtype.UUID
Percentage float64 Percentage float64
Epubcfi *string Epubcfi *string
ContextText *string
Chapter *int Chapter *int
Character *int64 Character *int64
Page *int Page *int
@@ -84,6 +86,10 @@ func (p *SyncQueueProcessor) SetProgressService(svc *ProgressService) {
p.progressSvc = svc p.progressSvc = svc
} }
func (p *SyncQueueProcessor) SetAnnotationService(svc *AnnotationService) {
p.annotationSvc = svc
}
func (p *SyncQueueProcessor) Start(ctx context.Context) { func (p *SyncQueueProcessor) Start(ctx context.Context) {
log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize) log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize)
@@ -112,6 +118,100 @@ func (p *SyncQueueProcessor) EnqueueProgress(update *ProgressUpdate) error {
} }
} }
type HighlightUpdate struct {
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
SelectionText string
StartPosition string
EndPosition string
Color string
NoteText string
EpubcfiStart string
EpubcfiEnd string
PercentageStart float64
PercentageEnd float64
Source string
DeviceSyncData map[string]interface{}
}
type NoteUpdate struct {
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
Content string
Position string
Source string
DeviceSyncData map[string]interface{}
}
type BookmarkUpdate struct {
DeviceID pgtype.UUID
MediaItemID pgtype.UUID
UserID pgtype.UUID
Title string
Position string
Notes string
Source string
DeviceSyncData map[string]interface{}
}
func (p *SyncQueueProcessor) EnqueueHighlight(ctx context.Context, update *HighlightUpdate) error {
syncData := map[string]interface{}{
"selection_text": update.SelectionText,
"start_position": update.StartPosition,
"end_position": update.EndPosition,
"color": update.Color,
"note_text": update.NoteText,
"source": update.Source,
"epubcfi_start": update.EpubcfiStart,
"epubcfi_end": update.EpubcfiEnd,
"percentage_start": update.PercentageStart,
"percentage_end": update.PercentageEnd,
"device_sync_data": update.DeviceSyncData,
}
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeHighlight, syncData)
}
func (p *SyncQueueProcessor) EnqueueNote(ctx context.Context, update *NoteUpdate) error {
syncData := map[string]interface{}{
"content": update.Content,
"position": update.Position,
"source": update.Source,
"device_sync_data": update.DeviceSyncData,
}
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeNote, syncData)
}
func (p *SyncQueueProcessor) EnqueueBookmark(ctx context.Context, update *BookmarkUpdate) error {
syncData := map[string]interface{}{
"title": update.Title,
"position": update.Position,
"notes": update.Notes,
"source": update.Source,
"device_sync_data": update.DeviceSyncData,
}
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeBookmark, syncData)
}
func (p *SyncQueueProcessor) enqueueAnnotation(ctx context.Context, deviceID, mediaItemID pgtype.UUID, syncType string, syncData map[string]interface{}) error {
syncDataJSON, err := json.Marshal(syncData)
if err != nil {
return fmt.Errorf("marshal sync data: %w", err)
}
_, err = p.db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
DeviceID: deviceID,
MediaItemID: mediaItemID,
SyncType: syncType,
SyncData: syncDataJSON,
Priority: pgtype.Int4{Int32: int32(PriorityCriticalNote), Valid: true},
MaxAttempts: pgtype.Int4{Int32: 3, Valid: true},
Status: pgtype.Text{String: SyncStatusPending, Valid: true},
})
return err
}
func (p *SyncQueueProcessor) enqueueProgressUpdate(ctx context.Context, update *ProgressUpdate) { func (p *SyncQueueProcessor) enqueueProgressUpdate(ctx context.Context, update *ProgressUpdate) {
syncData := map[string]interface{}{ syncData := map[string]interface{}{
"percentage": update.Percentage, "percentage": update.Percentage,
@@ -122,6 +222,9 @@ func (p *SyncQueueProcessor) enqueueProgressUpdate(ctx context.Context, update *
if update.Epubcfi != nil { if update.Epubcfi != nil {
syncData["epubcfi"] = *update.Epubcfi syncData["epubcfi"] = *update.Epubcfi
} }
if update.ContextText != nil {
syncData["context_text"] = *update.ContextText
}
if update.Chapter != nil { if update.Chapter != nil {
syncData["chapter"] = *update.Chapter syncData["chapter"] = *update.Chapter
} }
@@ -304,6 +407,8 @@ func (p *SyncQueueProcessor) executeSync(ctx context.Context, item SyncQueueItem
return p.syncNote(ctx, device.UserID, item.MediaItemID, syncData) return p.syncNote(ctx, device.UserID, item.MediaItemID, syncData)
case SyncTypeHighlight: case SyncTypeHighlight:
return p.syncHighlight(ctx, device.UserID, item.MediaItemID, syncData) return p.syncHighlight(ctx, device.UserID, item.MediaItemID, syncData)
case SyncTypeBookmark:
return p.syncBookmark(ctx, device.UserID, item.MediaItemID, syncData)
default: default:
return fmt.Errorf("unsupported sync type: %s", item.SyncType) return fmt.Errorf("unsupported sync type: %s", item.SyncType)
} }
@@ -331,6 +436,9 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
if v, ok := syncData["epubcfi"].(string); ok { if v, ok := syncData["epubcfi"].(string); ok {
req.Epubcfi = &v req.Epubcfi = &v
} }
if v, ok := syncData["context_text"].(string); ok {
req.ContextText = &v
}
if v, ok := syncData["chapter"].(float64); ok { if v, ok := syncData["chapter"].(float64); ok {
ch := int(v) ch := int(v)
req.Chapter = &ch req.Chapter = &ch
@@ -400,11 +508,138 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
} }
func (p *SyncQueueProcessor) syncNote(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error { func (p *SyncQueueProcessor) syncNote(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
return fmt.Errorf("note sync not yet implemented") if p.annotationSvc == nil {
return fmt.Errorf("annotation service not available")
}
req := SaveNoteRequest{
MediaItemID: mediaItemID,
UserID: userID,
}
if v, ok := syncData["content"].(string); ok {
req.Content = v
}
if v, ok := syncData["position"].(string); ok {
req.Position = v
}
if v, ok := syncData["source"].(string); ok {
req.Source = v
}
if v, ok := syncData["epubcfi_location"].(string); ok {
req.EpubcfiLocation = v
}
if v, ok := syncData["percentage_location"].(float64); ok {
req.PercentageLocation = v
}
if v, ok := syncData["chapter_reference"].(float64); ok {
req.ChapterReference = int32(v)
}
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
req.DeviceSyncData, _ = json.Marshal(v)
}
_, err := p.annotationSvc.SaveNote(ctx, req)
return err
} }
func (p *SyncQueueProcessor) syncHighlight(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error { func (p *SyncQueueProcessor) syncHighlight(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
return fmt.Errorf("highlight sync not yet implemented") if p.annotationSvc == nil {
return fmt.Errorf("annotation service not available")
}
req := SaveHighlightRequest{
MediaItemID: mediaItemID,
UserID: userID,
}
if v, ok := syncData["selection_text"].(string); ok {
req.SelectionText = v
}
if v, ok := syncData["start_position"].(string); ok {
req.StartPosition = v
}
if v, ok := syncData["end_position"].(string); ok {
req.EndPosition = v
}
if v, ok := syncData["color"].(string); ok {
req.Color = v
}
if v, ok := syncData["note_text"].(string); ok {
req.NoteText = v
}
if v, ok := syncData["source"].(string); ok {
req.Source = v
}
if v, ok := syncData["epubcfi_start"].(string); ok {
req.EpubcfiStart = v
}
if v, ok := syncData["epubcfi_end"].(string); ok {
req.EpubcfiEnd = v
}
if v, ok := syncData["percentage_start"].(float64); ok {
req.PercentageStart = v
}
if v, ok := syncData["percentage_end"].(float64); ok {
req.PercentageEnd = v
}
if v, ok := syncData["chapter_reference"].(float64); ok {
req.ChapterReference = int32(v)
}
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
req.DeviceSyncData, _ = json.Marshal(v)
}
_, err := p.annotationSvc.SaveHighlight(ctx, req)
return err
}
func (p *SyncQueueProcessor) syncBookmark(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
if p.annotationSvc == nil {
return fmt.Errorf("annotation service not available")
}
req := SaveBookmarkRequest{
MediaItemID: mediaItemID,
UserID: userID,
}
if v, ok := syncData["title"].(string); ok {
req.Title = v
}
if v, ok := syncData["position"].(string); ok {
req.Position = v
}
if v, ok := syncData["notes"].(string); ok {
req.Notes = v
}
if v, ok := syncData["source"].(string); ok {
req.Source = v
}
if v, ok := syncData["cfi_position"].(string); ok {
req.CFIPosition = v
}
if v, ok := syncData["epubcfi_location"].(string); ok {
req.EpubcfiLocation = v
}
if v, ok := syncData["percentage_loc"].(float64); ok {
req.PercentageLoc = v
}
if v, ok := syncData["page_number"].(float64); ok {
req.PageNumber = int32(v)
}
if v, ok := syncData["chapter_number"].(float64); ok {
req.ChapterNumber = int32(v)
}
if v, ok := syncData["chapter_reference"].(float64); ok {
req.ChapterReference = int32(v)
}
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
req.DeviceSyncData, _ = json.Marshal(v)
}
_, err := p.annotationSvc.SaveBookmark(ctx, req)
return err
} }
func (p *SyncQueueProcessor) markItemFailed(ctx context.Context, item SyncQueueItem, errMsg string) { func (p *SyncQueueProcessor) markItemFailed(ctx context.Context, item SyncQueueItem, errMsg string) {
+6 -7
View File
@@ -4,14 +4,8 @@ import (
"sort" "sort"
"strings" "strings"
"unicode" "unicode"
"golang.org/x/text/cases"
"golang.org/x/text/language"
) )
// titleCaser is a global caser for titlecase conversion
var titleCaser = cases.Title(language.Und)
// titlecase converts a string to title case while preserving hyphenation and apostrophes // titlecase converts a string to title case while preserving hyphenation and apostrophes
// Example: "science fiction" → "Science Fiction", "non-fiction" → "Non-Fiction", "o'reilly" → "O'Reilly" // Example: "science fiction" → "Science Fiction", "non-fiction" → "Non-Fiction", "o'reilly" → "O'Reilly"
func titlecase(s string) string { func titlecase(s string) string {
@@ -34,7 +28,12 @@ func titlecase(s string) string {
} }
words[i] = result.String() words[i] = result.String()
} else { } else {
words[i] = titleCaser.String(word) runes := []rune(word)
runes[0] = unicode.ToUpper(runes[0])
for j := 1; j < len(runes); j++ {
runes[j] = unicode.ToLower(runes[j])
}
words[i] = string(runes)
} }
} }
return strings.Join(words, " ") return strings.Join(words, " ")
+4 -4
View File
@@ -5,14 +5,14 @@
"scripts": { "scripts": {
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch", "build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify", "build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && mkdir -p web/static/vendor/pdfjs && cp -r node_modules/@bookhoard/foliate-js/vendor/pdfjs/standard_fonts web/static/vendor/pdfjs/ && vite build", "build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build",
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && mkdir -p web/static/vendor/pdfjs && cp -r node_modules/@bookhoard/foliate-js/vendor/pdfjs/standard_fonts web/static/vendor/pdfjs/ && vite build --mode development", "build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --mode development",
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && mkdir -p web/static/vendor/pdfjs && cp -r node_modules/@bookhoard/foliate-js/vendor/pdfjs/standard_fonts web/static/vendor/pdfjs/ && vite build --watch", "build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --watch",
"build": "npm run build:ts && npm run build:css:prod", "build": "npm run build:ts && npm run build:css:prod",
"dev": "npm run build:ts:dev && npm run build:css" "dev": "npm run build:ts:dev && npm run build:css"
}, },
"dependencies": { "dependencies": {
"@bookhoard/foliate-js": "github:john-okeefe/foliate-js#74c317d58c2cedb53811da2973d0bf9bb6a70dcb", "@bookhoard/foliate-js": "github:john-okeefe/foliate-js#d164d6f",
"alpinejs": "^3.15.8", "alpinejs": "^3.15.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
@@ -1,276 +0,0 @@
# Convert Fish config.fish to Zsh .zshrc
## Objective
Translate all functional content from `~/.config/fish/config.fish` into equivalent Zsh syntax, append it **below** the existing Forge-managed block in `~/.config/zsh/.zshrc`, and leave the existing content untouched.
## Source Analysis
### Fish config breakdown (`~/.config/fish/config.fish`, 178 lines)
| Lines | Category | Notes |
|-------|----------|-------|
| 1 | Comment (collapsed) | Multi-line comment got collapsed into one line; contains `set fish_greeting`, `set VIRTUAL_ENV_DISABLE_PROMPT`, `set -x SHELL /usr/bin/fish` |
| 2-3 | Man pager (bat) | `set -xU` universal env vars |
| 5-6 | Paru pager | `set -x` exported env var |
| 8-10 | Done plugin settings | `set -U` universal vars for `done` notification plugin |
| 12-16 | Source `~/.fish_profile` | Fish-specific profile file |
| 18-23 | PATH: `~/.local/bin` | Already in .zshrc (line 2) |
| 25-30 | PATH: `depot_tools` | Conditional PATH prepend |
| 32-37 | Starship + zoxide + atuin | Interactive-only init |
| 39-40 | find-the-command hook | Fish-specific (`ftc.fish`) |
| 42-76 | Bang-bang + history functions | `!!` and `!$` support + history formatting |
| 78-80 | `backup` function | Simple `cp` backup |
| 83-92 | `copy` function | Smart `cp` with directory detection |
| 94-102 | `cleanup` function | Remove orphaned pacman packages |
| 104-163 | Aliases & abbreviations | Many aliases, some with Fish-specific syntax |
| 165-168 | Fastfetch on interactive | Run fastfetch if available |
| 170-172 | PATH: opencode + local bin | PATH additions (local bin already covered) |
| 174-178 | Qt/KDE theming | Conditional `QT_STYLE_OVERRIDE` |
### Existing .zshrc breakdown (`~/.config/zsh/.zshrc`, 48 lines)
Lines 1-47 are Forge-managed and must remain untouched. The new content should be appended **after line 48** (end of file).
## Implementation Plan
- [ ] **Step 1.** Append all converted content below the existing Forge block (after line 48). Do not modify any existing lines.
## Converted Zsh Code (to append after line 48 of `~/.config/zsh/.zshrc`)
```zsh
# ============================================================
# Converted from ~/.config/fish/config.fish
# ============================================================
# --- Environment Variables ---
# Hide welcome message (Zsh equivalent: set empty PS1 greeting or just skip)
VIRTUAL_ENV_DISABLE_PROMPT="1"
export VIRTUAL_ENV_DISABLE_PROMPT
# Use bat for man pages
export MANPAGER="sh -c 'col -bx | bat -l man -p'"
export MANROFFOPT="-c"
# Hint to exit PKGBUILD review in Paru
export PARU_PAGER="less -P \"Press 'q' to exit the PKGBUILD review.\""
# --- PATH Additions ---
# Add ~/.local/bin to PATH (already present via Forge, but kept for completeness)
if [[ -d ~/.local/bin ]]; then
case ":${PATH}:" in
*:"$HOME/.local/bin":*) ;;
*) export PATH="$HOME/.local/bin:$PATH" ;;
esac
fi
# Add depot_tools to PATH
if [[ -d ~/Applications/depot_tools ]]; then
case ":${PATH}:" in
*:"$HOME/Applications/depot_tools":*) ;;
*) export PATH="$HOME/Applications/depot_tools:$PATH" ;;
esac
fi
# Add opencode to PATH
if [[ -d ~/.opencode/bin ]]; then
case ":${PATH}:" in
*:"$HOME/.opencode/bin":*) ;;
*) export PATH="$HOME/.opencode/bin:$PATH" ;;
esac
fi
# --- Interactive Shell Setup ---
if [[ -o interactive ]]; then
# Zoxide (smart cd)
eval "$(zoxide init zsh)"
# Atuin (shell history)
eval "$(atuin init zsh)"
# Starship prompt
eval "$(starship init zsh)"
fi
# --- Bang-Bang Support (!!) ---
# Zsh has built-in bang-bang via `setopt bang_hist` (default on).
# Enable history expansion:
setopt bang_hist
setopt hist_expand
# --- History Settings ---
HISTFILE=~/.config/zsh/.zsh_history
HISTSIZE=10000
SAVEHIST=10000
setopt extended_history # Record timestamps (equivalent to --show-time='%F %T ')
setopt share_history # Share history across sessions
setopt hist_ignore_all_dups # Remove older duplicate entries
setopt hist_ignore_space # Ignore commands starting with space
setopt hist_save_no_dups # Don't save duplicates
# --- Functions ---
# Create a backup of a file
backup() {
cp "$1" "${1}.bak"
}
# Smart copy: if source is a directory and exactly 2 args, copy recursively
copy() {
if [[ $# -eq 2 && -d "$1" ]]; then
command cp -r "${1%/}" "$2"
else
command cp "$@"
fi
}
# Cleanup orphaned packages
cleanup() {
local orphans
orphans=$(pacman -Qdtq 2>/dev/null)
while [[ -n "$orphans" ]]; do
sudo pacman -R $orphans || break
orphans=$(pacman -Qdtq 2>/dev/null)
done
}
# --- Aliases ---
# Replace ls with eza
alias ls='eza -al --color=always --group-directories-first --icons'
alias lsz='eza -al --color=always --total-size --group-directories-first --icons'
alias la='eza -a --color=always --group-directories-first --icons'
alias ll='eza -l --color=always --group-directories-first --icons'
alias lt='eza -aT --color=always --group-directories-first --icons'
alias l.='eza -ald --color=always --group-directories-first --icons .*'
# Replace cat with bat
alias cat='bat --style header,snip,changes'
# Use paru as yay if yay is not installed
if ! command -v yay &>/dev/null && command -v paru &>/dev/null; then
alias yay='paru'
fi
# Directory navigation
alias ..='cd ..'
alias ...='cd ../..'
alias ....='cd ../../..'
alias .....='cd ../../../..'
alias ......='cd ../../../../..'
# Package management
alias big='expac -H M "%m\t%n" | sort -h | nl'
alias fixpacman='sudo rm /var/lib/pacman/db.lck'
alias gitpkg='pacman -Q | grep -i "\-git" | wc -l'
alias rmpkg='sudo pacman -Rdd'
alias upd='/usr/bin/garuda-update'
# Grep replacements (ugrep)
alias grep='ugrep --color=auto'
alias egrep='ugrep -E --color=auto'
alias fgrep='ugrep -F --color=auto'
# Tar
alias tarnow='tar -acf '
alias untar='tar -zxvf '
# Misc
alias dir='dir --color=auto'
alias grubup='sudo update-grub'
alias hw='hwinfo --short'
alias ip='ip -color'
alias psmem='ps auxf | sort -nr -k 4'
alias psmem10='ps auxf | sort -nr -k 4 | head -10'
alias vdir='vdir --color=auto'
alias wget='wget -c '
# Mirror management (reflector)
alias mirror='sudo reflector -f 30 -l 30 --number 10 --verbose --save /etc/pacman.d/mirrorlist'
alias mirrora='sudo reflector --latest 50 --number 20 --sort age --save /etc/pacman.d/mirrorlist'
alias mirrord='sudo reflector --latest 50 --number 20 --sort delay --save /etc/pacman.d/mirrorlist'
alias mirrors='sudo reflector --latest 50 --number 20 --sort score --save /etc/pacman.d/mirrorlist'
# Help for newcomers to Arch
alias apt='man pacman'
alias apt-get='man pacman'
alias please='sudo'
alias tb='nc termbin.com 9999'
alias helpme='echo "To print basic information about a command use tldr <command>"'
alias pacdiff='sudo -H DIFFPROG=meld pacdiff'
# Journalctl errors
alias jctl='journalctl -p 3 -xb'
# Recent installed packages
alias rip='expac --timefmt="%Y-%m-%d %T" "%l\t%n %v" | sort | tail -200 | nl'
# --- Run fastfetch if session is interactive ---
if [[ -o interactive ]] && command -v fastfetch &>/dev/null; then
fastfetch --config dr460nized.jsonc
fi
# --- Qt/KDE Theming ---
# Set for non-Plasma sessions (Hyprland, etc.)
if [[ -z "$XDG_CURRENT_DESKTOP" ]] || [[ "$XDG_CURRENT_DESKTOP" != "KDE" ]]; then
export QT_STYLE_OVERRIDE=kvantum
fi
```
## Items Intentionally Excluded
| Fish Line(s) | Item | Reason for Exclusion |
|---|---|---|
| 1 | `set -x SHELL /usr/bin/fish` | Fish-specific; should be `SHELL=/usr/bin/zsh` if anything, but zsh sets this automatically |
| 1 | `set fish_greeting` | Fish-specific; Zsh has no equivalent greeting mechanism |
| 8-10 | `__done_*` settings | These are Fish `done` plugin variables; Zsh equivalent would need a different notification plugin |
| 12-16 | Source `~/.fish_profile` | Fish-specific profile file; if needed, convert to `~/.zprofile` or source a zsh-compatible profile |
| 39-40 | `source /usr/share/doc/find-the-command/ftc.fish` | Fish-specific command-not-found hook; Zsh has `command-not-found-handler` or uses pkgfile |
| 42-76 | `__history_previous_command` / `__history_previous_command_arguments` + bind | Fish-specific bang-bang plugin; Zsh has built-in `!!` and `!$` via `setopt bang_hist hist_expand` |
## Verification Criteria
- [ ] All aliases work: open a new zsh shell and run `alias` to confirm they are loaded
- [ ] `eza` aliases produce colored output with icons
- [ ] `bat` alias shows headers/snippets
- [ ] `backup testfile.txt` creates `testfile.txt.bak`
- [ ] `copy dir1 dir2` copies recursively; `copy file1 file2` copies normally
- [ ] `cleanup` removes orphaned pacman packages without error
- [ ] `fastfetch` runs on interactive shell launch
- [ ] `QT_STYLE_OVERRIDE=kvantum` is set when not in KDE
- [ ] PATH includes `~/.local/bin`, `~/Applications/depot_tools`, and `~/.opencode/bin` (when dirs exist)
- [ ] `zoxide`, `atuin`, and `starship` initialize in interactive sessions
- [ ] Existing Forge-managed block (lines 1-47) is completely untouched
- [ ] History expansion works: try `!!` to repeat last command
## Potential Risks and Mitigations
1. **Alias name conflicts with existing commands**
Mitigation: All aliases use the same names as the Fish config, which was already working. The `cat` alias overriding `cat` with `bat` is intentional but could break scripts that call `cat` and expect raw output. Use `\cat` or `command cat` when needed.
2. **Forge-managed block interference**
Mitigation: The Forge block already sets up `compinit`, zsh-autosuggestions, zsh-syntax-highlighting, and a prompt theme. The converted Starship init will override the Forge prompt theme. If you want to keep the Forge prompt, remove the `eval "$(starship init zsh)"` line.
3. **`cleanup` function subshell variable scope**
Mitigation: The Fish version used a while loop with `$status`. The Zsh version captures orphan list in a variable and uses the `-n` test instead, which is more reliable.
4. **`garuda-update` alias may not exist**
Mitigation: The alias is a direct path reference. If the binary doesn't exist, the alias simply won't work when invoked — same behavior as Fish.
5. **Fastfetch config path**
Mitigation: `fastfetch --config dr460nized.jsonc` uses a relative config name. Ensure the config file is discoverable by fastfetch (typically in `~/.config/fastfetch/`).
## Alternative Approaches
1. **Use a framework (oh-my-zsh / zinit)**: Instead of manual conversion, use a Zsh framework that provides bang-bang, aliases, and plugin management out of the box. Trade-off: adds framework dependency and complexity.
2. **Use `babelfish` or `fish2zsh` tools**: Automated Fish-to-Zsh converters exist but may not handle all Fish-specific constructs correctly. Trade-off: faster but less reliable for edge cases.
3. **Source a separate file**: Instead of appending to `.zshrc`, place converted content in `~/.config/zsh/.zshrc.fish-converted` and source it from `.zshrc`. Trade-off: cleaner separation but adds an extra file to manage.
-279
View File
@@ -1,279 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Series Cover Layout Options</title>
<style>
body {
font-family: system-ui, sans-serif;
background: #1a1b26;
color: #c0caf5;
padding: 40px;
max-width: 1200px;
margin: 0 auto;
}
h1 { margin-bottom: 10px; }
.subtitle { color: #9aa5ce; margin-bottom: 40px; }
.options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
}
.option {
background: #24283b;
border-radius: 12px;
padding: 24px;
text-align: center;
}
.option h2 { margin: 0 0 8px 0; font-size: 1.1rem; color: #7aa2f7; }
.option .desc { color: #9aa5ce; font-size: 0.85rem; margin-bottom: 20px; }
/* === Common book cover placeholder === */
.cover {
width: 80px;
height: 120px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
background: linear-gradient(135deg, #414868, #1a1b26);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: #565f89;
font-weight: bold;
flex-shrink: 0;
}
/* ============================================================
OPTION A: Stacked Cascade (pile of books, top-left offset)
============================================================ */
.stacked-cascade {
position: relative;
width: 260px;
height: 200px;
margin: 0 auto;
}
.stacked-cascade .cover {
position: absolute;
}
.stacked-cascade .cover:nth-child(1) { top: 0; left: 0; z-index: 7; }
.stacked-cascade .cover:nth-child(2) { top: 8px; left: 8px; z-index: 6; opacity: 0.92; }
.stacked-cascade .cover:nth-child(3) { top: 16px; left: 16px; z-index: 5; opacity: 0.85; }
.stacked-cascade .cover:nth-child(4) { top: 24px; left: 24px; z-index: 4; opacity: 0.78; }
.stacked-cascade .cover:nth-child(5) { top: 32px; left: 32px; z-index: 3; opacity: 0.7; }
.stacked-cascade .cover:nth-child(6) { top: 40px; left: 40px; z-index: 2; opacity: 0.65; }
.stacked-cascade .cover:nth-child(7) { top: 48px; left: 48px; z-index: 1; opacity: 0.6; }
.stacked-cascade:hover .cover:nth-child(1) { top: 0; left: 0; }
.stacked-cascade:hover .cover:nth-child(2) { top: 6px; left: 12px; }
.stacked-cascade:hover .cover:nth-child(3) { top: 14px; left: 24px; }
.stacked-cascade:hover .cover:nth-child(4) { top: 22px; left: 36px; }
.stacked-cascade:hover .cover:nth-child(5) { top: 30px; left: 50px; }
.stacked-cascade:hover .cover:nth-child(6) { top: 40px; left: 64px; }
.stacked-cascade:hover .cover:nth-child(7) { top: 50px; left: 78px; }
.stacked-cascade .cover { transition: all 0.3s ease; }
/* ============================================================
OPTION B: Grid Mosaic (mini bookshelf grid)
============================================================ */
.grid-mosaic {
width: 200px;
height: 200px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 4px;
padding: 8px;
background: #1a1b26;
border-radius: 8px;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.3);
}
.grid-mosaic .cover {
width: 100%;
height: 100%;
border-radius: 3px;
font-size: 9px;
}
.grid-mosaic .cover.spacer {
background: transparent;
box-shadow: none;
}
/* ============================================================
OPTION C: Fanned Bottom (arc from bottom center, upward)
============================================================ */
.fanned-bottom {
position: relative;
width: 280px;
height: 200px;
margin: 0 auto;
}
.fanned-bottom .cover {
position: absolute;
bottom: 0;
left: 50%;
margin-left: -40px; /* half cover width */
transform-origin: bottom center;
}
.fanned-bottom .cover:nth-child(1) { z-index: 7; transform: rotate(0deg); }
.fanned-bottom .cover:nth-child(2) { z-index: 6; transform: rotate(-12deg); opacity: 0.92; }
.fanned-bottom .cover:nth-child(3) { z-index: 5; transform: rotate(12deg); opacity: 0.92; }
.fanned-bottom .cover:nth-child(4) { z-index: 4; transform: rotate(-24deg); opacity: 0.82; }
.fanned-bottom .cover:nth-child(5) { z-index: 3; transform: rotate(24deg); opacity: 0.82; }
.fanned-bottom .cover:nth-child(6) { z-index: 2; transform: rotate(-36deg); opacity: 0.72; }
.fanned-bottom .cover:nth-child(7) { z-index: 1; transform: rotate(36deg); opacity: 0.72; }
.fanned-bottom:hover .cover:nth-child(1) { transform: rotate(0deg); }
.fanned-bottom:hover .cover:nth-child(2) { transform: rotate(-15deg); }
.fanned-bottom:hover .cover:nth-child(3) { transform: rotate(15deg); }
.fanned-bottom:hover .cover:nth-child(4) { transform: rotate(-30deg); }
.fanned-bottom:hover .cover:nth-child(5) { transform: rotate(30deg); }
.fanned-bottom:hover .cover:nth-child(6) { transform: rotate(-42deg); }
.fanned-bottom:hover .cover:nth-child(7) { transform: rotate(42deg); }
.fanned-bottom .cover { transition: all 0.3s ease; }
/* ============================================================
OPTION D: Perspective Shelf (3D standing books)
============================================================ */
.perspective-shelf {
width: 280px;
height: 200px;
margin: 0 auto;
perspective: 600px;
display: flex;
align-items: flex-end;
justify-content: center;
gap: 3px;
padding-bottom: 20px;
}
.perspective-shelf .cover {
transform: rotateY(-15deg);
transition: transform 0.3s ease;
}
.perspective-shelf:hover .cover {
transform: rotateY(0deg);
}
.perspective-shelf .cover:nth-child(odd) {
transform: rotateY(-18deg) translateZ(2px);
}
.perspective-shelf .cover:nth-child(even) {
transform: rotateY(-12deg) translateZ(-1px);
}
.perspective-shelf:hover .cover:nth-child(odd) {
transform: rotateY(-5deg) translateZ(4px);
}
.perspective-shelf:hover .cover:nth-child(even) {
transform: rotateY(-5deg) translateZ(2px);
}
/* === Demo colors for covers === */
.c1 { background: linear-gradient(135deg, #f7768e, #c53b53) !important; color: #fff !important; }
.c2 { background: linear-gradient(135deg, #ff9e64, #c97423) !important; color: #fff !important; }
.c3 { background: linear-gradient(135deg, #e0af68, #b08830) !important; color: #fff !important; }
.c4 { background: linear-gradient(135deg, #9ece6a, #6d9c3a) !important; color: #fff !important; }
.c5 { background: linear-gradient(135deg, #73daca, #3fb0a5) !important; color: #fff !important; }
.c6 { background: linear-gradient(135deg, #7aa2f7, #4070d4) !important; color: #fff !important; }
.c7 { background: linear-gradient(135deg, #bb9af7, #8b6fd4) !important; color: #fff !important; }
.series-label {
margin-top: 16px;
font-size: 14px;
font-weight: 600;
color: #c0caf5;
}
.series-meta {
font-size: 12px;
color: #565f89;
margin-top: 4px;
}
.hover-hint {
font-size: 11px;
color: #565f89;
margin-top: 12px;
font-style: italic;
}
</style>
</head>
<body>
<h1>Series Card Cover Layouts</h1>
<p class="subtitle">4 options for displaying multi-cover composites on the /series browse page. Hover to see animation.</p>
<div class="options">
<!-- Option A -->
<div class="option">
<h2>A. Stacked Cascade</h2>
<p class="desc">Covers stacked diagonally with small offset, like a pile of books on a table</p>
<div class="stacked-cascade">
<div class="cover c1">#1</div>
<div class="cover c2">#2</div>
<div class="cover c3">#3</div>
<div class="cover c4">#4</div>
<div class="cover c5">#5</div>
<div class="cover c6">#6</div>
<div class="cover c7">#7</div>
</div>
<div class="series-label">The Expanse</div>
<div class="series-meta">7 of 9 books</div>
<div class="hover-hint">hover to spread</div>
</div>
<!-- Option B -->
<div class="option">
<h2>B. Grid Mosaic</h2>
<p class="desc">Covers tiled in a 3x3 grid, like a mini bookshelf. Clean and dense.</p>
<div class="grid-mosaic">
<div class="cover c1">#1</div>
<div class="cover c2">#2</div>
<div class="cover c3">#3</div>
<div class="cover c4">#4</div>
<div class="cover c5">#5</div>
<div class="cover c6">#6</div>
<div class="cover c7">#7</div>
<div class="cover spacer"></div>
<div class="cover spacer"></div>
</div>
<div class="series-label">The Expanse</div>
<div class="series-meta">7 of 9 books</div>
</div>
<!-- Option C -->
<div class="option">
<h2>C. Fanned Bottom Arc</h2>
<p class="desc">Like a hand of cards fanned from the bottom center, spreading upward</p>
<div class="fanned-bottom">
<div class="cover c1">#1</div>
<div class="cover c2">#2</div>
<div class="cover c3">#3</div>
<div class="cover c4">#4</div>
<div class="cover c5">#5</div>
<div class="cover c6">#6</div>
<div class="cover c7">#7</div>
</div>
<div class="series-label">The Expanse</div>
<div class="series-meta">7 of 9 books</div>
<div class="hover-hint">hover to spread wider</div>
</div>
<!-- Option D -->
<div class="option">
<h2>D. Perspective Shelf</h2>
<p class="desc">Books standing side-by-side with CSS 3D perspective, like a real shelf viewed at angle</p>
<div class="perspective-shelf">
<div class="cover c1">#1</div>
<div class="cover c2">#2</div>
<div class="cover c3">#3</div>
<div class="cover c4">#4</div>
<div class="cover c5">#5</div>
<div class="cover c6">#6</div>
<div class="cover c7">#7</div>
</div>
<div class="series-label">The Expanse</div>
<div class="series-meta">7 of 9 books</div>
<div class="hover-hint">hover to flatten</div>
</div>
</div>
</body>
</html>
Executable
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env sh
# Project-attached wrapper around `make release` so you can run:
# ./release v0.3.0 (or) ./release 0.3.0
# instead of:
# make release VERSION=v0.3.0
# Lives in the repo (no machine-specific alias needed).
set -eu
[ "$#" -ge 1 ] || { echo "Usage: ./release v0.3.0" >&2; exit 1; }
# Accept "0.3.0" or "v0.3.0"; ensure the tag starts with 'v' (the workflow
# only triggers on v* tags).
VERSION="v${1#v}"
exec make release "VERSION=${VERSION}"
+22
View File
@@ -21,6 +21,28 @@ const config: Config = {
], ],
theme: { theme: {
extend: { extend: {
screens: {
nav: "970px",
},
fontFamily: {
sans: [
"ui-sans-serif",
"system-ui",
"sans-serif",
'"Noto Sans SC"',
'"Noto Sans TC"',
'"Noto Sans JP"',
'"Noto Sans KR"',
'"PingFang SC"',
'"Microsoft YaHei"',
'"Hiragino Sans"',
'"Apple SD Gothic Neo"',
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
],
},
colors: { colors: {
primary: { primary: {
DEFAULT: "#7aa2f7", DEFAULT: "#7aa2f7",
+9 -9
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -110,11 +110,11 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.ID) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 53, Col: 50} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 53, Col: 50}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -123,11 +123,11 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.ID) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 54, Col: 50} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 54, Col: 50}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -136,11 +136,11 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(library.ID) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 55, Col: 50} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 55, Col: 50}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -149,11 +149,11 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs("library-folders-" + library.ID) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-folders-" + library.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 58, Col: 53} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 58, Col: 53}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
+3 -3
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -73,11 +73,11 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 40, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 40, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+9 -9
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -43,11 +43,11 @@ func AdminSidebar(user User, currentPath string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String()) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -65,11 +65,11 @@ func AdminSidebar(user User, currentPath string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String()) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -87,11 +87,11 @@ func AdminSidebar(user User, currentPath string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String()) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -109,11 +109,11 @@ func AdminSidebar(user User, currentPath string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String()) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+15 -15
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -55,11 +55,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("user-" + user.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 37, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 37, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -114,11 +114,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("/api/auth/profile/" + user.ID) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 70, Col: 52} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 70, Col: 52}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -127,11 +127,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("#role-result-" + user.ID) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 72, Col: 50} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 72, Col: 50}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -160,11 +160,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("role-result-" + user.ID) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 84, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 84, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -191,11 +191,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("/admin/users/" + user.ID + "/profile-modal") templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 94, Col: 65} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 94, Col: 65}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -214,11 +214,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("/api/auth/profile/" + user.ID) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 113, Col: 55} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 113, Col: 55}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -227,11 +227,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("#user-" + user.ID) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 115, Col: 43} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 115, Col: 43}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+63 -6
View File
@@ -19,7 +19,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<script src="/static/htmx.min.js"></script> <script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/> <link href="/static/style.css" rel="stylesheet"/>
</head> </head>
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) }> <body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) } data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) } data-conflict-id={ conflictID(book.ActiveConflict) } data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }>
@Header(user, "/media/{ uuidToString(book.ID) }") @Header(user, "/media/{ uuidToString(book.ID) }")
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);"> <div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="w-full px-4 py-3 flex items-center gap-4"> <div class="w-full px-4 py-3 flex items-center gap-4">
@@ -67,6 +67,34 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
> >
📖 Read Now 📖 Read Now
</button> </button>
<!-- Mark as Read / Unread toggle -->
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
<button
@click="toggleRead(false)"
:disabled="readSaving"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
<span x-show="!readSaving"> Mark as Unread</span>
<svg x-show="readSaving" 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>
} else {
<button
@click="toggleRead(true)"
:disabled="readSaving"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
>
<span x-show="!readSaving">📖 Mark as Read</span>
<svg x-show="readSaving" 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>
}
<!-- Sync Progress --> <!-- Sync Progress -->
if book.ActiveConflict != nil || book.ReadingProgress != nil { if book.ActiveConflict != nil || book.ReadingProgress != nil {
<button <button
@@ -103,13 +131,42 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</button> </button>
</div> </div>
<!-- Rating Display --> <!-- Rating Display -->
<div class="mb-6"> <div class="mb-6" @mouseleave="ratingHover = 0">
<span class="text-2xl"> <span class="text-2xl">
@templ.Raw(renderStars(getBookRating(book.Rating))) <template x-for="i in 5" :key="i">
</span> <span style="position: relative; display: inline-block;">
<span class="ml-2 text-sm" style="color: var(--text-secondary);"> <span :style="starFill(i)"></span>
({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5) <button
type="button"
@click="setRating(i*2-1)"
@mouseenter="ratingHover = i*2-1"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'"
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
<button
type="button"
@click="setRating(i*2)"
@mouseenter="ratingHover = i*2"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
</span>
</template>
</span> </span>
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
<button
type="button"
x-show="userRating > 0 && !ratingSaving"
@click="clearRating()"
class="ml-2 text-xs underline hover:opacity-70"
style="color: var(--text-secondary);"
>Clear</button>
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
<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>
</div> </div>
<!-- Community Rating Display (from metadata) --> <!-- Community Rating Display (from metadata) -->
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 { if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
+34 -26
View File
@@ -37,15 +37,8 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
⚠️ Conflict Detected ⚠️ Conflict Detected
</p> </p>
<p class="text-sm" style="color: var(--text-secondary);"> <p class="text-sm" style="color: var(--text-secondary);">
Progress differs between devices. Review the options below and manually resolve via the Conflicts page. Progress differs between devices. Choose which version to keep.
</p> </p>
<a
href="/conflicts"
class="inline-block mt-3 px-4 py-2 rounded-lg text-sm font-semibold"
style="background-color: #f59e0b; color: white; text-decoration: none;"
>
Go to Conflicts Page
</a>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
for source, data := range book.ActiveConflict.ConflictData { for source, data := range book.ActiveConflict.ConflictData {
@@ -59,21 +52,27 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2" class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2"
style="background-color: var(--accent); color: white;" style="background-color: var(--accent); color: white;"
> >
{ source } { data.Source }
</span> </span>
<p class="text-xs" style="color: var(--text-secondary);"> <p class="text-xs" style="color: var(--text-secondary);">
{ FormatInTimezone(data.Timestamp, user.Timezone) } { FormatInTimezone(data.Timestamp, user.Timezone) }
</p> </p>
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="text-3xl font-bold" style="color: var(--accent);"> if data.Data["percentage"] != nil {
{ fmt.Sprintf("%.1f", data.Data["percentage"].(float64)) }% <div class="text-3xl font-bold" style="color: var(--accent);">
</div> { fmt.Sprintf("%.1f", data.Data["percentage"].(float64) * 100) }%
</div>
}
</div> </div>
</div> </div>
if data.Data["current_page"] != nil { if data.Data["page"] != nil {
<div class="text-sm" style="color: var(--text-secondary);"> <div class="text-sm" style="color: var(--text-secondary);">
Page: { fmt.Sprintf("%.0f", data.Data["current_page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) } if data.Data["total_pages"] != nil {
Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) }
} else {
Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) }
}
</div> </div>
} }
if data.Data["epubcfi"] != nil { if data.Data["epubcfi"] != nil {
@@ -81,6 +80,15 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
CFI: { data.Data["epubcfi"].(string) } CFI: { data.Data["epubcfi"].(string) }
</div> </div>
} }
<div class="mt-3">
<button
@click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"}
class="px-3 py-1.5 rounded-lg text-sm font-semibold"
style="background-color: var(--accent); color: white;"
>
Keep This
</button>
</div>
</div> </div>
} }
</div> </div>
@@ -467,25 +475,25 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</button> </button>
<div x-show="openSections.comic" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);"> <div x-show="openSections.comic" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
<div> <div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Manga Type</label> <label for="manga_type" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Manga Type</label>
<select name="manga_type" <select id="manga_type" name="manga_type"
class="w-full px-3 py-2 rounded-lg border text-sm" class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"> style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
<option value="unknown" if textToString(book.MangaType) == "unknown" { selected } >Unknown</option> <option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option>
<option value="no" if textToString(book.MangaType) == "no" { selected }>No</option> <option value="no" selected={ textToString(book.MangaType) == "no" }>No</option>
<option value="yes" if textToString(book.MangaType) == "yes" { selected }>Yes</option> <option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option>
<option value="yes_and_right_to_left" if textToString(book.MangaType) == "yes_and_right_to_left" { selected }>Yes (Right to Left)</option> <option value="yes_and_right_to_left" selected={ textToString(book.MangaType) == "yes_and_right_to_left" }>Yes (Right to Left)</option>
</select> </select>
</div> </div>
<div> <div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Reading Direction</label> <label for="reading_direction" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Reading Direction</label>
<select name="reading_direction" <select id="reading_direction" name="reading_direction"
class="w-full px-3 py-2 rounded-lg border text-sm" class="w-full px-3 py-2 rounded-lg border text-sm"
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);"> style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
<option value="auto" if textToString(book.ReadingDirection) == "auto" { selected }>Auto</option> <option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
<option value="ltr" if textToString(book.ReadingDirection) == "ltr" { selected }>Left to Right</option> <option value="ltr" selected={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option>
<option value="rtl" if textToString(book.ReadingDirection) == "rtl" { selected }>Right to Left</option> <option value="rtl" selected={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
<option value="vertical" if textToString(book.ReadingDirection) == "vertical" { selected }>Vertical</option> <option value="vertical" selected={ textToString(book.ReadingDirection) == "vertical" }>Vertical</option>
</select> </select>
</div> </div>
<div> <div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+12 -12
View File
@@ -50,22 +50,22 @@ templ BookShelf(
class="w-full px-3 py-2 border rounded-lg" class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
> >
if len(libraries) == 0 { if len(libraries) == 0 {
<option value="">No libraries available</option> <option value="">No libraries available</option>
} else {
if currentLibraryID == "" {
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
} else { } else {
if currentLibraryID == "" { <option value="">All Books ({ TotalMediaCount(libraries) })</option>
<option value="" selected>All Books</option> }
for _, lib := range libraries {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else { } else {
<option value="">All Books</option> <option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
}
for _, lib := range libraries {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option>
} else {
<option value={ lib.ID }>{ lib.Name }</option>
}
} }
} }
}
</select> </select>
</div> </div>
<!-- Search Input --> <!-- Search Input -->
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+13 -13
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -54,11 +54,11 @@ func CollectionModal(collection CollectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("/api/collections/" + collection.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 16, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 16, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -67,11 +67,11 @@ func CollectionModal(collection CollectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.ID) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 19, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 19, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -80,11 +80,11 @@ func CollectionModal(collection CollectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 25, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 25, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -106,11 +106,11 @@ func CollectionModal(collection CollectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 56, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 56, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -119,11 +119,11 @@ func CollectionModal(collection CollectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Color) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 75, Col: 86} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 75, Col: 86}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -137,11 +137,11 @@ func CollectionModal(collection CollectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 122, Col: 83} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 122, Col: 83}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+15 -15
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -55,11 +55,11 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("/collections/" + col.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 62, Col: 82} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 62, Col: 82}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -68,11 +68,11 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(col.Color) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -94,11 +94,11 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("/collections/" + col.ID + "/edit-modal") templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 72, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 72, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -107,11 +107,11 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("/api/collections/" + col.ID + "") templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 81, Col: 56} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 81, Col: 56}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -317,11 +317,11 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.CoverImagePath) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -345,11 +345,11 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(collection.ID) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 352, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 352, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -358,11 +358,11 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(libraryID) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 353, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 353, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+3 -3
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -47,11 +47,11 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 68, Col: 31} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 68, Col: 31}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+33 -33
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -102,11 +102,11 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 37, Col: 33} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 37, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -115,11 +115,11 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(section.IsSystem) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -200,11 +200,11 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 69, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 69, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -213,11 +213,11 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("carousel-track-" + section.ID) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -242,11 +242,11 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -297,11 +297,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 60} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 60}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -310,11 +310,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs("View " + item.Title) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("View " + item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 36} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 36}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -328,11 +328,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.CoverImagePath) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 122, Col: 31} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 122, Col: 31}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -341,11 +341,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 123, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 123, Col: 22}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -359,11 +359,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 22} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 22}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -377,11 +377,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 137, Col: 106} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 137, Col: 106}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -408,11 +408,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 141, Col: 93} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 141, Col: 93}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -473,11 +473,11 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 178, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 178, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -486,11 +486,11 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", section.IsSystem)) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 179, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 179, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -540,11 +540,11 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var28 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 197, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 197, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -586,11 +586,11 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var30 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+4 -4
View File
@@ -58,10 +58,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
} }
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<button @click="showShelfMappings('{ device.ID }')" class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);"> <button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
📚 📚
</button> </button>
<button @click="showDeviceSettings('{ device.ID }')" class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);"> <button @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
⚙️ ⚙️
</button> </button>
</div> </div>
@@ -175,10 +175,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
</p> </p>
</div> </div>
<div class="flex space-x-2"> <div class="flex space-x-2">
<button @click="approveDevice('{ reg.RegistrationID }')" class="btn-primary px-4 py-2 rounded-lg text-sm"> <button @click={ "approveDevice('" + reg.RegistrationID + "')" } class="btn-primary px-4 py-2 rounded-lg text-sm">
Approve Approve
</button> </button>
<button @click="rejectDevice('{ reg.RegistrationID }')" class="btn-danger px-4 py-2 rounded-lg text-sm"> <button @click={ "rejectDevice('" + reg.RegistrationID + "')" } class="btn-danger px-4 py-2 rounded-lg text-sm">
Reject Reject
</button> </button>
</div> </div>
+182 -130
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -58,11 +58,11 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var3).String()) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+3 -3
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -35,11 +35,11 @@ func FilterItem(id string, name string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(id) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 8, Col: 21} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 8, Col: 21}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+231 -3
View File
@@ -23,7 +23,7 @@ templ Header(user User, currentPath string) {
</svg> </svg>
<span x-show="scanning && scanProgress > 0" x-transition class="text-xs font-normal" style="color: var(--text-secondary);" x-text="scanProgress + '%'"></span> <span x-show="scanning && scanProgress > 0" x-transition class="text-xs font-normal" style="color: var(--text-secondary);" x-text="scanProgress + '%'"></span>
</a> </a>
<div class="hidden md:flex items-center space-x-4"> <div class="hidden nav:flex items-center space-x-4">
<a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;"> <a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Library Library
</a> </a>
@@ -49,7 +49,7 @@ templ Header(user User, currentPath string) {
</div> </div>
</div> </div>
<!-- Center: Search Box --> <!-- Center: Search Box -->
<div class="flex-1 max-w-2xl mx-8"> <div class="flex-1 max-w-2xl mx-8 hidden nav:block">
<div class="relative"> <div class="relative">
<input <input
type="text" type="text"
@@ -65,7 +65,7 @@ templ Header(user User, currentPath string) {
</div> </div>
</div> </div>
<!-- Right: Theme Switcher & User Menu --> <!-- Right: Theme Switcher & User Menu -->
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }" class="flex items-center space-x-4"> <div x-data="{ themeDropdownOpen: false, userMenuOpen: false }" class="hidden nav:flex items-center space-x-4">
<!-- Theme Switcher --> <!-- Theme Switcher -->
<div class="relative"> <div class="relative">
<button <button
@@ -296,6 +296,234 @@ templ Header(user User, currentPath string) {
} }
</div> </div>
</div> </div>
<button
@click="mobileMenuOpen = !mobileMenuOpen"
type="button"
class="nav:hidden p-2 rounded-lg hover:opacity-80 transition-opacity"
style="background-color: var(--bg-primary); color: var(--text-primary);"
aria-label="Toggle menu"
>
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
<div
x-show="mobileMenuOpen"
@click.outside="mobileMenuOpen = false"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
class="nav:hidden border-t"
style="border-color: var(--border); background-color: var(--bg-secondary); display: none;"
x-data="{ mobileThemeOpen: false }"
>
<div class="px-4 py-3 space-y-1">
<div class="relative mb-3">
<input
type="text"
id="header-search-mobile"
placeholder="Search your library..."
class="w-full px-4 py-2 pl-10 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
autocomplete="off"
/>
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<a href="/dashboard" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Library
</a>
<a href="/bookshelf" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
All Books
</a>
<a href="/series" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Series
</a>
<a href="/collections" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Collections
</a>
<a href="/progress" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Progress
</a>
<a href="/devices" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Devices
</a>
<div class="border-t my-2" style="border-color: var(--border);"></div>
<button
@click="mobileThemeOpen = !mobileThemeOpen"
type="button"
class="flex items-center space-x-2 w-full px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-secondary);"
>
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
</svg>
<span>Theme</span>
<svg class="h-4 w-4 ml-auto transform transition-transform" :class="{ 'rotate-180': mobileThemeOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div x-show="mobileThemeOpen" x-transition class="pl-8 pr-2 py-1 space-y-1">
<button
@click="changeTheme('tokyo-night'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
Tokyo Night
</button>
<button
@click="changeTheme('dracula'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
Dracula
</button>
<button
@click="changeTheme('nord'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
Nord
</button>
<button
@click="changeTheme('solarized-dark'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
Solarized Dark
</button>
<button
@click="changeTheme('monokai'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
Monokai
</button>
<button
@click="changeTheme('one-dark-pro'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
One Dark Pro
</button>
<button
@click="changeTheme('material-dark'); mobileThemeOpen = false"
type="button"
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
Material Dark
</button>
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
<p class="text-xs mb-2 px-3" style="color: var(--text-secondary)">Bookshelf Background</p>
<button
@click="changeWoodPaneling('none'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="none"
>
None
</button>
<button
@click="changeWoodPaneling('wood-light'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-light"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span>
Wood Light
</button>
<button
@click="changeWoodPaneling('wood-dark'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-dark"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span>
Wood Dark
</button>
<button
@click="changeWoodPaneling('wood-mahogany'); mobileThemeOpen = false"
type="button"
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-primary);"
data-wood="wood-mahogany"
>
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span>
Wood Mahogany
</button>
</div>
</div>
<div class="border-t my-2" style="border-color: var(--border);"></div>
if user.ID != "" {
<div class="space-y-1">
<div class="flex items-center space-x-2 px-3 py-2">
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
<span class="text-sm" style="color: var(--text-primary)">{ user.Username }</span>
</div>
<a href="/profile" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Profile
</a>
if user.Role == "admin" {
<a href="/admin" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
Admin Panel
</a>
}
<button
@click="logout()"
type="button"
class="block w-full text-left px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity"
style="color: var(--text-secondary);"
>
Logout
</button>
</div>
} else {
<div class="space-y-3">
<form hx-post="/api/auth/login" hx-target="#login-result-mobile" hx-swap="innerHTML" class="space-y-3">
<input type="hidden" name="redirect" value="{ currentPath }"/>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<div>
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
</div>
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
Sign In
</button>
</form>
<div id="login-result-mobile"></div>
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
Don't have an account? Sign Up
</a>
</div>
}
</div>
</div> </div>
</div> </div>
</nav> </nav>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+7 -7
View File
@@ -11,14 +11,14 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500" class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-secondary); color: var(--text-primary);" style="background-color: var(--bg-secondary); color: var(--text-primary);"
> >
<option value="">All Libraries</option> <option value="">All Libraries ({ TotalMediaCount(libData) })</option>
for _, lib := range libData { for _, lib := range libData {
if lib.ID == currentLibraryID { if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option> <option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else { } else {
<option value={ lib.ID }>{ lib.Name }</option> <option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
}
} }
}
</select> </select>
</div> </div>
if len(actions) > 0 { if len(actions) > 0 {
+78 -39
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -29,81 +29,120 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
templ_7745c5c3_Var1 = templ.NopComponent templ_7745c5c3_Var1 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center justify-between\"><div class=\"flex items-center gap-4\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library:</label> <select id=\"library-select\" name=\"library_id\" class=\"px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-secondary); color: var(--text-primary);\"><option value=\"\">All Libraries</option> ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center justify-between\"><div class=\"flex items-center gap-4\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library:</label> <select id=\"library-select\" name=\"library_id\" class=\"px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-secondary); color: var(--text-primary);\"><option value=\"\">All Libraries (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 14, Col: 62}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, ")</option> ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, lib := range libData { for _, lib := range libData {
if lib.ID == currentLibraryID { if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" selected>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" selected>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 50}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 70}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ")</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ")</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</select></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</select></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(actions) > 0 { if len(actions) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"flex items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -113,12 +152,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -142,12 +181,12 @@ func DashboardActions() templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var6 := templ.GetChildren(ctx) templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil { if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var6 = templ.NopComponent templ_7745c5c3_Var9 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button data-action=\"open-dashboard-settings\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Customize Dashboard\">⚙️</button> <button data-action=\"reload-page\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Refresh\">🔄</button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Customize Dashboard\">⚙️</button> <button data-action=\"reload-page\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Refresh\">🔄</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+13 -13
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -34,11 +34,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(actionURL) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 5, Col: 20} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 5, Col: 20}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -47,11 +47,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 20, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 20, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -60,11 +60,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 30, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 30, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -73,11 +73,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(user.FirstName) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 40, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 40, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -86,11 +86,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(user.LastName) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 51, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 51, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -449,11 +449,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("/api/auth/password/" + user.ID) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 199, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 199, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+9 -1
View File
@@ -14,11 +14,19 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string {
"mangaType": metadata.MangaType, "mangaType": metadata.MangaType,
} }
if progress.Percentage > 0 { if progress.Percentage > 0 {
config["savedPercentage"] = progress.Percentage config["savedPercentage"] = progress.Percentage / 100
} }
if progress.EpubCfi != "" { if progress.EpubCfi != "" {
config["savedCfi"] = 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
}
}
jsonBytes, _ := json.Marshal(config) jsonBytes, _ := json.Marshal(config)
return fmt.Sprintf("initReader(%s)", string(jsonBytes)) return fmt.Sprintf("initReader(%s)", string(jsonBytes))
} }
+24 -16
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -22,11 +22,19 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string {
"mangaType": metadata.MangaType, "mangaType": metadata.MangaType,
} }
if progress.Percentage > 0 { if progress.Percentage > 0 {
config["savedPercentage"] = progress.Percentage config["savedPercentage"] = progress.Percentage / 100
} }
if progress.EpubCfi != "" { if progress.EpubCfi != "" {
config["savedCfi"] = 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
}
}
jsonBytes, _ := json.Marshal(config) jsonBytes, _ := json.Marshal(config)
return fmt.Sprintf("initReader(%s)", string(jsonBytes)) return fmt.Sprintf("initReader(%s)", string(jsonBytes))
} }
@@ -59,7 +67,7 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 32, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 40, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -70,11 +78,11 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(readerInitExpr(metadata, progress)) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(readerInitExpr(metadata, progress))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 41, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 49, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -162,7 +170,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
var templ_7745c5c3_Var5 templ.SafeURL var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + metadata.MediaItemID) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + metadata.MediaItemID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 79, Col: 46} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 87, Col: 46}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -175,7 +183,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 82, Col: 54} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 90, Col: 54}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -190,7 +198,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages)) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 160, Col: 112} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 168, Col: 112}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -200,7 +208,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%%", progress.Percentage)) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%%", progress.Percentage))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 162, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 170, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -211,7 +219,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages)) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 165, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 173, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -349,11 +357,11 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.CfiPosition) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(bookmark.CfiPosition)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 382, Col: 38} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 390, Col: 38}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -364,7 +372,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component {
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Title) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Title)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 385, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 393, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -377,7 +385,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component {
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Position) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Position)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 387, Col: 27} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 395, Col: 27}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+9 -9
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -212,11 +212,11 @@ func SeriesCard(series SeriesCardData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String()) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -235,11 +235,11 @@ func SeriesCard(series SeriesCardData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(cover) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 75, Col: 17} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 75, Col: 17}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -248,11 +248,11 @@ func SeriesCard(series SeriesCardData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var10).String()) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -261,11 +261,11 @@ func SeriesCard(series SeriesCardData) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name) templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 77, Col: 23} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 77, Col: 23}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+402
View File
@@ -0,0 +1,402 @@
package templates
templ Setup() {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Setup - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script type="module" src="/static/main.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body class="theme-tokyo-night min-h-screen" x-data="setupWizard" x-init="initSetup()">
<div class="container mx-auto px-4 py-8 max-w-2xl">
<div class="text-center mb-8">
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">Welcome to Bookhoard</h1>
<p class="text-lg" style="color: var(--text-secondary)">Let's get your library set up.</p>
</div>
<!-- Step indicator -->
<div class="flex items-center justify-center mb-8 gap-2">
<template x-for="n in 4" :key="n">
<div class="flex items-center gap-2">
<div
class="w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm transition-all"
:class="currentStep >= n ? 'bg-[var(--accent)] text-[var(--bg-primary)]' : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border)]'"
>
<span x-text="currentStep > n ? '✓' : n"></span>
</div>
<div
x-text="['Admin', 'Libraries', 'Folders', 'Scan'][n-1]"
class="text-sm hidden sm:inline"
:style="currentStep >= n ? 'color: var(--text-primary)' : 'color: var(--text-secondary)'"
></div>
<div x-show="n < 4" class="w-8 h-0.5 mx-1" :class="currentStep > n ? 'bg-[var(--accent)]' : 'bg-[var(--border)]'"></div>
</div>
</template>
</div>
<!-- Error display -->
<div x-show="errorMsg" class="mb-4 p-4 rounded-lg border border-red-500/50 bg-red-500/10 text-red-400 text-sm">
<span x-text="errorMsg"></span>
</div>
<!-- ==================== STEP 1: Create Admin ==================== -->
<div x-show="currentStep === 1" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Create Administrator Account</h2>
<p class="mb-6" style="color: var(--text-secondary)">This first account will have full admin privileges.</p>
<form @submit.prevent="submitAdmin()">
<div class="mb-6 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Server URL (Base URL)</label>
<input
type="url"
x-model="admin.baseUrl"
placeholder="https://books.example.com"
class="w-full px-3 py-2 border rounded mt-2"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
required
/>
<p class="text-sm mt-2" style="color: var(--text-secondary)">The public URL of your Bookhoard instance. Used for device sync, OPDS feed, and API endpoints.</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<input
type="email"
id="email"
x-model="admin.email"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
required
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<input
type="text"
x-model="admin.username"
id="username"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
required
/>
</div>
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<input
type="text"
x-model="admin.firstName"
placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<input
type="text"
x-model="admin.lastName"
placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
/>
</div>
</div>
<div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-primary); border-color: var(--border)">
<p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
<ul class="space-y-1" style="color: var(--text-secondary)">
<li id="req-length" class="flex items-center gap-2">
<span class="requirement-icon"></span> At least 8 characters
</li>
<li id="req-upper" class="flex items-center gap-2">
<span class="requirement-icon"></span> One uppercase letter
</li>
<li id="req-lower" class="flex items-center gap-2">
<span class="requirement-icon"></span> One lowercase letter
</li>
<li id="req-number" class="flex items-center gap-2">
<span class="requirement-icon"></span> One number
</li>
<li id="req-special" class="flex items-center gap-2">
<span class="requirement-icon"></span> One special character
</li>
<li id="req-match" class="flex items-center gap-2">
<span class="requirement-icon"></span> Passwords match
</li>
</ul>
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
<input
type="password"
x-model="admin.password"
id="password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
required
/>
</div>
<div class="mb-6">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label>
<input
type="password"
x-model="admin.confirmPassword"
id="confirm-password"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
required
/>
</div>
<button
type="submit"
id="register-btn"
class="btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed"
:disabled="loading"
x-text="loading ? 'Creating...' : 'Create Admin Account'"
></button>
</form>
</div>
<!-- ==================== STEP 2: Create Libraries ==================== -->
<div x-show="currentStep === 2" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Create Libraries</h2>
<p class="mb-6" style="color: var(--text-secondary)">Add one or more media libraries. You can always add more later.</p>
<!-- Created libraries list -->
<div x-show="libraries.length > 0" class="mb-6 space-y-2">
<template x-for="(lib, idx) in libraries" :key="lib.id">
<div class="p-3 rounded-lg border flex items-center justify-between" style="background-color: var(--bg-primary); border-color: var(--border)">
<div class="flex items-center gap-3">
<span class="text-xl" x-text="lib.type === 'ebooks' ? '📚' : lib.type === 'comics' ? '📖' : '🗾'"></span>
<div>
<p class="font-medium" style="color: var(--text-primary)" x-text="lib.name"></p>
<p class="text-xs capitalize" style="color: var(--text-secondary)" x-text="lib.type"></p>
</div>
</div>
<button
type="button"
@click="removeLibrary(idx)"
class="text-xs text-red-500 hover:underline"
>Remove</button>
</div>
</template>
</div>
<!-- Add library form -->
<form @submit.prevent="addLibrary()" class="space-y-4 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Library Name</label>
<input
type="text"
x-model="newLibrary.name"
placeholder="My Ebook Library"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
required
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Library Type</label>
<select
x-model="newLibrary.type"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
>
<option value="ebooks">📚 Ebooks</option>
<option value="comics">📖 Comics</option>
<option value="manga">🗾 Manga</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Description (optional)</label>
<input
type="text"
x-model="newLibrary.description"
placeholder="Optional"
class="w-full px-3 py-2 border rounded"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
/>
</div>
<button
type="submit"
class="btn-secondary w-full py-2 rounded"
:disabled="loading || !newLibrary.name.trim()"
x-text="loading ? 'Creating...' : '+ Add This Library'"
></button>
</form>
<div class="flex justify-between mt-6">
<button type="button" @click="currentStep = 1" class="btn-secondary px-4 py-2 rounded">Back</button>
<button type="button" @click="currentStep = 3" class="btn-primary px-6 py-2 rounded">
<span x-text="libraries.length > 0 ? 'Next: Configure Folders' : 'Skip Continue'"></span>
</button>
</div>
</div>
<!-- ==================== STEP 3: Configure Folders ==================== -->
<div x-show="currentStep === 3" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Configure Library Folders</h2>
<p class="mb-6" style="color: var(--text-secondary)">Add the filesystem folders where your media files are stored.</p>
<div x-show="libraries.length === 0" class="text-center py-8" style="color: var(--text-secondary)">
<p>No libraries created. You can add folders later from the admin panel.</p>
</div>
<div class="space-y-6">
<template x-for="lib in libraries" :key="lib.id">
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<div class="flex items-center gap-2 mb-3">
<span class="text-xl" x-text="lib.type === 'ebooks' ? '📚' : lib.type === 'comics' ? '📖' : '🗾'"></span>
<h4 class="font-semibold" style="color: var(--text-primary)" x-text="lib.name"></h4>
</div>
<!-- Existing folders for this library -->
<div class="space-y-1 mb-3" x-show="lib.folders && lib.folders.length > 0">
<template x-for="(folder, fIdx) in (lib.folders || [])" :key="folder">
<div class="flex items-center justify-between p-2 rounded text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)">
<span style="color: var(--text-primary)" x-text="folder"></span>
<button type="button" @click="removeFolder(lib.id, folder, fIdx)" class="text-xs text-red-500 hover:underline">Remove</button>
</div>
</template>
</div>
<!-- Add folder -->
<div class="flex gap-2">
<input
type="text"
:placeholder="'/path/to/' + lib.type"
:id="'folderInput-' + lib.id"
class="flex-1 px-3 py-2 border rounded text-sm"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
/>
<button
type="button"
@click="showFolderBrowser('folderInput-' + lib.id)"
class="btn-secondary px-3 py-2 text-xs rounded"
>Browse</button>
<button
type="button"
@click="addFolderToLibrary(lib)"
class="btn-primary px-3 py-2 text-xs rounded"
>Add</button>
</div>
</div>
</template>
</div>
<div class="flex justify-between mt-6">
<button type="button" @click="currentStep = 2" class="btn-secondary px-4 py-2 rounded">Back</button>
<button type="button" @click="currentStep = 4" class="btn-primary px-6 py-2 rounded">Next: Scan</button>
</div>
</div>
<!-- ==================== STEP 4: Scan & Finish ==================== -->
<div x-show="currentStep === 4" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Scan & Finish</h2>
<p class="mb-6" style="color: var(--text-secondary)">Review your configuration and start the initial scan.</p>
<!-- Summary -->
<div class="space-y-3 mb-6">
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<p class="text-sm font-medium mb-1" style="color: var(--text-secondary)">Server URL</p>
<p style="color: var(--text-primary)" x-text="admin.baseUrl"></p>
</div>
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<p class="text-sm font-medium mb-1" style="color: var(--text-secondary)">Administrator</p>
<p style="color: var(--text-primary)" x-text="admin.username + ' (' + admin.email + ')'"></p>
</div>
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
<p class="text-sm font-medium mb-2" style="color: var(--text-secondary)">
Libraries (<span x-text="libraries.length"></span>)
</p>
<template x-for="lib in libraries" :key="lib.id">
<div class="flex items-start justify-between text-sm py-1">
<div>
<span style="color: var(--text-primary)" x-text="lib.name"></span>
<span class="ml-2 text-xs px-2 py-0.5 rounded" style="background-color: var(--accent); color: var(--bg-primary)" x-text="lib.type"></span>
</div>
<div class="text-right" style="color: var(--text-secondary)">
<span x-text="(lib.folders || []).length + ' folder(s)'"></span>
</div>
</div>
</template>
</div>
</div>
<!-- Scan progress -->
<div x-show="scanStarted" class="mb-6 space-y-3">
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Progress</h3>
<div id="library-progress-list" class="space-y-3">
<template x-for="job in scanJobs" :key="job.jobId">
<div class="p-3 rounded border" style="background-color: var(--bg-primary); border-color: var(--border)">
<div class="flex justify-between items-center mb-2">
<span class="font-medium" style="color: var(--text-primary)" x-text="job.libraryName"></span>
<span class="text-sm" style="color: var(--text-secondary)" x-text="job.statusText"></span>
</div>
<div class="w-full rounded-full h-2" style="background-color: var(--bg-secondary)">
<div
class="h-2 rounded-full transition-all duration-500"
:style="'width: ' + job.progress + '%; background-color: var(--accent);'"
></div>
</div>
</div>
</template>
</div>
<div x-show="scanComplete" class="p-4 rounded-lg border border-green-500/50 bg-green-500/10 text-green-400 text-sm">
Initial scan complete! Your libraries are ready to use.
</div>
</div>
<div class="flex justify-between mt-6">
<button type="button" @click="currentStep = 3" class="btn-secondary px-4 py-2 rounded" :disabled="loading">Back</button>
<div class="flex gap-3">
<button
x-show="!scanStarted"
type="button"
@click="startScan()"
class="btn-primary px-6 py-2 rounded"
:disabled="loading"
>Start Scan</button>
<button
x-show="scanStarted && !scanComplete"
type="button"
@click="finishSetup()"
class="btn-secondary px-6 py-2 rounded"
:disabled="loading"
>Skip to Dashboard</button>
<button
x-show="scanComplete"
type="button"
@click="finishSetup()"
class="btn-primary px-6 py-2 rounded"
:disabled="loading"
x-text="loading ? 'Finishing...' : 'Go to Dashboard'"
></button>
</div>
</div>
</div>
</div>
<!-- Folder Browser Modal (reused from admin library) -->
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7)">
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border)">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
<button type="button" @click="hideFolderBrowser()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)"></button>
</div>
<div id="folder-browser-content">
<!-- Directory listings rendered by Alpine -->
</div>
</div>
</div>
</body>
</html>
}
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
@@ -35,11 +35,11 @@ func ErrorToast(message string) templ.Component {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("showErrorToast('" + message + "')") templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("showErrorToast('" + message + "')")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/toast.templ`, Line: 5, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/toast.templ`, Line: 5, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+1
View File
@@ -37,6 +37,7 @@ type LibraryData struct {
Name string Name string
Description string Description string
TypeName string TypeName string
MediaCount int64
} }
type SeriesCardData struct { type SeriesCardData struct {
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by templ - DO NOT EDIT. // Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1001 // templ: version: v0.3.1020
package templates package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present. //lint:file-ignore SA4006 This context is only used if a nested component is present.
+43
View File
@@ -2,9 +2,11 @@ package templates
import ( import (
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/handlers"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url" "net/url"
"sort"
"strings" "strings"
"time" "time"
@@ -29,6 +31,16 @@ func ContainsString(slice []string, item string) bool {
return false return false
} }
// TotalMediaCount sums the MediaCount across the given libraries,
// used to display the total next to the "All Libraries" option.
func TotalMediaCount(libs []LibraryData) int64 {
var total int64
for _, l := range libs {
total += l.MediaCount
}
return total
}
func uuidToString(id pgtype.UUID) string { func uuidToString(id pgtype.UUID) string {
if !id.Valid { if !id.Valid {
return "" return ""
@@ -146,6 +158,37 @@ func getBookRating(rating *database.MediaRatings) int32 {
return 0 return 0
} }
// conflictWinnerSource returns a valid source key from a conflict's
// conflict_data map, to pass as the "winner" when resolving it. It prefers
// "web" (since the user is acting via the web UI) and otherwise falls back to
// the lexicographically smallest key. The chosen winner does not affect the
// final read/unread state, which is set by a subsequent progress write; it only
// needs to be a key present in the conflict data so the resolve endpoint
// accepts it and arms its 10-minute suppression window.
func conflictWinnerSource(c *handlers.ConflictDetailResponse) string {
if c == nil || len(c.ConflictData) == 0 {
return ""
}
if _, ok := c.ConflictData["web"]; ok {
return "web"
}
keys := make([]string, 0, len(c.ConflictData))
for k := range c.ConflictData {
keys = append(keys, k)
}
sort.Strings(keys)
return keys[0]
}
// conflictID returns the active conflict's ID, or "" when there is none. Used
// to render a data-conflict-id attribute the frontend can read.
func conflictID(c *handlers.ConflictDetailResponse) string {
if c == nil {
return ""
}
return c.ID
}
// getAlternateSeries extracts the alternate series name from JSONB data // getAlternateSeries extracts the alternate series name from JSONB data
func getAlternateSeries(data []byte) string { func getAlternateSeries(data []byte) string {
if len(data) == 0 { if len(data) == 0 {
+19 -1
View File
@@ -1,12 +1,30 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { cpSync, mkdirSync } from "node:fs";
import { join } from "node:path";
const pdfjsAssets = () => ({
name: "pdfjs-assets",
closeBundle() {
const outDir = "web/static/vendor/pdfjs";
const srcDir =
"node_modules/@bookhoard/foliate-js/vendor/pdfjs";
mkdirSync(outDir, { recursive: true });
cpSync(join(srcDir, "standard_fonts"), join(outDir, "standard_fonts"), {
recursive: true,
});
cpSync(join(srcDir, "cmaps"), join(outDir, "cmaps"), { recursive: true });
},
});
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@": "/web/src", "@": "/web/src",
"foliate-js": "/node_modules/@bookhoard/foliate-js", // Updated path "foliate-js": "/node_modules/@bookhoard/foliate-js",
}, },
}, },
base: "/static/", base: "/static/",
plugins: [pdfjsAssets()],
build: { build: {
outDir: "web/static", outDir: "web/static",
emptyOutDir: false, emptyOutDir: false,

Some files were not shown because too many files have changed in this diff Show More