14 Commits
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
28 changed files with 1868 additions and 877 deletions
+74 -5
View File
@@ -1,13 +1,25 @@
name: Release
# Builds and publishes the Bookhoard container image to the Gitea container registry.
# Triggered ONLY by a version tag push (pushing to main does nothing), so work-in-progress
# commits never ship. Each release publishes two image tags: the version and "latest".
# 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:
@@ -15,9 +27,17 @@ jobs:
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
@@ -27,7 +47,8 @@ jobs:
with:
registry: git.linuxhg.com
username: ${{ gitea.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# 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
@@ -39,5 +60,53 @@ jobs:
# 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:${{ gitea.ref_name }}
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
+28 -1
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)
# Ignore if .env doesn't exist yet
@@ -44,6 +44,9 @@ help:
@echo "Verification:"
@echo " make verify-guidelines - Run comprehensive 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)
test:
@@ -158,3 +161,27 @@ verify-guidelines:
verify-quick:
@echo "Running quick project guidelines verification..."
@./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."
+1 -1
View File
@@ -47,7 +47,7 @@ docs: |-
- `id` (string, required): Media item UUID
**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
**Response:** Updated rating object
+4 -3
View File
@@ -83,9 +83,10 @@ docs:
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
Ratings (All Users)
- **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 if unrated)
- **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision)
- **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating
- **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated)
- **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.
- **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert)
- **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating
Collections (All Users)
- **List Collections**: GET /api/collections - Get user's collections
- **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"
+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)
- `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):
```xml
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"
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>
<title>Bookhoard Library</title>
<updated>2026-02-01T12:00:00Z</updated>
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog"/>
<link rel="search" href="http://localhost:8765/opds/devices/kobo-id/search"/>
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/nav"/>
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=2&per_page=50"/>
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
<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>
<id>urn:uuid:bookhoard-uuid-123</id>
<dc:title>The Hobbit</dc:title>
<dc:creator>J.R.R. Tolkien</dc:creator>
<title>The Hobbit</title>
<author><name>J.R.R. Tolkien</name></author>
<updated>2026-02-01T10:00:00Z</updated>
<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
```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
+1
View File
@@ -303,6 +303,7 @@ type Querier interface {
// Get user reading history for analytics
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, 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)
// Check if book is in collection
+34
View File
@@ -7493,6 +7493,40 @@ func (q *Queries) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUI
return items, nil
}
const GetVisibleLibraryMediaCounts = `-- 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
`
type GetVisibleLibraryMediaCountsRow struct {
ID pgtype.UUID `db:"id" json:"id"`
MediaCount int64 `db:"media_count" json:"media_count"`
}
func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) {
rows, err := q.db.Query(ctx, GetVisibleLibraryMediaCounts, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetVisibleLibraryMediaCountsRow{}
for rows.Next() {
var i GetVisibleLibraryMediaCountsRow
if err := rows.Scan(&i.ID, &i.MediaCount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one
SELECT EXISTS(
SELECT 1 FROM sync_conflicts
+8
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
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
-- 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)
+4
View File
@@ -1791,6 +1791,10 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
"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)
}
+75 -15
View File
@@ -67,6 +67,42 @@ func appendToken(url, token string) string {
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 {
@@ -206,14 +242,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
"Bookhoard Library",
)
// Add feed links
// Feed links, including OPDS pagination links (first/previous/next/last) and
// OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
token := h.getAuthToken(c)
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
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)
// OpenSearch: the search link points to an OpenSearch description document
// (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/atom+xml;profile=opds-catalog;kind=acquisition", "search")
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
// Add entries
for _, item := range allItems {
@@ -286,16 +325,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
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 {
deviceID := c.Param("deviceId")
query := c.QueryParam("q")
if query == "" {
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
}
// Get base URLs
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
if err != nil {
@@ -316,12 +356,30 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
// Get user's visible libraries
userID := device.UserID.Bytes
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
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
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
@@ -340,12 +398,14 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
)
// Add feed links
token := h.getAuthToken(c)
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 := 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)
userUUID := uuid.UUID(userID)
+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")
}
+89 -17
View File
@@ -9,15 +9,19 @@ import (
// OPDS 1.2 Feed Structures
type Feed struct {
XMLName xml.Name `xml:"feed"`
Xmlns string `xml:"xmlns,attr"`
OpdsNS string `xml:"xmlns:opds,attr"`
DcNS string `xml:"xmlns:dc,attr"`
ID string `xml:"id"`
Title string `xml:"title"`
Updated string `xml:"updated"`
Links []Link `xml:"link"`
Entries []Entry `xml:"entry"`
XMLName xml.Name `xml:"feed"`
Xmlns string `xml:"xmlns,attr"`
OpdsNS string `xml:"xmlns:opds,attr"`
DcNS string `xml:"xmlns:dc,attr"`
OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"`
ID string `xml:"id"`
Title string `xml:"title"`
Updated string `xml:"updated"`
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 {
@@ -64,17 +68,29 @@ type Category struct {
func NewFeed(feedID, title string) *Feed {
now := time.Now().Format(time.RFC3339)
return &Feed{
Xmlns: "http://www.w3.org/2005/Atom",
OpdsNS: "http://opds-spec.org/2010/",
DcNS: "http://purl.org/dc/elements/1.1/",
ID: feedID,
Title: title,
Updated: now,
Links: []Link{},
Entries: []Entry{},
Xmlns: "http://www.w3.org/2005/Atom",
OpdsNS: "http://opds-spec.org/2010/",
DcNS: "http://purl.org/dc/elements/1.1/",
OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/",
ID: feedID,
Title: title,
Updated: now,
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
func (f *Feed) AddLink(href, linkType, rel string) {
f.Links = append(f.Links, Link{
@@ -178,6 +194,62 @@ func (f *Feed) GenerateXMLString() (string, error) {
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
func NewErrorFeed(message string) *Feed {
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)
}
if entry.Creator != "Test Author" {
t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator)
if entry.Author == nil || entry.Author.Name != "Test Author" {
t.Errorf("expected Author.Name to be 'Test Author', got %v", entry.Author)
}
if entry.Updated != "2023-01-01T00:00:00Z" {
@@ -201,8 +201,8 @@ func TestFeedGenerateXML(t *testing.T) {
`<title>Test Feed</title>`,
`<entry>`,
`<id>urn:uuid:book-id</id>`,
`<dc:title>Test Book</dc:title>`,
`<dc:creator>Test Author</dc:creator>`,
`<title>Test Book</title>`,
`<name>Test Author</name>`,
`<link href="http://example.com/book.epub"`,
`rel="http://opds-spec.org/acquisition/open-access"`,
`<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 {
return len(s) >= len(substr) && indexOf(s, substr) >= 0
}
+12
View File
@@ -118,6 +118,17 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
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))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
@@ -126,6 +137,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
Name: lib.Name,
Description: getText(lib.Description),
TypeName: lib.TypeName,
MediaCount: countMap[libUUID.String()],
}
}
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}"
+63 -6
View File
@@ -19,7 +19,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</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) }")
<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">
@@ -67,6 +67,34 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
>
📖 Read Now
</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 -->
if book.ActiveConflict != nil || book.ReadingProgress != nil {
<button
@@ -103,13 +131,42 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</button>
</div>
<!-- Rating Display -->
<div class="mb-6">
<div class="mb-6" @mouseleave="ratingHover = 0">
<span class="text-2xl">
@templ.Raw(renderStars(getBookRating(book.Rating)))
</span>
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5)
<template x-for="i in 5" :key="i">
<span style="position: relative; display: inline-block;">
<span :style="starFill(i)"></span>
<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 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>
<!-- Community Rating Display (from metadata) -->
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
+143 -119
View File
@@ -737,214 +737,238 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" 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);\"></div></div></div><!-- Comic/Manga --><div class=\"border rounded-lg\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span x-text=\"openSections.comic ? '▾' : '▸'\">▸</span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3\" style=\"background-color: var(--bg-primary);\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select name=\"manga_type\" 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);\"><option value=\"unknown\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.MangaType) == "unknown" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, ">Unknown</option> <option value=\"no\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.MangaType) == "no" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, ">No</option> <option value=\"yes\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.MangaType) == "yes" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, ">Yes</option> <option value=\"yes_and_right_to_left\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.MangaType) == "yes_and_right_to_left" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, ">Yes (Right to Left)</option></select></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select name=\"reading_direction\" 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);\"><option value=\"auto\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.ReadingDirection) == "auto" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, ">Auto</option> <option value=\"ltr\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.ReadingDirection) == "ltr" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, ">Left to Right</option> <option value=\"rtl\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.ReadingDirection) == "rtl" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, ">Right to Left</option> <option value=\"vertical\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if textToString(book.ReadingDirection) == "vertical" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " selected")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, ">Vertical</option></select></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\" 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);\"></div></div></div><!-- Comic/Manga --><div class=\"border rounded-lg\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span x-text=\"openSections.comic ? '▾' : '▸'\">▸</span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3\" style=\"background-color: var(--bg-primary);\"><div><label for=\"manga_type\" class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" 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);\"><option value=\"unknown\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var46 string
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 501, Col: 81}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 482, Col: 85}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" 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);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\">Unknown</option> <option value=\"no\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var47 string
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 507, Col: 79}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 483, Col: 75}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" 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);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\">No</option> <option value=\"yes\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var48 string
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 513, Col: 76}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 484, Col: 77}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" 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);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var49 string
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 519, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 485, Col: 113}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" 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);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" 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);\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" 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);\"><option value=\"auto\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var50 string
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 528, Col: 43}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 493, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.IsBlackAndWhite.Bool {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><!-- Technical --><div class=\"border rounded-lg\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span x-text=\"openSections.technical ? '▾' : '▸'\">▸</span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3\" style=\"background-color: var(--bg-primary);\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\">Auto</option> <option value=\"ltr\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var51 string
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 550, Col: 56}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 494, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" 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);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\">Left to Right</option> <option value=\"rtl\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var52 string
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 556, Col: 93}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 495, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" 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);\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\">Right to Left</option> <option value=\"vertical\" selected=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var53 string
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 563, Col: 63}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 496, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" readonly class=\"w-full px-3 py-2 rounded-lg border text-sm opacity-60\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\">Vertical</option></select></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var54 string
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 569, Col: 98}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 501, Col: 81}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" readonly class=\"w-full px-3 py-2 rounded-lg border text-sm opacity-60\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div></div></div></div></div></div><div class=\"flex justify-end space-x-3 p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"hideMetadataEditor()\" class=\"px-4 py-2 rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);\">Cancel</button> <button @click=\"saveMetadata()\" class=\"px-6 py-2 rounded-lg font-semibold\" style=\"background-color: var(--accent); color: white;\">Save</button></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" 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);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var55 string
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 507, Col: 79}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" 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);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var56 string
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 513, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" 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);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var57 string
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 519, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\" 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);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" 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);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var58 string
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 528, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.IsBlackAndWhite.Bool {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " checked")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><!-- Technical --><div class=\"border rounded-lg\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center\" style=\"color: var(--text-primary); background-color: var(--bg-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span x-text=\"openSections.technical ? '▾' : '▸'\">▸</span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3\" style=\"background-color: var(--bg-primary);\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var59 string
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 550, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" 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);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var60 string
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 556, Col: 93}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" 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);\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var61 string
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 563, Col: 63}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" readonly class=\"w-full px-3 py-2 rounded-lg border text-sm opacity-60\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div><div><label class=\"block text-sm font-medium mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var62 string
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 569, Col: 98}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" readonly class=\"w-full px-3 py-2 rounded-lg border text-sm opacity-60\" style=\"background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);\"></div></div></div></div></div></div><div class=\"flex justify-end space-x-3 p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"hideMetadataEditor()\" class=\"px-4 py-2 rounded-lg\" style=\"background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);\">Cancel</button> <button @click=\"saveMetadata()\" class=\"px-6 py-2 rounded-lg font-semibold\" style=\"background-color: var(--accent); color: white;\">Save</button></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -50,22 +50,22 @@ templ BookShelf(
class="w-full px-3 py-2 border rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
>
if len(libraries) == 0 {
<option value="">No libraries available</option>
if len(libraries) == 0 {
<option value="">No libraries available</option>
} else {
if currentLibraryID == "" {
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
} else {
if currentLibraryID == "" {
<option value="" selected>All Books</option>
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
}
for _, lib := range libraries {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else {
<option value="">All Books</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>
}
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
}
}
}
</select>
</div>
<!-- Search Input -->
File diff suppressed because one or more lines are too long
+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"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
>
<option value="">All Libraries</option>
for _, lib := range libData {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option>
} else {
<option value={ lib.ID }>{ lib.Name }</option>
}
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
for _, lib := range libData {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
}
}
</select>
</div>
if len(actions) > 0 {
+78 -39
View File
@@ -29,81 +29,120 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
templ_7745c5c3_Var1 = templ.NopComponent
}
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 {
return templ_7745c5c3_Err
}
for _, lib := range libData {
if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, 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: 17, Col: 29}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" selected>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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 {
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 {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<option value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" selected>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
templ_7745c5c3_Var4, 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: 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_7745c5c3_Var4)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
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 {
return templ_7745c5c3_Err
}
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 {
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))
if templ_7745c5c3_Err != nil {
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 {
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 {
return templ_7745c5c3_Err
}
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 {
return templ_7745c5c3_Err
}
@@ -113,12 +152,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
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 {
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 {
return templ_7745c5c3_Err
}
@@ -142,12 +181,12 @@ func DashboardActions() templ.Component {
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
if templ_7745c5c3_Var6 == nil {
templ_7745c5c3_Var6 = templ.NopComponent
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil {
templ_7745c5c3_Var9 = templ.NopComponent
}
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 {
return templ_7745c5c3_Err
}
+1
View File
@@ -37,6 +37,7 @@ type LibraryData struct {
Name string
Description string
TypeName string
MediaCount int64
}
type SeriesCardData struct {
+43
View File
@@ -2,9 +2,11 @@ package templates
import (
"bookhoard/internal/database"
"bookhoard/internal/handlers"
"encoding/json"
"fmt"
"net/url"
"sort"
"strings"
"time"
@@ -29,6 +31,16 @@ func ContainsString(slice []string, item string) bool {
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 {
if !id.Valid {
return ""
@@ -146,6 +158,37 @@ func getBookRating(rating *database.MediaRatings) int32 {
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
func getAlternateSeries(data []byte) string {
if len(data) == 0 {
+170
View File
@@ -97,6 +97,12 @@ interface MetadataEditorState {
coverFile: Blob | null;
coverAction: string;
saving: boolean;
userRating: number;
ratingHover: number;
ratingSaving: boolean;
conflictId: string;
conflictWinner: string;
readSaving: boolean;
toggleSection(section: string): void;
showMetadataEditor(): void;
hideMetadataEditor(): void;
@@ -104,6 +110,11 @@ interface MetadataEditorState {
generateCover(): Promise<void>;
removeCover(): void;
saveMetadata(): Promise<void>;
starFill(i: number): string;
ratingText(): string;
setRating(value: number): Promise<void>;
clearRating(): Promise<void>;
toggleRead(read: boolean): Promise<void>;
resolveConflict(conflictId: string, winner: string): Promise<void>;
}
@@ -136,6 +147,12 @@ Alpine.data("bookDetail", () => {
coverFile: null as Blob | null,
coverAction: "keep",
saving: false,
userRating: 0,
ratingHover: 0,
ratingSaving: false,
conflictId: "",
conflictWinner: "",
readSaving: false,
editorTags: initialTags,
tagSearch: "",
tagSuggestions: [] as TagSuggestion[],
@@ -178,6 +195,15 @@ Alpine.data("bookDetail", () => {
},
init() {
const ratingAttr = document.body.getAttribute("data-rating");
this.userRating = ratingAttr ? parseInt(ratingAttr, 10) || 0 : 0;
const conflictId = document.body.getAttribute("data-conflict-id");
const conflictWinner = document.body.getAttribute("data-conflict-winner");
this.conflictId = conflictId && conflictId !== "null" ? conflictId : "";
this.conflictWinner =
conflictWinner && conflictWinner !== "null" ? conflictWinner : "";
const link = document.getElementById("back-link");
if (!link) return;
const storageKey = "book_detail_back";
@@ -207,6 +233,150 @@ Alpine.data("bookDetail", () => {
this.openSections[section] = !this.openSections[section];
},
starFill(i: number): string {
const display = this.ratingHover || this.userRating;
if (i * 2 <= display) {
return "color: var(--accent);";
} else if (i * 2 - 1 === display) {
return "background: linear-gradient(90deg, var(--accent) 50%, var(--text-secondary) 50%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;";
}
return "color: var(--text-secondary);";
},
ratingText(): string {
if (this.userRating === 0) return "(not rated)";
return `(${(this.userRating / 2).toFixed(1)} / 5)`;
},
async setRating(value: number) {
if (this.ratingSaving) return;
this.ratingSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
method: "POST",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ rating: value }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to save rating");
}
this.userRating = value;
this.ratingHover = 0;
showToast("Rating saved", "success");
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to save rating",
"error",
);
} finally {
this.ratingSaving = false;
}
},
async clearRating() {
if (this.ratingSaving) return;
this.ratingSaving = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/rating`, {
method: "DELETE",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to clear rating");
}
this.userRating = 0;
this.ratingHover = 0;
showToast("Rating cleared", "success");
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to clear rating",
"error",
);
} finally {
this.ratingSaving = false;
}
},
async toggleRead(read: boolean) {
if (this.readSaving) return;
this.readSaving = true;
const mediaId = getMediaId();
try {
// Clear any active sync conflict first. Resolving arms a 10-minute
// suppression window so the progress write below does not spawn a new
// conflict. The winner only needs to be a valid source key; the final
// read/unread state is set by the progress write that follows.
if (this.conflictId && this.conflictWinner) {
const cr = await fetch(
`/api/conflicts/${this.conflictId}/resolve`,
{
method: "POST",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ winner: this.conflictWinner }),
},
);
// 400 means it was already resolved - treat as no conflict.
if (!cr.ok && cr.status !== 400) {
const err = await cr.json().catch(() => ({}));
throw new Error(
err.error || err.message || "Failed to clear sync conflict",
);
}
}
if (read) {
// Mark as Read: PUT percentage 1.0. (Cannot PUT 0 to unread - the
// server silently ignores percentage < 0.005 when progress > 0.01.)
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
method: "PUT",
headers: {
Authorization: getAuthHeader(),
"Content-Type": "application/json",
},
body: JSON.stringify({ percentage: 1.0 }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to mark as read");
}
} else {
// Mark as Unread: DELETE the progress row. Notes, highlights and
// ratings are independent and are NOT affected.
const resp = await fetch(`/api/media-items/${mediaId}/progress`, {
method: "DELETE",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to mark as unread");
}
}
showToast(
read ? "Marked as read" : "Marked as unread",
"success",
);
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to update read state",
"error",
);
} finally {
this.readSaving = false;
}
},
handleCoverUpload(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
+10 -20
View File
@@ -1,5 +1,4 @@
import { Alpine } from "./alpine";
import { setSelectedLibrary } from "./storage";
let searchInputTimeout: ReturnType<typeof setTimeout> | null = null;
const SEARCH_DEBOUNCE_MS = 300;
@@ -190,12 +189,6 @@ function showSearchResults(results: MediaItemSummary[], query: string, activeId?
searchResults.dataset.selectedIndex = "-1";
const libraryIconMap: Record<string, string> = {
ebooks: "📚",
comics: "📖",
manga: "🗾",
};
let html = `
<div class="p-3 border-b" style="border-color: var(--border)">
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">
@@ -206,19 +199,23 @@ function showSearchResults(results: MediaItemSummary[], query: string, activeId?
`;
results.forEach((item, index) => {
const icon = libraryIconMap[item.library_type_name] || "📁";
const titleHtml = highlightMatch(item.title, query);
const authorHtml = item.author ? highlightMatch(item.author, query) : "";
const coverUrl = item.cover_image_path || "/static/placeholder-book.svg";
html += `
<div class="search-result-item p-3 border-b hover:bg-opacity-50 transition-colors cursor-pointer"
style="border-color: var(--border); background-color: var(--bg-secondary)"
data-index="${index}">
<a href="/bookshelf"
class="block"
onclick="window.selectLibraryAndBook('${item.library_id}', '${item.id}')">
<a href="/media/${item.id}" class="block">
<div class="flex items-start space-x-3">
<div class="text-2xl">${icon}</div>
<div class="w-10 h-14 flex-shrink-0 rounded overflow-hidden bg-gradient-to-br from-gray-700 to-gray-900">
<img src="${coverUrl}"
alt="${searchEscapeHtml(item.title)}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-medium truncate" style="color: var(--text-primary)">
${titleHtml}
@@ -323,15 +320,8 @@ function searchEscapeHtml(text: string): string {
return div.innerHTML;
}
function selectLibraryAndBook(libraryId: string, bookId: string): void {
setSelectedLibrary(libraryId);
localStorage.setItem("selectedBook", bookId);
hideSearchResults();
}
export { selectLibraryAndBook, initializeSearch };
export { initializeSearch };
Alpine.data("search", () => ({
selectLibraryAndBook,
initializeSearch,
}));
-10
View File
@@ -45,14 +45,6 @@ function setSelectedLibrary(libraryId: string): void {
document.cookie = `selectedLibrary=${encodeURIComponent(value)};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
}
function getSelectedBook(): string | null {
return localStorage.getItem("selectedBook");
}
function setSelectedBook(bookId: string): void {
localStorage.setItem("selectedBook", bookId);
}
function clearAll(): void {
localStorage.clear();
}
@@ -61,14 +53,12 @@ export {
ALL_LIBRARIES,
clearAll,
getRefreshToken,
getSelectedBook,
getSelectedLibrary,
getTheme,
getToken,
removeRefreshToken,
removeToken,
setRefreshToken,
setSelectedBook,
setSelectedLibrary,
setTheme,
setToken