Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0040fe428 | ||
|
|
59d5de3607 | ||
|
|
fffe0b17e6 | ||
|
|
451aa48aec | ||
|
|
5ac407057e | ||
|
|
bf83492bf7 | ||
|
|
33c69e7c71 | ||
|
|
c2f72ca785 | ||
|
|
ca8c592496 | ||
|
|
1f5b0d0164 | ||
|
|
13cc689bff | ||
|
|
9920fd47b9 | ||
|
|
26f695f480 | ||
|
|
05370d236a | ||
|
|
114a4574b0 | ||
|
|
3c9e4d8126 |
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -90,7 +90,7 @@ func TestCalibreLibraryScan(t *testing.T) {
|
||||
// Create scanner and configure it
|
||||
scanner := services.NewMediaScanner(setup.DB)
|
||||
scanner.SetAdminID(adminID)
|
||||
err = scanner.SetFolders([]string{tmpDir})
|
||||
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||
require.NoError(t, err, "Failed to set scanner folders")
|
||||
|
||||
// Scan library
|
||||
@@ -167,7 +167,7 @@ func TestCalibreLibraryScanWithoutSidecar(t *testing.T) {
|
||||
// Create scanner and configure it
|
||||
scanner := services.NewMediaScanner(setup.DB)
|
||||
scanner.SetAdminID(adminID)
|
||||
err = scanner.SetFolders([]string{tmpDir})
|
||||
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||
require.NoError(t, err, "Failed to set scanner folders")
|
||||
|
||||
// Scan library
|
||||
|
||||
@@ -31,6 +31,7 @@ services:
|
||||
app:
|
||||
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
||||
container_name: bookhoard
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Database Configuration
|
||||
DATABASE_HOST: db
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -128,8 +128,8 @@ func (h *Handler) StartScanner(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||
}
|
||||
|
||||
// Set the folder paths
|
||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
||||
// Set the folder paths (watch=true: this long-lived scanner reads events)
|
||||
if err := h.scanner.SetFolders(req.FolderPaths, true); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype
|
||||
}
|
||||
|
||||
scanner := services.NewMediaScanner(h.db)
|
||||
if err := scanner.SetFolders(folderPaths); err != nil {
|
||||
if err := scanner.SetFolders(folderPaths, true); err != nil {
|
||||
return fmt.Errorf("failed to set scanner folders: %v", err)
|
||||
}
|
||||
|
||||
|
||||
+89
-17
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,16 +146,18 @@ type CalibreOPFMetadata struct {
|
||||
Timestamp *time.Time
|
||||
}
|
||||
|
||||
// NewMediaScanner creates a new media scanner instance
|
||||
// NewMediaScanner creates a new media scanner instance.
|
||||
//
|
||||
// The fsnotify watcher is NOT created here. It is created lazily inside
|
||||
// SetFolders only when watch=true (the long-lived watch-mode scanner).
|
||||
// Ephemeral one-off scan jobs pass watch=false, so they never allocate a
|
||||
// watcher (and thus can never panic on EMFILE/ENOSPC). This fixes the
|
||||
// fd/inotify-watch leak where every scan job created a watcher that was
|
||||
// never closed.
|
||||
func NewMediaScanner(db *database.Queries) *MediaScanner {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to create file watcher: %v", err))
|
||||
}
|
||||
|
||||
return &MediaScanner{
|
||||
db: db,
|
||||
watcher: watcher,
|
||||
watcher: nil,
|
||||
settingsCache: NewSettingsCache(30 * time.Second),
|
||||
dirtyDirs: make(map[string]time.Time),
|
||||
fileStability: make(map[string]*atomic.Bool),
|
||||
@@ -238,24 +240,34 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
||||
return s.totalFiles, s.newItems, s.errors
|
||||
}
|
||||
|
||||
func (s *MediaScanner) SetFolders(folders []string) error {
|
||||
// SetFolders configures the scanner's folders and (optionally) sets up an
|
||||
// fsnotify watcher over the full directory tree.
|
||||
//
|
||||
// watch should be true only for the single long-lived watch-mode scanner that
|
||||
// actually consumes watcher.Events. Ephemeral scan jobs must pass false so no
|
||||
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
||||
// read by scan jobs and previously leaked one watcher per job.
|
||||
func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
||||
s.folders = folders
|
||||
|
||||
// Remove old watch if exists
|
||||
// Always close any previously-owned watcher so reconfiguration doesn't leak.
|
||||
if s.watcher != nil {
|
||||
if s.watcher != nil {
|
||||
if err := s.watcher.Close(); err != nil {
|
||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||
}
|
||||
if err := s.watcher.Close(); err != nil {
|
||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||
}
|
||||
s.watcher = nil
|
||||
}
|
||||
|
||||
// Create new watcher
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create watcher: %v", err)
|
||||
// Create + populate a fresh watcher only when the caller intends to read events.
|
||||
if watch {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
// Return an error instead of panicking so a failed watcher can't
|
||||
// take down the whole process.
|
||||
return fmt.Errorf("failed to create watcher: %w", err)
|
||||
}
|
||||
s.watcher = watcher
|
||||
}
|
||||
s.watcher = watcher
|
||||
|
||||
// Build cache of allowed extensions per folder
|
||||
// Uses Go AllowedExtensions map as source of truth (not DB)
|
||||
@@ -286,31 +298,36 @@ func (s *MediaScanner) SetFolders(folders []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Add all folders and their subdirectories to the watcher (like Audiobookshelf)
|
||||
watchCount := 0
|
||||
for _, folder := range folders {
|
||||
if err := s.watcher.Add(folder); err != nil {
|
||||
fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err)
|
||||
} else {
|
||||
watchCount++
|
||||
}
|
||||
filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() || path == folder {
|
||||
return nil
|
||||
}
|
||||
if err := s.watcher.Add(path); err != nil {
|
||||
fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||
// Add all folders and their subdirectories to the watcher (like Audiobookshelf).
|
||||
// Only when watching; scan jobs (watch=false) skip this entirely.
|
||||
if s.watcher != nil {
|
||||
watchCount := 0
|
||||
for _, folder := range folders {
|
||||
if err := s.watcher.Add(folder); err != nil {
|
||||
fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err)
|
||||
} else {
|
||||
watchCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() || path == folder {
|
||||
return nil
|
||||
}
|
||||
if err := s.watcher.Add(path); err != nil {
|
||||
fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||
} else {
|
||||
watchCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders))
|
||||
fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders))
|
||||
} else {
|
||||
fmt.Printf("[SCANNER] Configured %d root folders (watch mode disabled, no inotify watcher)\n", len(folders))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -417,8 +434,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
if err := s.watcher.Add(path); err != nil {
|
||||
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||
if s.watcher != nil {
|
||||
if err := s.watcher.Add(path); err != nil {
|
||||
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2601,6 +2620,13 @@ func (s *MediaScanner) WatchChanges(ctx context.Context) error {
|
||||
go s.startBackupScan(ctx)
|
||||
|
||||
go func() {
|
||||
// The event loop only runs if a real watcher was set up (watch=true).
|
||||
// If watching with no watcher (e.g. inotify unavailable through a Docker
|
||||
// bind mount), polling via startBackupScan above still handles detection.
|
||||
if s.watcher == nil {
|
||||
fmt.Printf("[WATCHER] No inotify watcher configured; relying on periodic polling for change detection\n")
|
||||
return
|
||||
}
|
||||
fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders))
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -205,7 +205,23 @@ func (w *Worker) worker() {
|
||||
return
|
||||
}
|
||||
|
||||
w.processJob(job)
|
||||
// Recover from any panic inside a job so a single failing job can
|
||||
// never crash the whole worker goroutine (and thus the process).
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Printf("[WORKER] panic in job %s (%s): %v\n", job.ID, job.Type, r)
|
||||
w.mu.Lock()
|
||||
w.results[job.ID] = &JobResult{
|
||||
JobID: job.ID,
|
||||
Status: JobStatusFailed,
|
||||
Error: fmt.Sprintf("panic: %v", r),
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
w.processJob(job)
|
||||
}()
|
||||
|
||||
case <-w.ctx.Done():
|
||||
return
|
||||
@@ -348,6 +364,7 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
|
||||
}
|
||||
|
||||
scanner := NewMediaScanner(db)
|
||||
defer scanner.Close()
|
||||
scanner.job = job
|
||||
|
||||
job.ProgressCallback = func(progress float64, filesScanned, newItems, errors int) {
|
||||
@@ -377,7 +394,7 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.SetFolders(folders); err != nil {
|
||||
if err := scanner.SetFolders(folders, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -521,7 +538,8 @@ func (w *Worker) processSetFoldersJob(job *Job) (interface{}, error) {
|
||||
|
||||
// Create scanner and configure folders
|
||||
scanner := NewMediaScanner(db)
|
||||
if err := scanner.SetFolders(folders); err != nil {
|
||||
defer scanner.Close()
|
||||
if err := scanner.SetFolders(folders, false); err != nil {
|
||||
return nil, fmt.Errorf("failed to set folders: %w", err)
|
||||
}
|
||||
|
||||
@@ -900,6 +918,7 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) {
|
||||
|
||||
// Create temporary scanner instance for this job
|
||||
scanner := NewMediaScanner(db)
|
||||
defer scanner.Close()
|
||||
scanner.job = job
|
||||
// Find which library owns this directory (prefix match for subdirectories)
|
||||
ctx := context.Background()
|
||||
@@ -918,7 +937,7 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) {
|
||||
folderPaths = append(folderPaths, f.FolderPath)
|
||||
}
|
||||
// Configure scanner with folders
|
||||
if err := scanner.SetFolders(folderPaths); err != nil {
|
||||
if err := scanner.SetFolders(folderPaths, false); err != nil {
|
||||
return nil, fmt.Errorf("failed to set folders: %w", err)
|
||||
}
|
||||
// Now scan the directory
|
||||
|
||||
@@ -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}"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+575
-542
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -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 -->
|
||||
|
||||
+120
-68
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type LibraryData struct {
|
||||
Name string
|
||||
Description string
|
||||
TypeName string
|
||||
MediaCount int64
|
||||
}
|
||||
|
||||
type SeriesCardData struct {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user