Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fa8ee3a59 | ||
|
|
72d167005f | ||
|
|
bf2c2825ac | ||
|
|
cd119a74da | ||
|
|
14445a7c3f | ||
|
|
e6aceae0da | ||
|
|
44b98f3fc3 | ||
|
|
61681aac23 | ||
|
|
7a9a66fcc5 | ||
|
|
2df3ecbf36 | ||
|
|
9da193e718 | ||
|
|
7f64b92b9d | ||
|
|
b9645752f5 | ||
|
|
9c8337d0a8 | ||
|
|
da1f689263 | ||
|
|
298330a3a8 | ||
|
|
0614795ca2 | ||
|
|
6aa958c78f | ||
|
|
3514b4dc1c | ||
|
|
ae87c0cd6a | ||
|
|
b6f507b9e5 | ||
|
|
ce3ae31ced | ||
|
|
75c1d9bb95 | ||
|
|
905218dd4b | ||
|
|
94dad5e089 | ||
|
|
2366faccce |
@@ -35,5 +35,9 @@ DBPASS=your-secure-database-password-here
|
|||||||
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
||||||
# Conversion Cache TTL: Override default 24h
|
# Conversion Cache TTL: Override default 24h
|
||||||
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
||||||
|
# Archive Retention: days to keep archived items (files missing from disk for
|
||||||
|
# 2+ scans) before they are purged, deleting their reading history with them.
|
||||||
|
# Set 0 to keep archived items until purged manually on the library admin page.
|
||||||
|
# ARCHIVE_RETENTION_DAYS=90
|
||||||
# System timezone (fallback for server-side time operations, defaults to UTC)
|
# System timezone (fallback for server-side time operations, defaults to UTC)
|
||||||
# TZ=America/New_York
|
# TZ=America/New_York
|
||||||
+2
-2
@@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
|
|||||||
# This stage is ONLY used for running tests, never deployed to production
|
# This stage is ONLY used for running tests, never deployed to production
|
||||||
FROM golang:1.26-alpine AS test-runner
|
FROM golang:1.26-alpine AS test-runner
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates curl
|
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
|
|||||||
# Final stage
|
# Final stage
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates curl
|
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||||
|
|
||||||
# Install kepubify for EPUB→KEPUB conversion
|
# Install kepubify for EPUB→KEPUB conversion
|
||||||
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
info:
|
||||||
|
name: Rescan Media Item
|
||||||
|
type: http
|
||||||
|
seq: 9
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: POST
|
||||||
|
url: "{{base_url}}/api/media-items/{{media_item_id}}/rescan"
|
||||||
|
headers:
|
||||||
|
- name: ""
|
||||||
|
value: application/json
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
runtime:
|
||||||
|
scripts:
|
||||||
|
- type: tests
|
||||||
|
code: |-
|
||||||
|
test_rescan_media_item_success(status, headers, body) {
|
||||||
|
if (status !== 200) {
|
||||||
|
throw new Error("Expected status 200, got " + status);
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Rescan Media Item
|
||||||
|
|
||||||
|
Re-extracts metadata and cover art for a single media item from the file on disk.
|
||||||
|
|
||||||
|
**Method:** POST
|
||||||
|
|
||||||
|
**Endpoint:** /api/media-items/{id}/rescan
|
||||||
@@ -207,6 +207,19 @@ CREATE TABLE IF NOT EXISTS media_items (
|
|||||||
-- Summary (distinct from description - may merge with Calibre description)
|
-- Summary (distinct from description - may merge with Calibre description)
|
||||||
summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre)
|
summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre)
|
||||||
|
|
||||||
|
-- Per-field user-override tracking. Column names listed here (e.g.
|
||||||
|
-- 'description', 'tags', 'cover_image_path') are user-customized and MUST
|
||||||
|
-- NOT be overwritten by library scans or per-book rescans; only the
|
||||||
|
-- reset-to-scanned-defaults action clears them.
|
||||||
|
metadata_overrides TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Archive lifecycle: a file missing from disk for two consecutive scans
|
||||||
|
-- gets archived_at set (hidden from all browsing, reading history kept).
|
||||||
|
-- If the file returns, the row is un-archived. Archived rows are purged
|
||||||
|
-- after ARCHIVE_RETENTION_DAYS (0 = manual purge only).
|
||||||
|
missing_scan_count INT NOT NULL DEFAULT 0,
|
||||||
|
archived_at TIMESTAMPTZ,
|
||||||
|
|
||||||
-- Chapter metadata for reader navigation and progress tracking
|
-- Chapter metadata for reader navigation and progress tracking
|
||||||
-- Caches detected chapter structure to avoid re-parsing files
|
-- Caches detected chapter structure to avoid re-parsing files
|
||||||
-- Populated by ReaderService.DetectChapters() on first read
|
-- Populated by ReaderService.DetectChapters() on first read
|
||||||
@@ -232,6 +245,16 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
|
|||||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
||||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
||||||
|
|
||||||
|
-- Per-field user-override tracking (idempotent backfill for existing
|
||||||
|
-- installs; the column is also declared in the media_items table
|
||||||
|
-- definition above for fresh databases)
|
||||||
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS metadata_overrides TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Archive lifecycle (idempotent backfill for existing installs)
|
||||||
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS missing_scan_count INT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_items_archived ON media_items (archived_at) WHERE archived_at IS NOT NULL;
|
||||||
|
|
||||||
-- Create GIN indexes for fast search field searches
|
-- Create GIN indexes for fast search field searches
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
|
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
|
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ services:
|
|||||||
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
||||||
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
||||||
|
|
||||||
|
# Library Maintenance
|
||||||
|
# Days an archived item (file missing from disk for 2+ scans) is kept,
|
||||||
|
# with its reading history, before library scans purge it for good.
|
||||||
|
# Set 0 to keep archived items until purged manually on the library admin page.
|
||||||
|
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}
|
||||||
|
|
||||||
# System timezone (fallback for server-side time operations)
|
# System timezone (fallback for server-side time operations)
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -84,3 +84,14 @@ func getEnvInt(key string, defaultValue int) int {
|
|||||||
}
|
}
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArchiveRetentionDays returns how many days a media item stays archived
|
||||||
|
// (file missing from disk for two consecutive scans) before library scans
|
||||||
|
// purge it for good. Reading progress, notes, and highlights survive the
|
||||||
|
// archive window and are restored if the file returns; the purge deletes
|
||||||
|
// them along with the row.
|
||||||
|
// Configure via ARCHIVE_RETENTION_DAYS (default 90); 0 keeps archived items
|
||||||
|
// until an admin purges them manually from the library admin page.
|
||||||
|
func ArchiveRetentionDays() int {
|
||||||
|
return getEnvInt("ARCHIVE_RETENTION_DAYS", 90)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -306,6 +306,9 @@ type MediaItems struct {
|
|||||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||||
// Summary from ComicInfo.xml (may be merged with description from Calibre)
|
// Summary from ComicInfo.xml (may be merged with description from Calibre)
|
||||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||||
|
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||||
|
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||||
|
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -20,6 +20,9 @@ type Querier interface {
|
|||||||
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
||||||
// Library Folders queries
|
// Library Folders queries
|
||||||
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
|
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
|
||||||
|
// Second consecutive missing scan: hide the item from all browsing while
|
||||||
|
// preserving reading history in case the file returns.
|
||||||
|
ArchiveMediaItem(ctx context.Context, id pgtype.UUID) error
|
||||||
// Bulk update format group for all media items
|
// Bulk update format group for all media items
|
||||||
BulkUpdateFormatGroups(ctx context.Context) error
|
BulkUpdateFormatGroups(ctx context.Context) error
|
||||||
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
||||||
@@ -30,7 +33,14 @@ type Querier interface {
|
|||||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||||
|
// File is back on disk (by path or content hash): restore visibility and
|
||||||
|
// reset the missing-scan counter. No-op for items that were never archived.
|
||||||
|
ClearMediaItemArchive(ctx context.Context, id pgtype.UUID) error
|
||||||
|
// Reset an item to scanned defaults: clears per-field user overrides so the
|
||||||
|
// next rescan can freely overwrite user-customized metadata.
|
||||||
|
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
|
||||||
CountAdmins(ctx context.Context) (int64, error)
|
CountAdmins(ctx context.Context) (int64, error)
|
||||||
|
CountArchivedMediaItems(ctx context.Context) (int64, error)
|
||||||
// Count unlinked books for a device
|
// Count unlinked books for a device
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
@@ -340,6 +350,7 @@ type Querier interface {
|
|||||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||||
|
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
|
||||||
// List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
// List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||||
ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error)
|
ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error)
|
||||||
// List media items that have no stored SHA-256 (imported before hashing existed)
|
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||||
@@ -353,6 +364,13 @@ type Querier interface {
|
|||||||
// List unresolved unlinked books with pagination
|
// List unresolved unlinked books with pagination
|
||||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||||
|
// First consecutive scan that cannot find the file on disk.
|
||||||
|
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
||||||
|
// Manual bulk purge from the library admin page.
|
||||||
|
PurgeAllArchivedMediaItems(ctx context.Context) ([]PurgeAllArchivedMediaItemsRow, error)
|
||||||
|
// Retention sweep at library-scan time: hard-delete archived items older
|
||||||
|
// than the cutoff. Cascades remove reading history with the row.
|
||||||
|
PurgeExpiredArchivedMediaItems(ctx context.Context, archivedAt pgtype.Timestamptz) ([]PurgeExpiredArchivedMediaItemsRow, error)
|
||||||
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
// source: queries.sql
|
// source: queries.sql
|
||||||
|
|
||||||
package database
|
package database
|
||||||
@@ -107,6 +107,18 @@ func (q *Queries) AddLibraryFolder(ctx context.Context, arg AddLibraryFolderPara
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ArchiveMediaItem = `-- name: ArchiveMediaItem :exec
|
||||||
|
UPDATE media_items SET archived_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Second consecutive missing scan: hide the item from all browsing while
|
||||||
|
// preserving reading history in case the file returns.
|
||||||
|
func (q *Queries) ArchiveMediaItem(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, ArchiveMediaItem, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const BulkUpdateFormatGroups = `-- name: BulkUpdateFormatGroups :exec
|
const BulkUpdateFormatGroups = `-- name: BulkUpdateFormatGroups :exec
|
||||||
UPDATE media_items m
|
UPDATE media_items m
|
||||||
SET
|
SET
|
||||||
@@ -226,6 +238,30 @@ func (q *Queries) ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfBy
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ClearMediaItemArchive = `-- name: ClearMediaItemArchive :exec
|
||||||
|
UPDATE media_items SET archived_at = NULL, missing_scan_count = 0, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND (archived_at IS NOT NULL OR missing_scan_count > 0)
|
||||||
|
`
|
||||||
|
|
||||||
|
// File is back on disk (by path or content hash): restore visibility and
|
||||||
|
// reset the missing-scan counter. No-op for items that were never archived.
|
||||||
|
func (q *Queries) ClearMediaItemArchive(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, ClearMediaItemArchive, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClearMediaItemMetadataOverrides = `-- name: ClearMediaItemMetadataOverrides :exec
|
||||||
|
UPDATE media_items SET metadata_overrides = '{}', updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Reset an item to scanned defaults: clears per-field user overrides so the
|
||||||
|
// next rescan can freely overwrite user-customized metadata.
|
||||||
|
func (q *Queries) ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, ClearMediaItemMetadataOverrides, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const CountAdmins = `-- name: CountAdmins :one
|
const CountAdmins = `-- name: CountAdmins :one
|
||||||
SELECT COUNT(*) FROM users WHERE role = 'admin'
|
SELECT COUNT(*) FROM users WHERE role = 'admin'
|
||||||
`
|
`
|
||||||
@@ -237,6 +273,17 @@ func (q *Queries) CountAdmins(ctx context.Context) (int64, error) {
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CountArchivedMediaItems = `-- name: CountArchivedMediaItems :one
|
||||||
|
SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) CountArchivedMediaItems(ctx context.Context) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, CountArchivedMediaItems)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
|
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
|
||||||
SELECT COUNT(*) as count
|
SELECT COUNT(*) as count
|
||||||
FROM unlinked_books
|
FROM unlinked_books
|
||||||
@@ -900,7 +947,7 @@ const CreateMediaItem = `-- name: CreateMediaItem :one
|
|||||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
||||||
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
||||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateMediaItemParams struct {
|
type CreateMediaItemParams struct {
|
||||||
@@ -1053,6 +1100,9 @@ func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -2447,7 +2497,7 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetBooksByTag = `-- name: GetBooksByTag :many
|
const GetBooksByTag = `-- name: GetBooksByTag :many
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
|
||||||
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
||||||
ORDER BY title ASC
|
ORDER BY title ASC
|
||||||
`
|
`
|
||||||
@@ -2520,6 +2570,9 @@ func (q *Queries) GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -2619,7 +2672,7 @@ func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UU
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many
|
const GetCollectionItemsForDashboard = `-- name: GetCollectionItemsForDashboard :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, ci.excluded FROM media_items mi
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, ci.excluded FROM media_items mi
|
||||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||||
WHERE ci.collection_id = $1
|
WHERE ci.collection_id = $1
|
||||||
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
||||||
@@ -2687,6 +2740,9 @@ type GetCollectionItemsForDashboardRow struct {
|
|||||||
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
||||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||||
|
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||||
|
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||||
|
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||||
@@ -2761,6 +2817,9 @@ func (q *Queries) GetCollectionItemsForDashboard(ctx context.Context, arg GetCol
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -2913,7 +2972,7 @@ func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetContinueReadingItems = `-- name: GetContinueReadingItems :many
|
const GetContinueReadingItems = `-- name: GetContinueReadingItems :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
||||||
FROM reading_progress
|
FROM reading_progress
|
||||||
@@ -2997,6 +3056,9 @@ func (q *Queries) GetContinueReadingItems(ctx context.Context, arg GetContinueRe
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -3030,15 +3092,16 @@ WITH user_series_progress AS (
|
|||||||
GROUP BY mi.series
|
GROUP BY mi.series
|
||||||
),
|
),
|
||||||
next_books AS (
|
next_books AS (
|
||||||
SELECT DISTINCT ON (mi.series) mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence,
|
SELECT DISTINCT ON (mi.series) mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence,
|
||||||
usp.last_read_at
|
usp.last_read_at
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN user_series_progress usp ON mi.series = usp.series
|
JOIN user_series_progress usp ON mi.series = usp.series
|
||||||
WHERE ($3::uuid IS NULL OR mi.library_id = $3::uuid)
|
WHERE mi.archived_at IS NULL
|
||||||
|
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid)
|
||||||
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
|
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
|
||||||
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
||||||
)
|
)
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, last_read_at FROM next_books
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, last_read_at FROM next_books
|
||||||
ORDER BY last_read_at DESC NULLS LAST
|
ORDER BY last_read_at DESC NULLS LAST
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
`
|
`
|
||||||
@@ -3103,6 +3166,9 @@ type GetContinueSeriesItemsRow struct {
|
|||||||
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
||||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||||
|
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||||
|
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||||
|
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||||
@@ -3177,6 +3243,9 @@ func (q *Queries) GetContinueSeriesItems(ctx context.Context, arg GetContinueSer
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -4306,7 +4375,7 @@ func (q *Queries) GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetLibraryItems = `-- name: GetLibraryItems :many
|
const GetLibraryItems = `-- name: GetLibraryItems :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
||||||
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
ORDER BY mi.created_at DESC
|
ORDER BY mi.created_at DESC
|
||||||
`
|
`
|
||||||
@@ -4374,6 +4443,9 @@ func (q *Queries) GetLibraryItems(ctx context.Context, libraryID pgtype.UUID) ([
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -4805,7 +4877,7 @@ func (q *Queries) GetMediaHighlights(ctx context.Context, arg GetMediaHighlights
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetMediaItem = `-- name: GetMediaItem :one
|
const GetMediaItem = `-- name: GetMediaItem :one
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE id = $1
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) {
|
func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) {
|
||||||
@@ -4865,6 +4937,9 @@ func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems,
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -4878,7 +4953,7 @@ func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetMediaItemByFilePath = `-- name: GetMediaItemByFilePath :one
|
const GetMediaItemByFilePath = `-- name: GetMediaItemByFilePath :one
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 AND library_id = $2
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 AND library_id = $2
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetMediaItemByFilePathParams struct {
|
type GetMediaItemByFilePathParams struct {
|
||||||
@@ -4943,6 +5018,9 @@ func (q *Queries) GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemBy
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -4956,7 +5034,7 @@ func (q *Queries) GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemBy
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetMediaItemByFilePathAnyLibrary = `-- name: GetMediaItemByFilePathAnyLibrary :one
|
const GetMediaItemByFilePathAnyLibrary = `-- name: GetMediaItemByFilePathAnyLibrary :one
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 LIMIT 1
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath string) (MediaItems, error) {
|
func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath string) (MediaItems, error) {
|
||||||
@@ -5016,6 +5094,9 @@ func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -5030,7 +5111,7 @@ func (q *Queries) GetMediaItemByFilePathAnyLibrary(ctx context.Context, filePath
|
|||||||
|
|
||||||
const GetMediaItemByFilePathForSync = `-- name: GetMediaItemByFilePathForSync :one
|
const GetMediaItemByFilePathForSync = `-- name: GetMediaItemByFilePathForSync :one
|
||||||
|
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -5093,6 +5174,9 @@ func (q *Queries) GetMediaItemByFilePathForSync(ctx context.Context, filePath st
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -5106,7 +5190,7 @@ func (q *Queries) GetMediaItemByFilePathForSync(ctx context.Context, filePath st
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetMediaItemByKoboContentId = `-- name: GetMediaItemByKoboContentId :one
|
const GetMediaItemByKoboContentId = `-- name: GetMediaItemByKoboContentId :one
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE kobo_content_id = $1
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE kobo_content_id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error) {
|
func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error) {
|
||||||
@@ -5166,6 +5250,9 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -5179,7 +5266,7 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetMediaItemByOPFIdentifier = `-- name: GetMediaItemByOPFIdentifier :one
|
const GetMediaItemByOPFIdentifier = `-- name: GetMediaItemByOPFIdentifier :one
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_identifier = $1
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_identifier = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
// Get media item by OPF identifier
|
// Get media item by OPF identifier
|
||||||
@@ -5240,6 +5327,9 @@ func (q *Queries) GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -5253,7 +5343,7 @@ func (q *Queries) GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetMediaItemByOPFUUID = `-- name: GetMediaItemByOPFUUID :one
|
const GetMediaItemByOPFUUID = `-- name: GetMediaItemByOPFUUID :one
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_uuid = $1
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_uuid = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
// Get media item by OPF UUID
|
// Get media item by OPF UUID
|
||||||
@@ -5314,6 +5404,9 @@ func (q *Queries) GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -5327,7 +5420,7 @@ func (q *Queries) GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetMediaItemBySHA256 = `-- name: GetMediaItemBySHA256 :one
|
const GetMediaItemBySHA256 = `-- name: GetMediaItemBySHA256 :one
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
// Get media item by SHA-256 hash
|
// Get media item by SHA-256 hash
|
||||||
@@ -5388,6 +5481,9 @@ func (q *Queries) GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Te
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -5401,7 +5497,7 @@ func (q *Queries) GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Te
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetMediaItemBySHA256AndLibrary = `-- name: GetMediaItemBySHA256AndLibrary :one
|
const GetMediaItemBySHA256AndLibrary = `-- name: GetMediaItemBySHA256AndLibrary :one
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetMediaItemBySHA256AndLibraryParams struct {
|
type GetMediaItemBySHA256AndLibraryParams struct {
|
||||||
@@ -5467,6 +5563,9 @@ func (q *Queries) GetMediaItemBySHA256AndLibrary(ctx context.Context, arg GetMed
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -5807,7 +5906,7 @@ func (q *Queries) GetNextRetryTime(ctx context.Context) (interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetNotStartedItems = `-- name: GetNotStartedItems :many
|
const GetNotStartedItems = `-- name: GetNotStartedItems :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
||||||
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM reading_progress rp
|
SELECT 1 FROM reading_progress rp
|
||||||
@@ -5888,6 +5987,9 @@ func (q *Queries) GetNotStartedItems(ctx context.Context, arg GetNotStartedItems
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -6207,7 +6309,7 @@ func (q *Queries) GetReadingSpeed(ctx context.Context, arg GetReadingSpeedParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetRecentlyAddedItems = `-- name: GetRecentlyAddedItems :many
|
const GetRecentlyAddedItems = `-- name: GetRecentlyAddedItems :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
||||||
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
||||||
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
@@ -6281,6 +6383,9 @@ func (q *Queries) GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAdde
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -6301,7 +6406,7 @@ func (q *Queries) GetRecentlyAddedItems(ctx context.Context, arg GetRecentlyAdde
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetRecentlyReadItems = `-- name: GetRecentlyReadItems :many
|
const GetRecentlyReadItems = `-- name: GetRecentlyReadItems :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
||||||
FROM reading_progress
|
FROM reading_progress
|
||||||
@@ -6383,6 +6488,9 @@ func (q *Queries) GetRecentlyReadItems(ctx context.Context, arg GetRecentlyReadI
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -6503,7 +6611,7 @@ func (q *Queries) GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GetSeriesBooks = `-- name: GetSeriesBooks :many
|
const GetSeriesBooks = `-- name: GetSeriesBooks :many
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items
|
||||||
WHERE series = $1
|
WHERE series = $1
|
||||||
ORDER BY series_number ASC NULLS LAST
|
ORDER BY series_number ASC NULLS LAST
|
||||||
`
|
`
|
||||||
@@ -6571,6 +6679,9 @@ func (q *Queries) GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]Med
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -7772,7 +7883,7 @@ const GetVisibleLibraryMediaCounts = `-- name: GetVisibleLibraryMediaCounts :man
|
|||||||
SELECT l.id, COUNT(mi.id) as media_count
|
SELECT l.id, COUNT(mi.id) as media_count
|
||||||
FROM libraries l
|
FROM libraries l
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
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
|
LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
GROUP BY l.id
|
GROUP BY l.id
|
||||||
`
|
`
|
||||||
@@ -8354,10 +8465,11 @@ func (q *Queries) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ListMediaItems = `-- name: ListMediaItems :many
|
const ListMediaItems = `-- name: ListMediaItems :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
|
WHERE mi.archived_at IS NULL
|
||||||
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2
|
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -8420,6 +8532,9 @@ type ListMediaItemsRow struct {
|
|||||||
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
||||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||||
|
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||||
|
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||||
|
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||||
@@ -8495,6 +8610,9 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams)
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -8517,11 +8635,12 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ListMediaItemsByLibrary = `-- name: ListMediaItemsByLibrary :many
|
const ListMediaItemsByLibrary = `-- name: ListMediaItemsByLibrary :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
WHERE mi.library_id = $1
|
WHERE mi.library_id = $1
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
ORDER BY mi.created_at DESC
|
ORDER BY mi.created_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -8579,6 +8698,9 @@ type ListMediaItemsByLibraryRow struct {
|
|||||||
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
||||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||||
|
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||||
|
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||||
|
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||||
@@ -8654,6 +8776,174 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
|
&i.ChapterMetadata,
|
||||||
|
&i.LibraryTypeName,
|
||||||
|
&i.TagsSearch,
|
||||||
|
&i.ContributorsSearch,
|
||||||
|
&i.FileSha256,
|
||||||
|
&i.OpfIdentifier,
|
||||||
|
&i.OpfUuid,
|
||||||
|
&i.HashConfidence,
|
||||||
|
&i.LibraryName,
|
||||||
|
&i.LibraryTypeName_2,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListMediaItemsByLibraryIncludingArchived = `-- name: ListMediaItemsByLibraryIncludingArchived :many
|
||||||
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
||||||
|
FROM media_items mi
|
||||||
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
|
WHERE mi.library_id = $1
|
||||||
|
ORDER BY mi.created_at DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListMediaItemsByLibraryIncludingArchivedRow struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
Author pgtype.Text `db:"author" json:"author"`
|
||||||
|
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||||
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
|
FilePath string `db:"file_path" json:"file_path"`
|
||||||
|
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||||
|
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
||||||
|
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||||
|
Series pgtype.Text `db:"series" json:"series"`
|
||||||
|
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
|
||||||
|
Tags []string `db:"tags" json:"tags"`
|
||||||
|
Asin pgtype.Text `db:"asin" json:"asin"`
|
||||||
|
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
||||||
|
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
||||||
|
Contributors []string `db:"contributors" json:"contributors"`
|
||||||
|
Language pgtype.Text `db:"language" json:"language"`
|
||||||
|
Edition pgtype.Text `db:"edition" json:"edition"`
|
||||||
|
PageCount pgtype.Int4 `db:"page_count" json:"page_count"`
|
||||||
|
Genre pgtype.Text `db:"genre" json:"genre"`
|
||||||
|
CopyrightYear pgtype.Int4 `db:"copyright_year" json:"copyright_year"`
|
||||||
|
GoodreadsID pgtype.Text `db:"goodreads_id" json:"goodreads_id"`
|
||||||
|
OpenlibraryID pgtype.Text `db:"openlibrary_id" json:"openlibrary_id"`
|
||||||
|
GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"`
|
||||||
|
AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
ImportedAt pgtype.Timestamptz `db:"imported_at" json:"imported_at"`
|
||||||
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
FormatGroup string `db:"format_group" json:"format_group"`
|
||||||
|
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
|
||||||
|
IsReflowable pgtype.Bool `db:"is_reflowable" json:"is_reflowable"`
|
||||||
|
HasFixedLayout pgtype.Bool `db:"has_fixed_layout" json:"has_fixed_layout"`
|
||||||
|
TotalCharacters pgtype.Int8 `db:"total_characters" json:"total_characters"`
|
||||||
|
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
|
||||||
|
EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"`
|
||||||
|
RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"`
|
||||||
|
KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"`
|
||||||
|
KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"`
|
||||||
|
MangaType pgtype.Text `db:"manga_type" json:"manga_type"`
|
||||||
|
ReadingDirection pgtype.Text `db:"reading_direction" json:"reading_direction"`
|
||||||
|
SeriesCount pgtype.Int4 `db:"series_count" json:"series_count"`
|
||||||
|
Volume pgtype.Int4 `db:"volume" json:"volume"`
|
||||||
|
Imprint pgtype.Text `db:"imprint" json:"imprint"`
|
||||||
|
AgeRating pgtype.Text `db:"age_rating" json:"age_rating"`
|
||||||
|
WebUrl pgtype.Text `db:"web_url" json:"web_url"`
|
||||||
|
StoryArc pgtype.Text `db:"story_arc" json:"story_arc"`
|
||||||
|
IsBlackAndWhite pgtype.Bool `db:"is_black_and_white" json:"is_black_and_white"`
|
||||||
|
MetadataNotes pgtype.Text `db:"metadata_notes" json:"metadata_notes"`
|
||||||
|
CommunityRating pgtype.Float8 `db:"community_rating" json:"community_rating"`
|
||||||
|
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
||||||
|
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||||
|
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||||
|
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||||
|
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||||
|
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||||
|
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||||
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||||
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||||
|
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
|
||||||
|
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
|
||||||
|
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
|
||||||
|
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
|
||||||
|
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
|
||||||
|
LibraryName string `db:"library_name" json:"library_name"`
|
||||||
|
LibraryTypeName_2 string `db:"library_type_name_2" json:"library_type_name_2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, ListMediaItemsByLibraryIncludingArchived, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []ListMediaItemsByLibraryIncludingArchivedRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListMediaItemsByLibraryIncludingArchivedRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.LibraryID,
|
||||||
|
&i.Title,
|
||||||
|
&i.Author,
|
||||||
|
&i.Isbn,
|
||||||
|
&i.Description,
|
||||||
|
&i.FilePath,
|
||||||
|
&i.FileSize,
|
||||||
|
&i.MimeType,
|
||||||
|
&i.CoverImagePath,
|
||||||
|
&i.Series,
|
||||||
|
&i.SeriesNumber,
|
||||||
|
&i.Tags,
|
||||||
|
&i.Asin,
|
||||||
|
&i.DatePublished,
|
||||||
|
&i.Publisher,
|
||||||
|
&i.Contributors,
|
||||||
|
&i.Language,
|
||||||
|
&i.Edition,
|
||||||
|
&i.PageCount,
|
||||||
|
&i.Genre,
|
||||||
|
&i.CopyrightYear,
|
||||||
|
&i.GoodreadsID,
|
||||||
|
&i.OpenlibraryID,
|
||||||
|
&i.GoogleBooksID,
|
||||||
|
&i.AddedByAdminID,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.ImportedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.FormatGroup,
|
||||||
|
&i.FormatMimetype,
|
||||||
|
&i.IsReflowable,
|
||||||
|
&i.HasFixedLayout,
|
||||||
|
&i.TotalCharacters,
|
||||||
|
&i.ChapterCount,
|
||||||
|
&i.EntitlementID,
|
||||||
|
&i.RevisionNumber,
|
||||||
|
&i.KoboContentID,
|
||||||
|
&i.KoboMetadata,
|
||||||
|
&i.MangaType,
|
||||||
|
&i.ReadingDirection,
|
||||||
|
&i.SeriesCount,
|
||||||
|
&i.Volume,
|
||||||
|
&i.Imprint,
|
||||||
|
&i.AgeRating,
|
||||||
|
&i.WebUrl,
|
||||||
|
&i.StoryArc,
|
||||||
|
&i.IsBlackAndWhite,
|
||||||
|
&i.MetadataNotes,
|
||||||
|
&i.CommunityRating,
|
||||||
|
&i.AlternateInfo,
|
||||||
|
&i.ScanInformation,
|
||||||
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -8676,7 +8966,7 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ListMediaItemsBySHA256AndLibrary = `-- name: ListMediaItemsBySHA256AndLibrary :many
|
const ListMediaItemsBySHA256AndLibrary = `-- name: ListMediaItemsBySHA256AndLibrary :many
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListMediaItemsBySHA256AndLibraryParams struct {
|
type ListMediaItemsBySHA256AndLibraryParams struct {
|
||||||
@@ -8748,6 +9038,9 @@ func (q *Queries) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg List
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -8768,7 +9061,7 @@ func (q *Queries) ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg List
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ListMediaItemsMissingHash = `-- name: ListMediaItemsMissingHash :many
|
const ListMediaItemsMissingHash = `-- name: ListMediaItemsMissingHash :many
|
||||||
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at
|
SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
// List media items that have no stored SHA-256 (imported before hashing existed)
|
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||||
@@ -8835,6 +9128,9 @@ func (q *Queries) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems,
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -8855,11 +9151,12 @@ func (q *Queries) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :many
|
const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
WHERE mi.library_id = $1
|
WHERE mi.archived_at IS NULL
|
||||||
|
AND mi.library_id = $1
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
WHEN $2 = 'title ASC' THEN mi.title
|
WHEN $2 = 'title ASC' THEN mi.title
|
||||||
@@ -8990,6 +9287,9 @@ type ListMediaItemsSortedRow struct {
|
|||||||
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
||||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||||
|
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||||
|
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||||
|
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||||
@@ -9070,6 +9370,9 @@ func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSo
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -9491,6 +9794,82 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MarkMediaItemMissing = `-- name: MarkMediaItemMissing :exec
|
||||||
|
UPDATE media_items SET missing_scan_count = missing_scan_count + 1, updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// First consecutive scan that cannot find the file on disk.
|
||||||
|
func (q *Queries) MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, MarkMediaItemMissing, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const PurgeAllArchivedMediaItems = `-- name: PurgeAllArchivedMediaItems :many
|
||||||
|
DELETE FROM media_items
|
||||||
|
WHERE archived_at IS NOT NULL
|
||||||
|
RETURNING id, title
|
||||||
|
`
|
||||||
|
|
||||||
|
type PurgeAllArchivedMediaItemsRow struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual bulk purge from the library admin page.
|
||||||
|
func (q *Queries) PurgeAllArchivedMediaItems(ctx context.Context) ([]PurgeAllArchivedMediaItemsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, PurgeAllArchivedMediaItems)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []PurgeAllArchivedMediaItemsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i PurgeAllArchivedMediaItemsRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.Title); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const PurgeExpiredArchivedMediaItems = `-- name: PurgeExpiredArchivedMediaItems :many
|
||||||
|
DELETE FROM media_items
|
||||||
|
WHERE archived_at IS NOT NULL AND archived_at < $1
|
||||||
|
RETURNING id, title
|
||||||
|
`
|
||||||
|
|
||||||
|
type PurgeExpiredArchivedMediaItemsRow struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
Title string `db:"title" json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retention sweep at library-scan time: hard-delete archived items older
|
||||||
|
// than the cutoff. Cascades remove reading history with the row.
|
||||||
|
func (q *Queries) PurgeExpiredArchivedMediaItems(ctx context.Context, archivedAt pgtype.Timestamptz) ([]PurgeExpiredArchivedMediaItemsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, PurgeExpiredArchivedMediaItems, archivedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []PurgeExpiredArchivedMediaItemsRow{}
|
||||||
|
for rows.Next() {
|
||||||
|
var i PurgeExpiredArchivedMediaItemsRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.Title); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const PurgeExpiredBookmarkTombstones = `-- name: PurgeExpiredBookmarkTombstones :exec
|
const PurgeExpiredBookmarkTombstones = `-- name: PurgeExpiredBookmarkTombstones :exec
|
||||||
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1
|
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1
|
||||||
`
|
`
|
||||||
@@ -10027,6 +10406,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND mi.library_id = $3
|
AND mi.library_id = $3
|
||||||
AND word_similarity($1, COALESCE(mi.author, '')) > 0.3
|
AND word_similarity($1, COALESCE(mi.author, '')) > 0.3
|
||||||
AND mi.author IS NOT NULL
|
AND mi.author IS NOT NULL
|
||||||
@@ -10085,6 +10465,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND mi.library_id = $3
|
AND mi.library_id = $3
|
||||||
AND word_similarity($1, COALESCE(mi.genre, '')) > 0.3
|
AND word_similarity($1, COALESCE(mi.genre, '')) > 0.3
|
||||||
AND mi.genre IS NOT NULL
|
AND mi.genre IS NOT NULL
|
||||||
@@ -10143,6 +10524,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND mi.library_id = $3
|
AND mi.library_id = $3
|
||||||
AND word_similarity($1, COALESCE(mi.language, '')) > 0.3
|
AND word_similarity($1, COALESCE(mi.language, '')) > 0.3
|
||||||
AND mi.language IS NOT NULL
|
AND mi.language IS NOT NULL
|
||||||
@@ -10193,12 +10575,13 @@ func (q *Queries) SearchLanguageValues(ctx context.Context, arg SearchLanguageVa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SearchMediaItems = `-- name: SearchMediaItems :many
|
const SearchMediaItems = `-- name: SearchMediaItems :many
|
||||||
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence, l.name as library_name, lt.name as library_type_name
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
AND ($2::uuid IS NULL OR mi.library_id = $2::uuid)
|
||||||
AND (
|
AND (
|
||||||
mi.title ILIKE $3 OR
|
mi.title ILIKE $3 OR
|
||||||
@@ -10281,6 +10664,9 @@ type SearchMediaItemsRow struct {
|
|||||||
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
||||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||||
|
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||||
|
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||||
|
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||||
@@ -10363,6 +10749,9 @@ func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsPara
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -10437,6 +10826,7 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND ($2::uuid IS NULL
|
AND ($2::uuid IS NULL
|
||||||
OR mi.library_id = $2::uuid)
|
OR mi.library_id = $2::uuid)
|
||||||
-- Fuzzy author filter
|
-- Fuzzy author filter
|
||||||
@@ -10721,6 +11111,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $2
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND mi.library_id = $3
|
AND mi.library_id = $3
|
||||||
AND word_similarity($1, COALESCE(mi.series, '')) > 0.3
|
AND word_similarity($1, COALESCE(mi.series, '')) > 0.3
|
||||||
AND mi.series IS NOT NULL
|
AND mi.series IS NOT NULL
|
||||||
@@ -11834,9 +12225,10 @@ UPDATE media_items SET
|
|||||||
alternate_info = $35,
|
alternate_info = $35,
|
||||||
scan_information = $36,
|
scan_information = $36,
|
||||||
summary = $37,
|
summary = $37,
|
||||||
|
metadata_overrides = $38,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateMediaItemParams struct {
|
type UpdateMediaItemParams struct {
|
||||||
@@ -11877,6 +12269,7 @@ type UpdateMediaItemParams struct {
|
|||||||
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
AlternateInfo []byte `db:"alternate_info" json:"alternate_info"`
|
||||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||||
|
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error) {
|
func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error) {
|
||||||
@@ -11918,6 +12311,7 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
|
|||||||
arg.AlternateInfo,
|
arg.AlternateInfo,
|
||||||
arg.ScanInformation,
|
arg.ScanInformation,
|
||||||
arg.Summary,
|
arg.Summary,
|
||||||
|
arg.MetadataOverrides,
|
||||||
)
|
)
|
||||||
var i MediaItems
|
var i MediaItems
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -11974,6 +12368,9 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -11990,7 +12387,7 @@ const UpdateMediaItemChapterMetadata = `-- name: UpdateMediaItemChapterMetadata
|
|||||||
UPDATE media_items
|
UPDATE media_items
|
||||||
SET chapter_metadata = $2, updated_at = NOW()
|
SET chapter_metadata = $2, updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateMediaItemChapterMetadataParams struct {
|
type UpdateMediaItemChapterMetadataParams struct {
|
||||||
@@ -12055,6 +12452,9 @@ func (q *Queries) UpdateMediaItemChapterMetadata(ctx context.Context, arg Update
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -12162,7 +12562,7 @@ SET
|
|||||||
hash_confidence = $5,
|
hash_confidence = $5,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateMediaItemIdentifiersParams struct {
|
type UpdateMediaItemIdentifiersParams struct {
|
||||||
@@ -12241,6 +12641,9 @@ func (q *Queries) UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMedi
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
@@ -12261,7 +12664,7 @@ SET entitlement_id = $2,
|
|||||||
kobo_metadata = $5,
|
kobo_metadata = $5,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, tags_search, contributors_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateMediaItemKoboMetadataParams struct {
|
type UpdateMediaItemKoboMetadataParams struct {
|
||||||
@@ -12335,6 +12738,9 @@ func (q *Queries) UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMed
|
|||||||
&i.AlternateInfo,
|
&i.AlternateInfo,
|
||||||
&i.ScanInformation,
|
&i.ScanInformation,
|
||||||
&i.Summary,
|
&i.Summary,
|
||||||
|
&i.MetadataOverrides,
|
||||||
|
&i.MissingScanCount,
|
||||||
|
&i.ArchivedAt,
|
||||||
&i.ChapterMetadata,
|
&i.ChapterMetadata,
|
||||||
&i.LibraryTypeName,
|
&i.LibraryTypeName,
|
||||||
&i.TagsSearch,
|
&i.TagsSearch,
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ ORDER BY l.created_at ASC;
|
|||||||
SELECT l.id, COUNT(mi.id) as media_count
|
SELECT l.id, COUNT(mi.id) as media_count
|
||||||
FROM libraries l
|
FROM libraries l
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
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
|
LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
GROUP BY l.id;
|
GROUP BY l.id;
|
||||||
|
|
||||||
@@ -160,6 +160,7 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
|
WHERE mi.archived_at IS NULL
|
||||||
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
||||||
|
|
||||||
-- name: ListMediaItemsByLibrary :many
|
-- name: ListMediaItemsByLibrary :many
|
||||||
@@ -167,6 +168,15 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
|
WHERE mi.library_id = $1
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
ORDER BY mi.created_at DESC;
|
||||||
|
|
||||||
|
-- name: ListMediaItemsByLibraryIncludingArchived :many
|
||||||
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||||
|
FROM media_items mi
|
||||||
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
WHERE mi.library_id = $1
|
WHERE mi.library_id = $1
|
||||||
ORDER BY mi.created_at DESC;
|
ORDER BY mi.created_at DESC;
|
||||||
|
|
||||||
@@ -175,7 +185,8 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
WHERE mi.library_id = sqlc.narg('library_id')
|
WHERE mi.archived_at IS NULL
|
||||||
|
AND mi.library_id = sqlc.narg('library_id')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
||||||
@@ -282,10 +293,50 @@ UPDATE media_items SET
|
|||||||
alternate_info = $35,
|
alternate_info = $35,
|
||||||
scan_information = $36,
|
scan_information = $36,
|
||||||
summary = $37,
|
summary = $37,
|
||||||
|
metadata_overrides = $38,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: ClearMediaItemMetadataOverrides :exec
|
||||||
|
-- Reset an item to scanned defaults: clears per-field user overrides so the
|
||||||
|
-- next rescan can freely overwrite user-customized metadata.
|
||||||
|
UPDATE media_items SET metadata_overrides = '{}', updated_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: MarkMediaItemMissing :exec
|
||||||
|
-- First consecutive scan that cannot find the file on disk.
|
||||||
|
UPDATE media_items SET missing_scan_count = missing_scan_count + 1, updated_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ArchiveMediaItem :exec
|
||||||
|
-- Second consecutive missing scan: hide the item from all browsing while
|
||||||
|
-- preserving reading history in case the file returns.
|
||||||
|
UPDATE media_items SET archived_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ClearMediaItemArchive :exec
|
||||||
|
-- File is back on disk (by path or content hash): restore visibility and
|
||||||
|
-- reset the missing-scan counter. No-op for items that were never archived.
|
||||||
|
UPDATE media_items SET archived_at = NULL, missing_scan_count = 0, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND (archived_at IS NOT NULL OR missing_scan_count > 0);
|
||||||
|
|
||||||
|
-- name: PurgeExpiredArchivedMediaItems :many
|
||||||
|
-- Retention sweep at library-scan time: hard-delete archived items older
|
||||||
|
-- than the cutoff. Cascades remove reading history with the row.
|
||||||
|
DELETE FROM media_items
|
||||||
|
WHERE archived_at IS NOT NULL AND archived_at < $1
|
||||||
|
RETURNING id, title;
|
||||||
|
|
||||||
|
-- name: PurgeAllArchivedMediaItems :many
|
||||||
|
-- Manual bulk purge from the library admin page.
|
||||||
|
DELETE FROM media_items
|
||||||
|
WHERE archived_at IS NOT NULL
|
||||||
|
RETURNING id, title;
|
||||||
|
|
||||||
|
-- name: CountArchivedMediaItems :one
|
||||||
|
SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL;
|
||||||
|
|
||||||
-- name: DeleteMediaItem :exec
|
-- name: DeleteMediaItem :exec
|
||||||
DELETE FROM media_items WHERE id = $1;
|
DELETE FROM media_items WHERE id = $1;
|
||||||
|
|
||||||
@@ -434,6 +485,7 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND (
|
AND (
|
||||||
mi.title ILIKE sqlc.narg('search_pattern') OR
|
mi.title ILIKE sqlc.narg('search_pattern') OR
|
||||||
@@ -506,6 +558,7 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND (sqlc.narg('library_id')::uuid IS NULL
|
AND (sqlc.narg('library_id')::uuid IS NULL
|
||||||
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
-- Fuzzy author filter
|
-- Fuzzy author filter
|
||||||
@@ -639,6 +692,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND mi.library_id = sqlc.narg('library_id')
|
AND mi.library_id = sqlc.narg('library_id')
|
||||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
|
||||||
AND mi.author IS NOT NULL
|
AND mi.author IS NOT NULL
|
||||||
@@ -656,6 +710,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND mi.library_id = sqlc.narg('library_id')
|
AND mi.library_id = sqlc.narg('library_id')
|
||||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
|
||||||
AND mi.genre IS NOT NULL
|
AND mi.genre IS NOT NULL
|
||||||
@@ -690,6 +745,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND mi.library_id = sqlc.narg('library_id')
|
AND mi.library_id = sqlc.narg('library_id')
|
||||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
|
||||||
AND mi.series IS NOT NULL
|
AND mi.series IS NOT NULL
|
||||||
@@ -707,6 +763,7 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
AND mi.library_id = sqlc.narg('library_id')
|
AND mi.library_id = sqlc.narg('library_id')
|
||||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
|
||||||
AND mi.language IS NOT NULL
|
AND mi.language IS NOT NULL
|
||||||
@@ -2454,7 +2511,8 @@ next_books AS (
|
|||||||
usp.last_read_at
|
usp.last_read_at
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN user_series_progress usp ON mi.series = usp.series
|
JOIN user_series_progress usp ON mi.series = usp.series
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
WHERE mi.archived_at IS NULL
|
||||||
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
|
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
|
||||||
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ func parseTableNames() ([]string, error) {
|
|||||||
tables := make(map[string]bool)
|
tables := make(map[string]bool)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
|
// Skip SQL comments: a doc line like "-- ... declared in CREATE
|
||||||
|
// TABLE above" would otherwise register a phantom table and fail
|
||||||
|
// startup verification.
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
matches := pattern.FindStringSubmatch(line)
|
matches := pattern.FindStringSubmatch(line)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
tableName := matches[1]
|
tableName := matches[1]
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestParseTableNamesIgnoresComments guards against phantom tables parsed out
|
||||||
|
// of SQL comments (e.g. "-- ... declared in CREATE TABLE above" once
|
||||||
|
// registered a table named "above" and crashed startup verification).
|
||||||
|
func TestParseTableNamesIgnoresComments(t *testing.T) {
|
||||||
|
tables, err := parseTableNames()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseTableNames() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(tables) == 0 {
|
||||||
|
t.Fatal("parseTableNames() returned no tables")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range tables {
|
||||||
|
// Every parsed table must correspond to a real CREATE TABLE statement
|
||||||
|
// at the start of a (non-comment) line.
|
||||||
|
stmt := regexp.MustCompile(`(?m)^CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?` + regexp.QuoteMeta(name) + `\s`)
|
||||||
|
if !stmt.MatchString(SchemaFile) {
|
||||||
|
t.Errorf("parseTableNames() returned phantom table %q with no matching CREATE TABLE statement", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,28 +81,39 @@ func (h *KOReaderHandler) loadAnnotationEpub(ctx context.Context, mediaItemID pg
|
|||||||
|
|
||||||
// convertHighlightPositions resolves a device annotation's pos0/pos1
|
// convertHighlightPositions resolves a device annotation's pos0/pos1
|
||||||
// locators to canonical CFIs through the shared facade. contextText is the
|
// locators to canonical CFIs through the shared facade. contextText is the
|
||||||
// selection's own text — the ideal anchor for the converter's verification
|
// selection's own text — a quote of the document, so the converter can
|
||||||
// and text-search rungs. percentage anchors the last-resort fallback so a
|
// verify structural landings against it and, when it must search, anchor a
|
||||||
// failed conversion degrades to the neighborhood of the true position
|
// range end that spans block boundaries. percentage anchors the
|
||||||
// rather than the document start.
|
// last-resort fallback. Only structural/exact landings are returned: a
|
||||||
|
// low-confidence conversion yields "" so an echo preserves the row's
|
||||||
|
// existing web CFIs (applyLWW coalesces empty) instead of clobbering them
|
||||||
|
// with a guess, and a new device highlight paints nowhere rather than in
|
||||||
|
// the wrong place.
|
||||||
func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos1, contextText string, percentage float64) (string, string) {
|
func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos1, contextText string, percentage float64) (string, string) {
|
||||||
if pos0 == "" || !ec.convertible() {
|
if pos0 == "" || !ec.convertible() {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||||
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
|
startCFI := webUsableCFI(startLoc)
|
||||||
endCFI := endLoc.CFI
|
endCFI := ""
|
||||||
// The end conversion carries no context text, so unless it resolved
|
// A text-search start matched the selection text itself: its extent
|
||||||
// exactly it degenerates to a percentage fallback anchored at the
|
// is the selection's true end, even across blocks.
|
||||||
// document start — useless as a range end. When the START resolved
|
if startCFI != "" && startLoc.EndCFI != "" {
|
||||||
// structurally/exactly, derive the end from it: same node, character
|
endCFI = startLoc.EndCFI
|
||||||
// offset advanced by the selection's UTF-16 length (the CFI offset
|
|
||||||
// unit).
|
|
||||||
if endLoc.Precision != "exact" && endLoc.Precision != "structural" &&
|
|
||||||
(startLoc.Precision == "exact" || startLoc.Precision == "structural") && contextText != "" {
|
|
||||||
endCFI = extendCFIByLength(startLoc.CFI, contextText)
|
|
||||||
}
|
}
|
||||||
return startLoc.CFI, endCFI
|
if endCFI == "" && pos1 != "" {
|
||||||
|
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||||
|
endCFI = webUsableCFI(endLoc)
|
||||||
|
}
|
||||||
|
// The end conversion carries no context text; when neither resolved
|
||||||
|
// confidently, derive the end from the start advanced by the
|
||||||
|
// selection's UTF-16 length (the CFI offset unit). Multi-node
|
||||||
|
// selections produce an out-of-range offset — harmless: resolution
|
||||||
|
// clamps or fails, and consumers fall back to the start.
|
||||||
|
if endCFI == "" && startCFI != "" && contextText != "" {
|
||||||
|
endCFI = extendCFIByLength(startCFI, contextText)
|
||||||
|
}
|
||||||
|
return startCFI, endCFI
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertBookmarkPosition resolves a device bookmark's locator to the
|
// convertBookmarkPosition resolves a device bookmark's locator to the
|
||||||
@@ -866,10 +877,26 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
|||||||
contextText = *book.ContextText
|
contextText = *book.ContextText
|
||||||
}
|
}
|
||||||
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, *epubcfi, pct, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, *epubcfi, pct, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||||
if loc.CFI != "" && loc.CFI != *epubcfi {
|
switch {
|
||||||
|
case strings.HasPrefix(loc.CFI, "epubcfi(") &&
|
||||||
|
(loc.Precision == "structural" || loc.Precision == "exact"):
|
||||||
converted := loc.CFI
|
converted := loc.CFI
|
||||||
epubcfi = &converted
|
epubcfi = &converted
|
||||||
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to %s", loc.Precision, converted)
|
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to %s", loc.Precision, converted)
|
||||||
|
case loc.CFI != "" && loc.CFI != *epubcfi &&
|
||||||
|
(loc.Precision == "element" || loc.Precision == "section"):
|
||||||
|
// Fragment-ID positions resolve to a section href: keep
|
||||||
|
// serving it (legacy behavior).
|
||||||
|
converted := loc.CFI
|
||||||
|
epubcfi = &converted
|
||||||
|
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to href %s", loc.Precision, converted)
|
||||||
|
default:
|
||||||
|
// Low-confidence (percentage/fallback): store no
|
||||||
|
// canonical locator — the row's percentage restores
|
||||||
|
// approximately instead of a confidently-wrong CFI,
|
||||||
|
// and the device keeps its own native position.
|
||||||
|
log.Printf("Bookhoard: CRE→CFI conversion low-confidence (%s) for %s; storing percentage only", loc.Precision, *epubcfi)
|
||||||
|
epubcfi = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+100
-3
@@ -572,24 +572,33 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
|||||||
Summary: existingMedia.Summary,
|
Summary: existingMedia.Summary,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk edits are user customizations: keep existing overrides and mark
|
||||||
|
// each applied update as overridden so scans preserve it.
|
||||||
|
overrides := existingMedia.MetadataOverrides
|
||||||
if update.Updates.Title != nil {
|
if update.Updates.Title != nil {
|
||||||
updateParams.Title = *update.Updates.Title
|
updateParams.Title = *update.Updates.Title
|
||||||
|
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
|
||||||
}
|
}
|
||||||
if update.Updates.Author != nil {
|
if update.Updates.Author != nil {
|
||||||
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
|
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
|
||||||
|
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
|
||||||
}
|
}
|
||||||
if update.Updates.Genre != nil {
|
if update.Updates.Genre != nil {
|
||||||
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
|
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
|
||||||
|
overrides = utils.MergeOverrides(overrides, utils.OverrideGenre)
|
||||||
}
|
}
|
||||||
if update.Updates.Language != nil {
|
if update.Updates.Language != nil {
|
||||||
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
|
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
|
||||||
|
overrides = utils.MergeOverrides(overrides, utils.OverrideLanguage)
|
||||||
}
|
}
|
||||||
if len(update.Updates.Tags) > 0 {
|
if len(update.Updates.Tags) > 0 {
|
||||||
normalizedTags := utils.NormalizeTags(update.Updates.Tags)
|
normalizedTags := utils.NormalizeTags(update.Updates.Tags)
|
||||||
updateParams.Tags = normalizedTags
|
updateParams.Tags = normalizedTags
|
||||||
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
|
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
|
||||||
updateParams.TagsSearch = tagsSearch
|
updateParams.TagsSearch = tagsSearch
|
||||||
|
overrides = utils.MergeOverrides(overrides, utils.OverrideTags)
|
||||||
}
|
}
|
||||||
|
updateParams.MetadataOverrides = overrides
|
||||||
|
|
||||||
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
|
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1240,6 +1249,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
coverPath := existing.CoverImagePath.String
|
coverPath := existing.CoverImagePath.String
|
||||||
|
coverUploaded := false
|
||||||
|
|
||||||
if req.CoverAction == "remove" {
|
if req.CoverAction == "remove" {
|
||||||
coverPath = ""
|
coverPath = ""
|
||||||
@@ -1251,6 +1261,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
|
||||||
}
|
}
|
||||||
coverPath = savedPath
|
coverPath = savedPath
|
||||||
|
coverUploaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1259,7 +1270,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
alternateInfoBytes = []byte(req.AlternateInfo)
|
alternateInfoBytes = []byte(req.AlternateInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
|
params := database.UpdateMediaItemParams{
|
||||||
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||||
@@ -1297,7 +1308,17 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
AlternateInfo: alternateInfoBytes,
|
AlternateInfo: alternateInfoBytes,
|
||||||
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
|
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
|
||||||
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Fields the user actually changed become overrides so future scans keep
|
||||||
|
// the custom values. An explicit cover upload/removal always overrides.
|
||||||
|
overrides := utils.DetectMetadataOverrides(params, existing)
|
||||||
|
if req.CoverAction == "remove" || coverUploaded {
|
||||||
|
overrides = utils.MergeOverrides(overrides, utils.OverrideCoverImagePath)
|
||||||
|
}
|
||||||
|
params.MetadataOverrides = overrides
|
||||||
|
|
||||||
|
item, err := mh.db.UpdateMediaItem(c.Request().Context(), params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -1309,6 +1330,67 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, item)
|
return c.JSON(http.StatusOK, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PurgeArchivedMediaItems handles POST /api/media-items/purge-archived
|
||||||
|
// (admin only). Hard-deletes every archived item (files missing from disk for
|
||||||
|
// 2+ scans) together with its reading history. The archive retention window
|
||||||
|
// eventually does the same automatically; this is the manual bulk escape hatch.
|
||||||
|
func (mh *MediaHandler) PurgeArchivedMediaItems(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
|
||||||
|
if user.Role != "admin" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
purged, err := mh.db.PurgeAllArchivedMediaItems(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"purged": len(purged),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RescanMediaItem handles POST /api/media-items/:id/rescan (admin only)
|
||||||
|
func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
|
||||||
|
user := MustGetAuthenticatedUser(c)
|
||||||
|
|
||||||
|
if user.Role != "admin" {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
scanner := services.NewMediaScanner(mh.db)
|
||||||
|
defer scanner.Close()
|
||||||
|
|
||||||
|
// reset_overrides=true discards user customizations first, returning the
|
||||||
|
// item to pure scanned defaults (the "Reset to Scanned" action).
|
||||||
|
resetOverrides := c.QueryParam("reset_overrides") == "true"
|
||||||
|
|
||||||
|
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, resetOverrides); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, item)
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
|
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
|
||||||
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
||||||
user := MustGetAuthenticatedUser(c)
|
user := MustGetAuthenticatedUser(c)
|
||||||
@@ -1656,6 +1738,11 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
|||||||
ChapterReference: req.ChapterReference,
|
ChapterReference: req.ChapterReference,
|
||||||
Source: "web",
|
Source: "web",
|
||||||
ModifiedAt: time.Now(),
|
ModifiedAt: time.Now(),
|
||||||
|
// The PUT targets this exact row (from the URL): identity must
|
||||||
|
// not be re-derived from content — a device echo has usually
|
||||||
|
// rewritten the stored CFI to point shape, so the computed key
|
||||||
|
// would miss and mint a duplicate beside the edited row.
|
||||||
|
HighlightID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -2183,7 +2270,17 @@ func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file
|
|||||||
return "", fmt.Errorf("media item has no file path")
|
return "", fmt.Errorf("media item has no file path")
|
||||||
}
|
}
|
||||||
|
|
||||||
coverRelPath := relativeFilePath + ".cover.jpg"
|
// Custom covers live at a dedicated sidecar path (distinct from the
|
||||||
|
// scanner-generated {file}.cover.jpg) so scans can never overwrite a
|
||||||
|
// user-uploaded cover and the metadata_overrides set can protect it.
|
||||||
|
ext := ".jpg"
|
||||||
|
switch contentType {
|
||||||
|
case "image/png":
|
||||||
|
ext = ".png"
|
||||||
|
case "image/webp":
|
||||||
|
ext = ".webp"
|
||||||
|
}
|
||||||
|
coverRelPath := relativeFilePath + ".custom_cover" + ext
|
||||||
|
|
||||||
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
|
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import "bookhoard/internal/database"
|
|||||||
type MediaDetail struct {
|
type MediaDetail struct {
|
||||||
database.MediaItems // Embedded - ALL book fields available
|
database.MediaItems // Embedded - ALL book fields available
|
||||||
|
|
||||||
|
// Absolute on-disk location (library folder + relative path), resolved by
|
||||||
|
// the page handler so the UI can show where the file lives.
|
||||||
|
FileLocation string `json:"file_location"`
|
||||||
|
|
||||||
// User-specific data
|
// User-specific data
|
||||||
Rating *database.MediaRatings `json:"rating,omitempty"`
|
Rating *database.MediaRatings `json:"rating,omitempty"`
|
||||||
Collections []database.Collections `json:"collections"`
|
Collections []database.Collections `json:"collections"`
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -882,7 +884,8 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.AdminLibrary(user, libData, userData).Render(c.Request().Context(), &buf)
|
archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
|
||||||
|
err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1267,6 +1270,23 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
|
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the on-disk location (library folder + relative path) so the
|
||||||
|
// detail page can show where the file lives, even for sparse metadata.
|
||||||
|
// Admin-only: everyday users never receive the absolute path.
|
||||||
|
fileLocation := ""
|
||||||
|
if user.Role == "admin" {
|
||||||
|
fileLocation = mediaItem.FilePath
|
||||||
|
if folders, ferr := cfg.Queries.GetLibraryFolders(c.Request().Context(), mediaItem.LibraryID); ferr == nil {
|
||||||
|
for _, folder := range folders {
|
||||||
|
candidate := filepath.Join(folder.FolderPath, mediaItem.FilePath)
|
||||||
|
if _, serr := os.Stat(candidate); serr == nil {
|
||||||
|
fileLocation = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch rating
|
// Fetch rating
|
||||||
var rating *database.MediaRatings
|
var rating *database.MediaRatings
|
||||||
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
||||||
@@ -1330,6 +1350,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
// Assemble response (no field duplication!)
|
// Assemble response (no field duplication!)
|
||||||
detail := handlers.MediaDetail{
|
detail := handlers.MediaDetail{
|
||||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||||
|
FileLocation: fileLocation,
|
||||||
Rating: rating,
|
Rating: rating,
|
||||||
Collections: collections,
|
Collections: collections,
|
||||||
ReadingProgress: progress,
|
ReadingProgress: progress,
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ func registerMediaRoutes(cfg *Config) {
|
|||||||
// Admin-only media routes
|
// Admin-only media routes
|
||||||
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
||||||
|
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
|
||||||
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
|
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
|
||||||
|
admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
|
||||||
|
|
||||||
// Shelf management (protected)
|
// Shelf management (protected)
|
||||||
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
|
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/sync"
|
"bookhoard/internal/sync"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
@@ -69,21 +66,10 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
if !visible {
|
if !visible {
|
||||||
return renderErrorPage(c, "Access denied", "access_denied")
|
return renderErrorPage(c, "Access denied", "access_denied")
|
||||||
}
|
}
|
||||||
// Get reading progress
|
// Convert to template types. Reading state is deliberately NOT
|
||||||
var progress database.ReadingProgress
|
// fetched or embedded: the reader pulls position, bookmarks, and
|
||||||
progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
// annotations from the APIs at open time so the page can never
|
||||||
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
// carry (nor write back) a stale snapshot.
|
||||||
UserID: uuidToPGType(userUUID),
|
|
||||||
})
|
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
progress = database.ReadingProgress{}
|
|
||||||
}
|
|
||||||
// Get bookmarks
|
|
||||||
bookmarks, _ := cfg.Queries.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
|
||||||
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
|
||||||
UserID: uuidToPGType(userUUID),
|
|
||||||
})
|
|
||||||
// Convert to template types
|
|
||||||
mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
||||||
libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.Bytes[0:16])
|
libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.Bytes[0:16])
|
||||||
metadata := templates.ReaderMetadata{
|
metadata := templates.ReaderMetadata{
|
||||||
@@ -104,58 +90,9 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
TotalCharacters: mediaItem.TotalCharacters.Int64,
|
TotalCharacters: mediaItem.TotalCharacters.Int64,
|
||||||
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||||
}
|
}
|
||||||
// Progress conversion (inline)
|
// Render template
|
||||||
progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16])
|
|
||||||
progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16])
|
|
||||||
progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16])
|
|
||||||
templateProgress := templates.ReadingProgress{
|
|
||||||
ID: progressUUID.String(),
|
|
||||||
MediaItemID: progressMediaUUID.String(),
|
|
||||||
UserID: progressUserUUID.String(),
|
|
||||||
CurrentPage: int(progress.CurrentPage.Int32),
|
|
||||||
TotalPages: int(progress.TotalPages.Int32),
|
|
||||||
Percentage: progress.Percentage.Float64 * 100,
|
|
||||||
EpubCfi: textToString(progress.Epubcfi),
|
|
||||||
LastReadAt: progress.LastReadAt.Time,
|
|
||||||
Chapter: int(progress.Chapter.Int32),
|
|
||||||
ChapterProgress: progress.ChapterProgress.Float64 * 100,
|
|
||||||
FormatGroup: mediaItem.FormatGroup,
|
|
||||||
}
|
|
||||||
// Bookmarks conversion (inline, with loop)
|
|
||||||
templateBookmarks := make([]templates.Bookmark, len(bookmarks))
|
|
||||||
for i, b := range bookmarks {
|
|
||||||
bookmarkUUID, _ := uuid.FromBytes(b.ID.Bytes[0:16])
|
|
||||||
bookmarkMediaUUID, _ := uuid.FromBytes(b.MediaItemID.Bytes[0:16])
|
|
||||||
bookmarkUserUUID, _ := uuid.FromBytes(b.UserID.Bytes[0:16])
|
|
||||||
|
|
||||||
var pageNumber *int
|
|
||||||
if b.PageNumber.Valid {
|
|
||||||
val := int(b.PageNumber.Int32)
|
|
||||||
pageNumber = &val
|
|
||||||
}
|
|
||||||
|
|
||||||
var chapterNumber *int
|
|
||||||
if b.ChapterNumber.Valid {
|
|
||||||
val := int(b.ChapterNumber.Int32)
|
|
||||||
chapterNumber = &val
|
|
||||||
}
|
|
||||||
|
|
||||||
templateBookmarks[i] = templates.Bookmark{
|
|
||||||
ID: bookmarkUUID.String(),
|
|
||||||
MediaItemID: bookmarkMediaUUID.String(),
|
|
||||||
UserID: bookmarkUserUUID.String(),
|
|
||||||
PageNumber: pageNumber,
|
|
||||||
ChapterNumber: chapterNumber,
|
|
||||||
CfiPosition: textToString(b.CfiPosition),
|
|
||||||
Title: b.Title,
|
|
||||||
Position: textToString(b.Position),
|
|
||||||
Notes: textToString(b.Notes),
|
|
||||||
CreatedAt: b.CreatedAt.Time,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 8. Render template
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Reader(user, metadata, templateProgress, templateBookmarks).Render(c.Request().Context(), &buf)
|
err = templates.Reader(user, metadata).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error rendering reader", "render_error")
|
return renderErrorPage(c, "Error rendering reader", "render_error")
|
||||||
}
|
}
|
||||||
|
|||||||
+515
-226
@@ -5,6 +5,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"archive/tar"
|
"archive/tar"
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -22,7 +23,10 @@ import (
|
|||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -111,6 +115,7 @@ type MediaScanner struct {
|
|||||||
defaultLibraryID pgtype.UUID
|
defaultLibraryID pgtype.UUID
|
||||||
libraryTypes map[string][]string
|
libraryTypes map[string][]string
|
||||||
forceRescan bool
|
forceRescan bool
|
||||||
|
archiveRetentionDays int
|
||||||
logger *ScannerLogger
|
logger *ScannerLogger
|
||||||
dirtyDirs map[string]time.Time
|
dirtyDirs map[string]time.Time
|
||||||
dirtyDirsMu sync.RWMutex
|
dirtyDirsMu sync.RWMutex
|
||||||
@@ -158,6 +163,7 @@ func NewMediaScanner(db *database.Queries) *MediaScanner {
|
|||||||
return &MediaScanner{
|
return &MediaScanner{
|
||||||
db: db,
|
db: db,
|
||||||
watcher: nil,
|
watcher: nil,
|
||||||
|
archiveRetentionDays: config.ArchiveRetentionDays(),
|
||||||
settingsCache: NewSettingsCache(30 * time.Second),
|
settingsCache: NewSettingsCache(30 * time.Second),
|
||||||
dirtyDirs: make(map[string]time.Time),
|
dirtyDirs: make(map[string]time.Time),
|
||||||
fileStability: make(map[string]*atomic.Bool),
|
fileStability: make(map[string]*atomic.Bool),
|
||||||
@@ -475,17 +481,23 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
fmt.Printf("Scan completed: %d total files scanned, %d media files found, %d new items, %d errors\n",
|
fmt.Printf("Scan completed: %d total files scanned, %d media files found, %d new items, %d errors\n",
|
||||||
processedFiles, mediaFiles, s.newItems, s.errors)
|
processedFiles, mediaFiles, s.newItems, s.errors)
|
||||||
|
|
||||||
// Clean up: Find media items in DB that no longer exist on filesystem
|
// Archive lifecycle pass. Items whose files vanished from disk are
|
||||||
|
// archived after two consecutive missing scans (reading history kept,
|
||||||
|
// item hidden), and purged for good once archived older than the
|
||||||
|
// retention window (ARCHIVE_RETENTION_DAYS; 0 = manual purge only).
|
||||||
|
// Every branch logs - the previous hard-delete cleanup failed silently
|
||||||
|
// and left orphaned rows undetected.
|
||||||
for _, folder := range s.folders {
|
for _, folder := range s.folders {
|
||||||
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
lib, err := s.db.GetLibraryByFolder(ctx, folder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("[ARCHIVE] Warning: no library found for folder %s, skipping archive pass: %v\n", folder, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
libraryID := lib.LibraryID
|
libraryID := lib.LibraryID
|
||||||
|
|
||||||
dbItems, err := s.db.ListMediaItemsByLibrary(ctx, libraryID)
|
dbItems, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Warning: failed to get library items for cleanup: %v\n", err)
|
fmt.Printf("[ARCHIVE] Warning: failed to get library items for archive pass: %v\n", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,28 +512,46 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
fmt.Printf("[RESCAN-CLEANUP] Warning: failed to walk directory %s, skipping orphan cleanup: %v\n", folder, err)
|
fmt.Printf("[ARCHIVE] Warning: failed to walk directory %s, skipping archive pass: %v\n", folder, err)
|
||||||
continue // Skip to next folder to avoid false deletions
|
continue // Skip to next folder to avoid false archivals
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete items whose files no longer exist - with safety logging
|
|
||||||
for _, item := range dbItems {
|
for _, item := range dbItems {
|
||||||
filePath := item.FilePath
|
if item.FilePath == "" || scannedPaths[item.FilePath] {
|
||||||
if filePath != "" && !scannedPaths[filePath] {
|
continue // File present; unarchive is handled in processMediaFile
|
||||||
msg := fmt.Sprintf("[RESCAN-CLEANUP] Orphaned media item found: ID=%s, Title=%s, Path=%s",
|
}
|
||||||
item.ID, item.Title, filePath)
|
|
||||||
s.logger.LogDelete(msg)
|
|
||||||
|
|
||||||
delMsg := fmt.Sprintf("[RESCAN-CLEANUP] Deleting orphaned item '%s' (file no longer exists at %s)",
|
|
||||||
item.Title, filePath)
|
|
||||||
s.logger.LogDelete(delMsg)
|
|
||||||
|
|
||||||
|
if item.ArchivedAt.Valid {
|
||||||
|
// Still missing and already archived: purge once past the
|
||||||
|
// retention window (0 = keep until manual purge).
|
||||||
|
if s.archiveRetentionDays > 0 && time.Now().AddDate(0, 0, -s.archiveRetentionDays).After(item.ArchivedAt.Time) {
|
||||||
if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil {
|
if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil {
|
||||||
errMsg := fmt.Sprintf("[RESCAN-CLEANUP] ERROR: failed to delete orphaned item %s: %v", item.Title, err)
|
fmt.Printf("[ARCHIVE] Error: failed to purge archived item %s: %v\n", item.Title, err)
|
||||||
s.logger.LogDelete(errMsg)
|
s.logger.LogError(fmt.Sprintf("[ARCHIVE] ERROR: failed to purge archived item '%s': %v", item.Title, err))
|
||||||
s.logger.LogError(errMsg)
|
|
||||||
} else {
|
} else {
|
||||||
s.logger.LogDelete(fmt.Sprintf("[RESCAN-CLEANUP] SUCCESS: deleted orphaned item '%s'", item.Title))
|
fmt.Printf("[ARCHIVE] Purged archived item '%s' (retention %d days): %s\n", item.Title, s.archiveRetentionDays, item.FilePath)
|
||||||
|
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Purged archived item '%s' after retention window (file missing at %s)", item.Title, item.FilePath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing but not yet archived.
|
||||||
|
if item.MissingScanCount >= 1 {
|
||||||
|
// Second consecutive missing scan: archive it.
|
||||||
|
if err := s.db.ArchiveMediaItem(ctx, item.ID); err != nil {
|
||||||
|
fmt.Printf("[ARCHIVE] Error: failed to archive item %s: %v\n", item.Title, err)
|
||||||
|
s.logger.LogError(fmt.Sprintf("[ARCHIVE] ERROR: failed to archive item '%s': %v", item.Title, err))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[ARCHIVE] Archived item '%s' (missing from disk for %d scans): %s\n", item.Title, item.MissingScanCount+1, item.FilePath)
|
||||||
|
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Archived item '%s' (file missing from disk at %s)", item.Title, item.FilePath))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// First missing scan: mark, archive on the next one.
|
||||||
|
if err := s.db.MarkMediaItemMissing(ctx, item.ID); err != nil {
|
||||||
|
fmt.Printf("[ARCHIVE] Warning: failed to mark item missing %s: %v\n", item.Title, err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[ARCHIVE] Item missing from disk (1/2 scans before archiving): %s\n", item.FilePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -692,11 +722,22 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
fmt.Printf("Media item already exists in database: %s (size: %d vs %d)\n", path, existingItem.FileSize.Int64, info.Size())
|
fmt.Printf("Media item already exists in database: %s (size: %d vs %d)\n", path, existingItem.FileSize.Int64, info.Size())
|
||||||
|
|
||||||
|
// The file is back on disk: lift any archive/missing state so the
|
||||||
|
// item reappears in libraries and future missing scans start fresh.
|
||||||
|
if existingItem.ArchivedAt.Valid || existingItem.MissingScanCount > 0 {
|
||||||
|
if err := s.db.ClearMediaItemArchive(ctx, existingItem.ID); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to unarchive media item %s: %v\n", existingItem.FilePath, err)
|
||||||
|
} else if existingItem.ArchivedAt.Valid {
|
||||||
|
fmt.Printf("[ARCHIVE] Restored from archive, file is back: %s\n", existingItem.FilePath)
|
||||||
|
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Restored archived item '%s' (file returned at %s)", existingItem.Title, existingItem.FilePath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If force rescan is enabled, always re-process
|
// If force rescan is enabled, always re-process
|
||||||
if s.forceRescan {
|
if s.forceRescan {
|
||||||
fmt.Printf("Force rescan enabled, updating existing media item: %s\n", path)
|
fmt.Printf("Force rescan enabled, updating existing media item: %s\n", path)
|
||||||
// Use UPDATE instead of DELETE+INSERT to preserve created_at
|
// Use UPDATE instead of DELETE+INSERT to preserve created_at
|
||||||
if err := s.updateMediaItem(ctx, existingItem.ID, path, info); err != nil {
|
if err := s.updateMediaItem(ctx, existingItem, path); err != nil {
|
||||||
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
|
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
|
||||||
}
|
}
|
||||||
// Recompute hash identifiers too - a force rescan is the admin's
|
// Recompute hash identifiers too - a force rescan is the admin's
|
||||||
@@ -707,7 +748,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
// Normal behavior: check if file has changed (by size)
|
// Normal behavior: check if file has changed (by size)
|
||||||
if existingItem.FileSize.Int64 != info.Size() {
|
if existingItem.FileSize.Int64 != info.Size() {
|
||||||
fmt.Printf("File size changed, updating media item: %s\n", path)
|
fmt.Printf("File size changed, updating media item: %s\n", path)
|
||||||
_ = s.updateMediaItem(ctx, existingItem.ID, path, info)
|
_ = s.updateMediaItem(ctx, existingItem, path)
|
||||||
// The bytes changed, so any stored hash is stale.
|
// The bytes changed, so any stored hash is stale.
|
||||||
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
|
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -756,8 +797,17 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
if err == nil && existingByHash.ID.Valid {
|
if err == nil && existingByHash.ID.Valid {
|
||||||
fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n",
|
fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n",
|
||||||
existingByHash.FilePath, path)
|
existingByHash.FilePath, path)
|
||||||
|
// Content returned (possibly at a new path): restore archived rows.
|
||||||
|
if existingByHash.ArchivedAt.Valid || existingByHash.MissingScanCount > 0 {
|
||||||
|
if err := s.db.ClearMediaItemArchive(ctx, existingByHash.ID); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to unarchive media item %s: %v\n", existingByHash.FilePath, err)
|
||||||
|
} else if existingByHash.ArchivedAt.Valid {
|
||||||
|
fmt.Printf("[ARCHIVE] Restored from archive, identical content found at %s\n", path)
|
||||||
|
s.logger.LogDelete(fmt.Sprintf("[ARCHIVE] Restored archived item '%s' (identical content found at %s)", existingByHash.Title, path))
|
||||||
|
}
|
||||||
|
}
|
||||||
if s.forceRescan {
|
if s.forceRescan {
|
||||||
_ = s.updateMediaItem(ctx, existingByHash.ID, path, info)
|
_ = s.updateMediaItem(ctx, existingByHash, path)
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -957,6 +1007,94 @@ func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
|
|||||||
return metadata
|
return metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractAudiobookshelfSidecar checks for and parses an Audiobookshelf-style
|
||||||
|
// metadata.json sidecar next to the media file. Only fields with a matching
|
||||||
|
// media_items column are mapped; narrators, subtitle, explicit, abridged and
|
||||||
|
// chapters are deliberately skipped. Returns nil when no sidecar exists.
|
||||||
|
func extractAudiobookshelfSidecar(path string) *MediaMetadata {
|
||||||
|
jsonPath := filepath.Join(filepath.Dir(path), "metadata.json")
|
||||||
|
if _, err := os.Stat(jsonPath); os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(jsonPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to read metadata.json sidecar for %s: %v\n", path, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sidecar struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Subtitle string `json:"subtitle"`
|
||||||
|
Authors []string `json:"authors"`
|
||||||
|
Series []struct {
|
||||||
|
Series string `json:"series"`
|
||||||
|
Sequence string `json:"sequence"`
|
||||||
|
} `json:"series"`
|
||||||
|
Genres []string `json:"genres"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
PublishedYear *int `json:"publishedYear"`
|
||||||
|
PublishedDate *string `json:"publishedDate"`
|
||||||
|
Publisher *string `json:"publisher"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
ISBN *string `json:"isbn"`
|
||||||
|
ASIN *string `json:"asin"`
|
||||||
|
Language *string `json:"language"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &sidecar); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to parse metadata.json sidecar for %s: %v\n", path, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &MediaMetadata{
|
||||||
|
Title: strings.TrimSpace(sidecar.Title),
|
||||||
|
}
|
||||||
|
// Subtitle joins the title Calibre-style ("Main: Subtitle") - there is no
|
||||||
|
// separate subtitle column, and Calibre-sidecar books arrive pre-joined.
|
||||||
|
if subtitle := strings.TrimSpace(sidecar.Subtitle); subtitle != "" && metadata.Title != "" {
|
||||||
|
metadata.Title = metadata.Title + ": " + subtitle
|
||||||
|
}
|
||||||
|
if len(sidecar.Authors) > 0 {
|
||||||
|
metadata.Author = strings.TrimSpace(sidecar.Authors[0])
|
||||||
|
}
|
||||||
|
if len(sidecar.Series) > 0 {
|
||||||
|
metadata.Series = strings.TrimSpace(sidecar.Series[0].Series)
|
||||||
|
if index, err := strconv.ParseFloat(strings.TrimSpace(sidecar.Series[0].Sequence), 32); err == nil {
|
||||||
|
metadata.SeriesNumber = int32(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tags := append(append([]string{}, sidecar.Genres...), sidecar.Tags...); len(tags) > 0 {
|
||||||
|
metadata.Tags = utils.NormalizeTags(tags)
|
||||||
|
}
|
||||||
|
if sidecar.PublishedDate != nil {
|
||||||
|
if date, err := time.Parse("2006-01-02", strings.TrimSpace(*sidecar.PublishedDate)); err == nil {
|
||||||
|
metadata.PublishDate = date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if metadata.PublishDate.IsZero() && sidecar.PublishedYear != nil && *sidecar.PublishedYear > 0 {
|
||||||
|
metadata.PublishDate = time.Date(*sidecar.PublishedYear, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
if sidecar.Publisher != nil {
|
||||||
|
metadata.Publisher = strings.TrimSpace(*sidecar.Publisher)
|
||||||
|
}
|
||||||
|
if sidecar.Description != nil {
|
||||||
|
metadata.Description = strings.TrimSpace(*sidecar.Description)
|
||||||
|
}
|
||||||
|
if sidecar.ISBN != nil {
|
||||||
|
metadata.ISBN = utils.NormalizeISBNSafe(strings.TrimSpace(*sidecar.ISBN))
|
||||||
|
}
|
||||||
|
if sidecar.ASIN != nil {
|
||||||
|
metadata.ASIN = strings.TrimSpace(*sidecar.ASIN)
|
||||||
|
}
|
||||||
|
if sidecar.Language != nil {
|
||||||
|
metadata.Language = strings.TrimSpace(*sidecar.Language)
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAudiobookshelfSidecar-TMP-END
|
||||||
|
|
||||||
// mergeMetadata intelligently merges metadata from multiple sources
|
// mergeMetadata intelligently merges metadata from multiple sources
|
||||||
// Priority: metadata.opf (Calibre) → embedded metadata → folder structure → filename
|
// Priority: metadata.opf (Calibre) → embedded metadata → folder structure → filename
|
||||||
// For comics: metadata.opf → ComicInfo.xml → folder structure → filename
|
// For comics: metadata.opf → ComicInfo.xml → folder structure → filename
|
||||||
@@ -1264,6 +1402,8 @@ func extractGenreTagsFromComicInfo(comicInfo *ComicInfo) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
|
func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
|
||||||
|
// Metadata sidecar priority: Calibre metadata.opf, then Audiobookshelf
|
||||||
|
// metadata.json, then the media file's own embedded metadata.
|
||||||
// Try Calibre sidecar first
|
// Try Calibre sidecar first
|
||||||
calibreMetadata := s.extractCalibreSidecar(path)
|
calibreMetadata := s.extractCalibreSidecar(path)
|
||||||
if calibreMetadata != nil {
|
if calibreMetadata != nil {
|
||||||
@@ -1278,7 +1418,21 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
|
|||||||
return s.mergeMetadata(path, calibreMetadata)
|
return s.mergeMetadata(path, calibreMetadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EXISTING: Fallback to embedded metadata
|
// Audiobookshelf-style metadata.json sidecar (fields without a DB column
|
||||||
|
// - narrators, subtitle, explicit, abridged, chapters - are skipped)
|
||||||
|
abMetadata := extractAudiobookshelfSidecar(path)
|
||||||
|
if abMetadata != nil {
|
||||||
|
fmt.Printf("Using metadata.json sidecar for %s\n", path)
|
||||||
|
|
||||||
|
coverPath := findSidecarCover(path)
|
||||||
|
if coverPath != "" {
|
||||||
|
abMetadata.CoverPath = s.getRelativePath(coverPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.mergeMetadata(path, abMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to embedded metadata
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
|
||||||
switch ext {
|
switch ext {
|
||||||
@@ -1346,89 +1500,38 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractEPUBMetadata extracts metadata from an EPUB by parsing its embedded
|
||||||
|
// OPF document directly (container.xml → OPF → Dublin Core elements).
|
||||||
|
//
|
||||||
|
// It deliberately does NOT use a full-book parser: the previous go-epub
|
||||||
|
// implementation parsed every spine chapter and failed the whole call when any
|
||||||
|
// single chapter (or the TOC) was malformed, discarding perfectly good OPF
|
||||||
|
// metadata and leaving rescans writing blanks. The OPF holds all the metadata
|
||||||
|
// we need; chapter damage can no longer affect it.
|
||||||
func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error) {
|
func (s *MediaScanner) extractEPUBMetadata(path string) (*MediaMetadata, error) {
|
||||||
book, err := epub.ReadBook(path)
|
r, err := zip.OpenReader(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open EPUB: %v", err)
|
return nil, fmt.Errorf("failed to open EPUB: %v", err)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := r.Close(); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to close EPUB zip reader for %s: %v\n", path, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
metadata := &MediaMetadata{}
|
opfPath := findOPFPathInZip(r.File)
|
||||||
|
if opfPath == "" {
|
||||||
// Title
|
return nil, fmt.Errorf("no OPF document found in EPUB %s", path)
|
||||||
if title, err := book.Title(); err == nil && title != "" {
|
}
|
||||||
metadata.Title = title
|
opfContent, err := readFileFromZip(r.File, opfPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read OPF %s from EPUB: %v", opfPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Author
|
metadata, err := parseOPFContent(opfContent)
|
||||||
if authors, err := book.MetadataByKey("creator"); err == nil && len(authors) > 0 {
|
if err != nil {
|
||||||
metadata.Author = authors[0]
|
return nil, fmt.Errorf("failed to parse OPF in EPUB %s: %v", path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description
|
|
||||||
if descriptions, err := book.MetadataByKey("description"); err == nil && len(descriptions) > 0 {
|
|
||||||
metadata.Description = descriptions[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publisher
|
|
||||||
if publishers, err := book.MetadataByKey("publisher"); err == nil && len(publishers) > 0 {
|
|
||||||
metadata.Publisher = publishers[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Series and series number (Calibre specific metadata)
|
|
||||||
if series, err := book.MetadataByKey("calibre:series"); err == nil && len(series) > 0 {
|
|
||||||
metadata.Series = series[0]
|
|
||||||
}
|
|
||||||
if seriesIndex, err := book.MetadataByKey("calibre:series_index"); err == nil && len(seriesIndex) > 0 {
|
|
||||||
if index, err := strconv.ParseFloat(seriesIndex[0], 32); err == nil {
|
|
||||||
metadata.SeriesNumber = int32(index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish date
|
|
||||||
if dates, err := book.MetadataByKey("date"); err == nil && len(dates) > 0 {
|
|
||||||
if date, err := time.Parse("2006-01-02", dates[0]); err == nil {
|
|
||||||
metadata.PublishDate = date
|
|
||||||
} else {
|
|
||||||
// Try alternative date formats
|
|
||||||
if date, err := time.Parse("2006", dates[0]); err == nil {
|
|
||||||
metadata.PublishDate = date
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contributors
|
|
||||||
if contributors, err := book.MetadataByKey("contributor"); err == nil && len(contributors) > 0 {
|
|
||||||
// Normalize contributors for display
|
|
||||||
metadata.Contributors = utils.NormalizeContributors(contributors)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ISBN
|
|
||||||
if isbns, err := book.MetadataByKey("identifier"); err == nil && len(isbns) > 0 {
|
|
||||||
for _, isbn := range isbns {
|
|
||||||
if strings.Contains(strings.ToLower(isbn), "isbn") {
|
|
||||||
// Extract ISBN number from identifier like "isbn:978-3-16-148410-0"
|
|
||||||
isbnParts := strings.SplitN(isbn, ":", 2)
|
|
||||||
if len(isbnParts) == 2 {
|
|
||||||
metadata.ISBN = isbnParts[1]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.Contains(strings.ToLower(isbn), "asin") {
|
|
||||||
// Extract ASIN from identifier like "asin:B08XXXXX"
|
|
||||||
asinParts := strings.SplitN(isbn, ":", 2)
|
|
||||||
if len(asinParts) == 2 {
|
|
||||||
metadata.ASIN = asinParts[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags
|
|
||||||
if tags, err := book.MetadataByKey("subject"); err == nil && len(tags) > 0 {
|
|
||||||
// Normalize tags for display
|
|
||||||
metadata.Tags = utils.NormalizeTags(tags)
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata, nil
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1603,7 +1706,7 @@ func (s *MediaScanner) LogProcessingIssue(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCalibreMetadataOPF parses a Calibre metadata.opf file and extracts metadata
|
// parseCalibreMetadataOPF parses a Calibre metadata.opf sidecar file.
|
||||||
func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, error) {
|
func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata, error) {
|
||||||
// Open file
|
// Open file
|
||||||
file, err := os.Open(opfPath)
|
file, err := os.Open(opfPath)
|
||||||
@@ -1615,94 +1718,116 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
|
|||||||
fmt.Printf("Warning: failed to close metadata.opf: %v\n", err)
|
fmt.Printf("Warning: failed to close metadata.opf: %v\n", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
// Define XML structure for parsing with full Dublin Core namespace URLs
|
content, err := io.ReadAll(file)
|
||||||
var opf struct {
|
if err != nil {
|
||||||
XMLName xml.Name `xml:"package"`
|
return nil, fmt.Errorf("failed to read metadata.opf: %v", err)
|
||||||
Metadata struct {
|
|
||||||
XMLName xml.Name `xml:"metadata"`
|
|
||||||
Titles []string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
|
||||||
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
|
||||||
Subjects []string `xml:"http://purl.org/dc/elements/1.1/ subject"`
|
|
||||||
Desc []string `xml:"http://purl.org/dc/elements/1.1/ description"`
|
|
||||||
Publisher []string `xml:"http://purl.org/dc/elements/1.1/ publisher"`
|
|
||||||
Dates []string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
|
||||||
Language []string `xml:"http://purl.org/dc/elements/1.1/ language"`
|
|
||||||
Identifiers []struct {
|
|
||||||
Scheme string `xml:"http://www.idpf.org/2007/opf scheme,attr"`
|
|
||||||
Value string `xml:",chardata"`
|
|
||||||
} `xml:"http://purl.org/dc/elements/1.1/ identifier"`
|
|
||||||
Contributors []string `xml:"http://purl.org/dc/elements/1.1/ contributor"`
|
|
||||||
// Calibre-specific meta tags - capture all, filter later
|
|
||||||
MetaTags []struct {
|
|
||||||
Name string `xml:"name,attr"`
|
|
||||||
Value string `xml:"content,attr"`
|
|
||||||
} `xml:"meta"`
|
|
||||||
} `xml:"metadata"`
|
|
||||||
}
|
}
|
||||||
// Parse XML
|
return parseOPFContent(content)
|
||||||
if err := xml.NewDecoder(file).Decode(&opf); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse metadata.opf XML: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseOPFContent parses an OPF document (Dublin Core metadata) into
|
||||||
|
// MediaMetadata. Used for both Calibre metadata.opf sidecars and the OPF
|
||||||
|
// embedded inside an EPUB - the dc:* vocabulary is identical. Parsing is
|
||||||
|
// attribute-order agnostic and namespace aware (see media_scanner_opf.go);
|
||||||
|
// title selection and series detection follow Calibre's behavior.
|
||||||
|
func parseOPFContent(content []byte) (*MediaMetadata, error) {
|
||||||
|
opf, err := parseOPFXML(content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse OPF XML: %v", err)
|
||||||
|
}
|
||||||
|
md := opf.Metadata
|
||||||
|
|
||||||
// Map to MediaMetadata struct
|
// Map to MediaMetadata struct
|
||||||
metadata := &MediaMetadata{}
|
metadata := &MediaMetadata{}
|
||||||
// Title (required)
|
// Title: EPUB3 title-type main selection with Calibre-style subtitle join
|
||||||
if len(opf.Metadata.Titles) > 0 {
|
if title := opf.selectTitle(); title != "" {
|
||||||
metadata.Title = opf.Metadata.Titles[0]
|
metadata.Title = title
|
||||||
}
|
}
|
||||||
// Author (first creator)
|
// Author (first creator)
|
||||||
if len(opf.Metadata.Creators) > 0 {
|
if len(md.Creators) > 0 {
|
||||||
metadata.Author = opf.Metadata.Creators[0]
|
metadata.Author = md.Creators[0]
|
||||||
|
}
|
||||||
|
// Tags (all subjects; one element = one tag, commas are legal inside
|
||||||
|
// subject headings like "Holmes, Sherlock (Fictitious character)").
|
||||||
|
// Genre mirrors the sidecar path's processGenresAndTags: first subject.
|
||||||
|
if len(md.Subjects) > 0 {
|
||||||
|
metadata.Tags = utils.NormalizeTags(md.Subjects)
|
||||||
|
if len(metadata.Tags) > 0 {
|
||||||
|
metadata.Genre = metadata.Tags[0]
|
||||||
}
|
}
|
||||||
// Tags (all subjects)
|
|
||||||
if len(opf.Metadata.Subjects) > 0 {
|
|
||||||
metadata.Tags = utils.NormalizeTags(opf.Metadata.Subjects)
|
|
||||||
}
|
}
|
||||||
// Description
|
// Description
|
||||||
if len(opf.Metadata.Desc) > 0 {
|
if len(md.Descriptions) > 0 {
|
||||||
metadata.Description = opf.Metadata.Desc[0]
|
metadata.Description = md.Descriptions[0]
|
||||||
}
|
}
|
||||||
// Publisher
|
// Publisher
|
||||||
if len(opf.Metadata.Publisher) > 0 {
|
if len(md.Publishers) > 0 {
|
||||||
metadata.Publisher = opf.Metadata.Publisher[0]
|
metadata.Publisher = md.Publishers[0]
|
||||||
|
}
|
||||||
|
// Language
|
||||||
|
if len(md.Languages) > 0 && md.Languages[0] != "" {
|
||||||
|
metadata.Language = md.Languages[0]
|
||||||
}
|
}
|
||||||
// Publish date
|
// Publish date
|
||||||
if len(opf.Metadata.Dates) > 0 {
|
if len(md.Dates) > 0 {
|
||||||
if date, err := time.Parse("2006-01-02T15:04:05Z07:00", opf.Metadata.Dates[0]); err == nil {
|
if date, err := time.Parse("2006-01-02T15:04:05Z07:00", md.Dates[0]); err == nil {
|
||||||
metadata.PublishDate = date
|
metadata.PublishDate = date
|
||||||
} else if date, err := time.Parse("2006-01-02", opf.Metadata.Dates[0]); err == nil {
|
} else if date, err := time.Parse("2006-01-02", md.Dates[0]); err == nil {
|
||||||
metadata.PublishDate = date
|
metadata.PublishDate = date
|
||||||
} else {
|
} else {
|
||||||
// Try alternative date formats
|
// Try alternative date formats
|
||||||
if date, err := time.Parse("2006", opf.Metadata.Dates[0]); err == nil {
|
if date, err := time.Parse("2006", md.Dates[0]); err == nil {
|
||||||
metadata.PublishDate = date
|
metadata.PublishDate = date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Identifiers (ISBN, ASIN)
|
// Identifiers: opf:scheme attribute first, then URN-prefixed values
|
||||||
for _, id := range opf.Metadata.Identifiers {
|
// (urn:isbn:...), then bare values that normalize to a valid ISBN.
|
||||||
switch strings.ToUpper(id.Scheme) {
|
for _, id := range md.Identifiers {
|
||||||
|
value := strings.TrimSpace(id.Value)
|
||||||
|
scheme := strings.ToUpper(strings.TrimSpace(id.Scheme))
|
||||||
|
if scheme == "" {
|
||||||
|
if lower := strings.ToLower(value); strings.HasPrefix(lower, "urn:") {
|
||||||
|
rest := value[4:]
|
||||||
|
if prefix, val, ok := strings.Cut(rest, ":"); ok {
|
||||||
|
scheme = strings.ToUpper(prefix)
|
||||||
|
value = strings.TrimSpace(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch scheme {
|
||||||
case "ISBN":
|
case "ISBN":
|
||||||
metadata.ISBN = utils.NormalizeISBNSafe(id.Value)
|
metadata.ISBN = utils.NormalizeISBNSafe(value)
|
||||||
case "ASIN":
|
case "ASIN":
|
||||||
metadata.ASIN = id.Value
|
metadata.ASIN = value
|
||||||
case "UUID", "CALIBRE":
|
case "UUID", "CALIBRE":
|
||||||
// Store UUID in hash info, not metadata
|
// Store UUID in hash info, not metadata
|
||||||
// Will be extracted by extractHashInfo()
|
// Will be extracted by extractHashInfo()
|
||||||
|
default:
|
||||||
|
// EPUB3 identifiers often carry no opf:scheme attribute; accept a
|
||||||
|
// bare value only when it is ISBN-shaped (rejects the URIs and
|
||||||
|
// UUIDs that commonly share the identifier list).
|
||||||
|
if metadata.ISBN == "" && scheme == "" && isISBNLike(value) {
|
||||||
|
if normalized, err := utils.NormalizeISBN(value); err == nil {
|
||||||
|
metadata.ISBN = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Contributors
|
// Contributors
|
||||||
if len(opf.Metadata.Contributors) > 0 {
|
if len(md.Contributors) > 0 {
|
||||||
metadata.Contributors = utils.NormalizeContributors(opf.Metadata.Contributors)
|
metadata.Contributors = utils.NormalizeContributors(md.Contributors)
|
||||||
}
|
}
|
||||||
// Calibre-specific meta tags (filter by name attribute)
|
// Series: EPUB3 belongs-to-collection, then calibre:series metas
|
||||||
for _, meta := range opf.Metadata.MetaTags {
|
if series, index := opf.readSeries(); series != "" {
|
||||||
switch meta.Name {
|
metadata.Series = series
|
||||||
case "calibre:series":
|
if index > 0 {
|
||||||
metadata.Series = meta.Value
|
|
||||||
case "calibre:series_index":
|
|
||||||
if index, err := strconv.ParseFloat(meta.Value, 32); err == nil {
|
|
||||||
metadata.SeriesNumber = int32(index)
|
metadata.SeriesNumber = int32(index)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Calibre-specific meta tags (filter by name attribute)
|
||||||
|
for _, meta := range md.Metas {
|
||||||
|
switch meta.Name {
|
||||||
case "calibre:rating":
|
case "calibre:rating":
|
||||||
// Not imported (ratings are per-user in Bookhoard)
|
// Not imported (ratings are per-user in Bookhoard)
|
||||||
case "calibre:title_sort":
|
case "calibre:title_sort":
|
||||||
@@ -1714,6 +1839,45 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
|
|||||||
return metadata, nil
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// findOPFPathInZip locates the OPF document inside an EPUB by reading
|
||||||
|
// META-INF/container.xml (string-scraped; we only need the rootfile
|
||||||
|
// full-path attribute). Returns "" when absent.
|
||||||
|
func findOPFPathInZip(files []*zip.File) string {
|
||||||
|
for _, f := range files {
|
||||||
|
if f.Name != "META-INF/container.xml" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rc, err := f.Open()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
content, readErr := io.ReadAll(rc)
|
||||||
|
if closeErr := rc.Close(); closeErr != nil {
|
||||||
|
fmt.Printf("Warning: failed to close META-INF/container.xml reader: %v\n", closeErr)
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
opfStart := bytes.Index(content, []byte("<rootfile "))
|
||||||
|
if opfStart == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
opfStartAttr := bytes.Index(content[opfStart:], []byte("full-path="))
|
||||||
|
if opfStartAttr == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
opfStartAttr += len("full-path=")
|
||||||
|
quote := content[opfStart+opfStartAttr]
|
||||||
|
opfStartQuote := opfStart + opfStartAttr + 1
|
||||||
|
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{quote})
|
||||||
|
if opfEndQuote == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(content[opfStartQuote : opfStartQuote+opfEndQuote])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// extractEPUBCover extracts the cover image from an EPUB file.
|
// extractEPUBCover extracts the cover image from an EPUB file.
|
||||||
// It looks for:
|
// It looks for:
|
||||||
// 1. An item with properties="cover-image" in the manifest
|
// 1. An item with properties="cover-image" in the manifest
|
||||||
@@ -1735,43 +1899,7 @@ func (s *MediaScanner) extractEPUBCover(epubPath string) (string, error) {
|
|||||||
// Try to find cover image from OPF metadata
|
// Try to find cover image from OPF metadata
|
||||||
coverImageName := ""
|
coverImageName := ""
|
||||||
|
|
||||||
// Attempt to read the OPF file to find cover reference
|
opfPath := findOPFPathInZip(r.File)
|
||||||
// First, find container.xml to locate the OPF
|
|
||||||
var opfPath string
|
|
||||||
for _, f := range r.File {
|
|
||||||
if f.Name == "META-INF/container.xml" {
|
|
||||||
rc, err := f.Open()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
content, readErr := io.ReadAll(rc)
|
|
||||||
if closeErr := rc.Close(); closeErr != nil {
|
|
||||||
fmt.Printf("Warning: failed to close META-INF/container.xml reader in %s: %v\n", epubPath, closeErr)
|
|
||||||
}
|
|
||||||
if readErr != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Parse container.xml to find OPF path
|
|
||||||
// Simple string search since we just need the path
|
|
||||||
opfStart := bytes.Index(content, []byte("<rootfile "))
|
|
||||||
if opfStart == -1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
opfStartAttr := bytes.Index(content[opfStart:], []byte("full-path="))
|
|
||||||
if opfStartAttr == -1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
opfStartAttr += len("full-path=")
|
|
||||||
quote := content[opfStart+opfStartAttr]
|
|
||||||
opfStartQuote := opfStart + opfStartAttr + 1
|
|
||||||
opfEndQuote := bytes.Index(content[opfStartQuote:], []byte{quote})
|
|
||||||
if opfEndQuote == -1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
opfPath = string(content[opfStartQuote : opfStartQuote+opfEndQuote])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if opfPath == "" {
|
if opfPath == "" {
|
||||||
// No OPF found, try common cover image paths
|
// No OPF found, try common cover image paths
|
||||||
@@ -1826,7 +1954,47 @@ func findCoverImageInZip(files []*zip.File) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// findCoverInOPF parses OPF content to find cover image reference
|
// findCoverInOPF parses OPF content to find cover image reference
|
||||||
func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string {
|
// findCoverInOPF locates the cover image for an EPUB, following Calibre's
|
||||||
|
// read_raster_cover resolution order (see media_scanner_opf.go):
|
||||||
|
// 1. manifest item with properties="cover-image"
|
||||||
|
// 2. <meta name="cover"> resolved through the manifest
|
||||||
|
// 3. the first spine item being a raster image itself (store manga)
|
||||||
|
// 4. NEW: the cover page (guide type="cover" or first spine item) mined for
|
||||||
|
// <img src> / SVG <image xlink:href> - covers books that declare no
|
||||||
|
// raster cover at all, e.g. classic EPUB2/Adobe cover.xhtml wrappers
|
||||||
|
// 5. filename guessing in the zip (pre-existing fallback)
|
||||||
|
//
|
||||||
|
// XML parsing is attribute-order agnostic; if the OPF is too malformed for
|
||||||
|
// encoding/xml, the legacy regex chain runs as a compatibility fallback.
|
||||||
|
func findCoverInOPF(opfContent []byte, files []*zip.File, opfPath string) string {
|
||||||
|
opf, err := parseOPFXML(opfContent)
|
||||||
|
if err != nil {
|
||||||
|
return findCoverInOPFLegacy(opfContent, files, opfPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if href := opf.findRasterCoverInOPF(); href != "" {
|
||||||
|
return resolveOPFPath(opfPath, href)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cover-page fallback: Calibre renders the page; we extract the image it
|
||||||
|
// references (the practical case - the page wraps a raster in img/SVG).
|
||||||
|
if pageHref := opf.coverPageHref(); pageHref != "" {
|
||||||
|
if pageContent, err := readFileFromZip(files, resolveOPFPath(opfPath, pageHref)); err == nil {
|
||||||
|
if imgRef := findImageReferenceInPage(pageContent); imgRef != "" {
|
||||||
|
imgPath := resolveOPFPath(resolveOPFPath(opfPath, pageHref), imgRef)
|
||||||
|
if zipHasFile(files, imgPath) {
|
||||||
|
return imgPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findCoverImageInZip(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findCoverInOPFLegacy is the pre-XML cover lookup, kept solely as a
|
||||||
|
// fallback for OPFs too malformed for a real XML parse.
|
||||||
|
func findCoverInOPFLegacy(opfContent []byte, files []*zip.File, opfPath string) string {
|
||||||
contentStr := string(opfContent)
|
contentStr := string(opfContent)
|
||||||
|
|
||||||
// Look for item with properties="cover-image"
|
// Look for item with properties="cover-image"
|
||||||
@@ -1838,7 +2006,7 @@ func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string
|
|||||||
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
|
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
|
||||||
hrefMatches := hrefRE.FindStringSubmatch(contentStr)
|
hrefMatches := hrefRE.FindStringSubmatch(contentStr)
|
||||||
if len(hrefMatches) > 1 {
|
if len(hrefMatches) > 1 {
|
||||||
return resolveOPFPath(opfDir, hrefMatches[1])
|
return resolveOPFPath(opfPath, hrefMatches[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1852,7 +2020,7 @@ func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string
|
|||||||
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
|
hrefRE := regexp.MustCompile(fmt.Sprintf(`<item[^>]*id="%s"[^>]*href="([^"]+)"`, coverID))
|
||||||
hrefMatches := hrefRE.FindStringSubmatch(contentStr)
|
hrefMatches := hrefRE.FindStringSubmatch(contentStr)
|
||||||
if len(hrefMatches) > 1 {
|
if len(hrefMatches) > 1 {
|
||||||
return resolveOPFPath(opfDir, hrefMatches[1])
|
return resolveOPFPath(opfPath, hrefMatches[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1861,18 +2029,34 @@ func findCoverInOPF(opfContent []byte, files []*zip.File, opfDir string) string
|
|||||||
return findCoverImageInZip(files)
|
return findCoverImageInZip(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveOPFPath resolves a relative path against the OPF directory
|
// zipHasFile reports whether the zip contains an entry with exactly this name.
|
||||||
func resolveOPFPath(opfDir, href string) string {
|
func zipHasFile(files []*zip.File, name string) bool {
|
||||||
if opfDir == "" {
|
name = filepath.ToSlash(name)
|
||||||
return href
|
for _, f := range files {
|
||||||
|
if filepath.ToSlash(f.Name) == name {
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
// Handle ../ in href
|
|
||||||
if strings.HasPrefix(href, "../") {
|
|
||||||
// Simple case: just use the href as-is for now
|
|
||||||
return href
|
|
||||||
}
|
}
|
||||||
// Join the directory with the href
|
return false
|
||||||
return filepath.Join(filepath.Dir(opfDir), href)
|
}
|
||||||
|
|
||||||
|
// resolveOPFPath resolves an OPF-relative href against the OPF document's own
|
||||||
|
// path inside the zip. Hrefs are URL-decoded and normalized with posix path
|
||||||
|
// semantics ("../" walks up), matching Calibre's
|
||||||
|
// posixpath.normpath(posixpath.join(base, href)).
|
||||||
|
func resolveOPFPath(opfPath, href string) string {
|
||||||
|
if unescaped, err := url.PathUnescape(href); err == nil {
|
||||||
|
href = unescaped
|
||||||
|
}
|
||||||
|
href = strings.TrimPrefix(filepath.ToSlash(href), "/")
|
||||||
|
base := ""
|
||||||
|
if dir := path.Dir(filepath.ToSlash(opfPath)); dir != "." {
|
||||||
|
base = dir
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
return path.Clean(href)
|
||||||
|
}
|
||||||
|
return path.Clean(path.Join(base, href))
|
||||||
}
|
}
|
||||||
|
|
||||||
// readFileFromZip reads a file from the zip by name
|
// readFileFromZip reads a file from the zip by name
|
||||||
@@ -2078,17 +2262,10 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
|
|||||||
// Use pdfcpu API to extract images from first page
|
// Use pdfcpu API to extract images from first page
|
||||||
// ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error
|
// ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error
|
||||||
err = pdfcpuapi.ExtractImagesFile(pdfPath, tmpDir, []string{"1"}, nil)
|
err = pdfcpuapi.ExtractImagesFile(pdfPath, tmpDir, []string{"1"}, nil)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
// No images found or extraction failed - this is OK, just return empty
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for extracted images in the temp directory
|
// Check for extracted images in the temp directory
|
||||||
entries, err := os.ReadDir(tmpDir)
|
entries, err := os.ReadDir(tmpDir)
|
||||||
if err != nil || len(entries) == 0 {
|
if err == nil && len(entries) > 0 {
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the largest image (likely the cover)
|
// Find the largest image (likely the cover)
|
||||||
var largestImage string
|
var largestImage string
|
||||||
var largestSize int64
|
var largestSize int64
|
||||||
@@ -2111,16 +2288,10 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if largestImage == "" {
|
if largestImage != "" {
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the image
|
// Read the image
|
||||||
imageData, err := os.ReadFile(largestImage)
|
imageData, err := os.ReadFile(largestImage)
|
||||||
if err != nil || len(imageData) == 0 {
|
if err == nil && len(imageData) > 0 {
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
|
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
|
||||||
coverPath := pdfPath + ".cover.jpg"
|
coverPath := pdfPath + ".cover.jpg"
|
||||||
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
|
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
|
||||||
@@ -2129,6 +2300,61 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
|
|||||||
|
|
||||||
return coverPath, nil
|
return coverPath, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No embedded raster cover found (e.g. vector/text first page) - fall back
|
||||||
|
// to rendering the first page with pdftoppm (poppler-utils).
|
||||||
|
return s.renderPDFCoverPage(pdfPath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPDFCoverPage renders the first page of a PDF file to a JPEG image
|
||||||
|
// using pdftoppm. It saves the cover next to the PDF ({pdf_path}.cover.jpg).
|
||||||
|
// Returns the path to the saved cover, or empty string if rendering failed.
|
||||||
|
func (s *MediaScanner) renderPDFCoverPage(pdfPath string) string {
|
||||||
|
if _, err := exec.LookPath("pdftoppm"); err != nil {
|
||||||
|
fmt.Printf("Warning: pdftoppm not available, skipping PDF cover render for %s\n", pdfPath)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir, err := os.MkdirTemp("", "pdf-render-")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to create temp dir for PDF cover render %s: %v\n", pdfPath, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := os.RemoveAll(tmpDir); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to remove temp directory %s: %v\n", tmpDir, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
outPrefix := filepath.Join(tmpDir, "cover")
|
||||||
|
// -cropbox renders the CropBox (the viewer-visible region, matching pdf.js)
|
||||||
|
// rather than the MediaBox; poppler falls back to the MediaBox when no
|
||||||
|
// CropBox is defined. This matters for PDFs whose page 1 is a full print
|
||||||
|
// cover wrap (back + spine + front) with a CropBox covering just the front.
|
||||||
|
cmd := exec.Command("pdftoppm", "-jpeg", "-f", "1", "-l", "1", "-singlefile", "-cropbox", "-r", "150", pdfPath, outPrefix)
|
||||||
|
if output, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to render PDF cover from %s: %v, output: %s\n", pdfPath, err, string(output))
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
imageData, err := os.ReadFile(outPrefix + ".jpg")
|
||||||
|
if err != nil || len(imageData) < 1000 {
|
||||||
|
fmt.Printf("Warning: PDF cover render produced no usable image for %s\n", pdfPath)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
|
||||||
|
coverPath := pdfPath + ".cover.jpg"
|
||||||
|
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to write rendered PDF cover for %s: %v\n", pdfPath, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return coverPath
|
||||||
|
}
|
||||||
|
|
||||||
// ComicInfo represents metadata from ComicInfo.xml
|
// ComicInfo represents metadata from ComicInfo.xml
|
||||||
type ComicInfo struct {
|
type ComicInfo struct {
|
||||||
@@ -2562,7 +2788,11 @@ func countArchiveImages(filePath string) (int, error) {
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.UUID, path string, _ os.FileInfo) error {
|
// updateMediaItem re-extracts metadata for an existing media item and writes
|
||||||
|
// it back. Fields listed in the item's metadata_overrides set are preserved
|
||||||
|
// from the existing row so rescans never clobber user customizations; only
|
||||||
|
// reset-to-scanned-defaults (RescanMediaItem with reset) clears them.
|
||||||
|
func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.MediaItems, path string) error {
|
||||||
// Re-extract metadata for the update
|
// Re-extract metadata for the update
|
||||||
metadata, err := s.extractMetadata(path)
|
metadata, err := s.extractMetadata(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2581,8 +2811,8 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
|
|||||||
if metadata.AlternateInfo != "" {
|
if metadata.AlternateInfo != "" {
|
||||||
alternateInfoBytes = []byte(metadata.AlternateInfo)
|
alternateInfoBytes = []byte(metadata.AlternateInfo)
|
||||||
}
|
}
|
||||||
_, err = s.db.UpdateMediaItem(ctx, database.UpdateMediaItemParams{
|
params := database.UpdateMediaItemParams{
|
||||||
ID: mediaItemID,
|
ID: existing.ID,
|
||||||
Title: metadata.Title,
|
Title: metadata.Title,
|
||||||
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
|
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
|
||||||
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
||||||
@@ -2614,10 +2844,69 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
|
|||||||
AlternateInfo: alternateInfoBytes,
|
AlternateInfo: alternateInfoBytes,
|
||||||
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
|
ScanInformation: pgtype.Text{String: metadata.ScanInformation, Valid: metadata.ScanInformation != ""},
|
||||||
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
|
Summary: pgtype.Text{String: metadata.Summary, Valid: metadata.Summary != ""},
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Keep user-customized fields, and keep the override set itself intact.
|
||||||
|
utils.ApplyMetadataOverrides(¶ms, existing)
|
||||||
|
params.MetadataOverrides = existing.MetadataOverrides
|
||||||
|
|
||||||
|
_, err = s.db.UpdateMediaItem(ctx, params)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RescanMediaItem re-extracts metadata for a single media item and updates it.
|
||||||
|
// It is the per-book rescan used by the Edit Metadata dialog and backfills
|
||||||
|
// covers for items imported before the PDF render fallback existed.
|
||||||
|
// With resetOverrides, user customizations are discarded first: the item is
|
||||||
|
// returned to pure scanned defaults (the "Reset to Scanned" action).
|
||||||
|
func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.UUID, resetOverrides bool) error {
|
||||||
|
item, err := s.db.GetMediaItem(ctx, mediaItemID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("media item not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resetOverrides {
|
||||||
|
if err := s.db.ClearMediaItemMetadataOverrides(ctx, mediaItemID); err != nil {
|
||||||
|
return fmt.Errorf("failed to clear metadata overrides: %w", err)
|
||||||
|
}
|
||||||
|
item.MetadataOverrides = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID)
|
||||||
|
if err != nil || len(folders) == 0 {
|
||||||
|
return fmt.Errorf("no library folders found for library")
|
||||||
|
}
|
||||||
|
|
||||||
|
folderPaths := make([]string, 0, len(folders))
|
||||||
|
for _, folder := range folders {
|
||||||
|
folderPaths = append(folderPaths, folder.FolderPath)
|
||||||
|
}
|
||||||
|
s.folders = folderPaths
|
||||||
|
|
||||||
|
var fullPath string
|
||||||
|
for _, folder := range folders {
|
||||||
|
candidate := filepath.Join(folder.FolderPath, item.FilePath)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
fullPath = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fullPath == "" {
|
||||||
|
return fmt.Errorf("media file not found on disk: %s", item.FilePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(fullPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to stat media file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.updateMediaItem(ctx, item, fullPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to update media item: %w", err)
|
||||||
|
}
|
||||||
|
s.recomputeHashInfo(ctx, mediaItemID, item.LibraryID, fullPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) {
|
func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) {
|
||||||
return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
|
return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
|
||||||
FilePath: s.getRelativePath(filePath),
|
FilePath: s.getRelativePath(filePath),
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// createTestJPEGBytes returns the bytes of a minimal valid JPEG.
|
||||||
|
func createTestJPEGBytes() string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
||||||
|
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPragmaticStyleEPUB builds an EPUB modeled on Pragmatic Bookshelf
|
||||||
|
// output: the dc namespace declared on the <metadata> element (not on
|
||||||
|
// <package>), a scheme-less ISBN identifier, an OPF-declared cover, and a
|
||||||
|
// deliberately malformed chapter body. The malformed chapter is the
|
||||||
|
// regression trigger: the previous go-epub-based extractor failed the whole
|
||||||
|
// book when any chapter was unparseable and wrote blank metadata.
|
||||||
|
func createPragmaticStyleEPUB(epubPath string) error {
|
||||||
|
file, err := os.Create(epubPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
zipWriter := zip.NewWriter(file)
|
||||||
|
defer zipWriter.Close()
|
||||||
|
|
||||||
|
mimetypeW, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||||
|
Name: "mimetype",
|
||||||
|
Method: zip.Store,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mimetypeW.Write([]byte("application/epub+zip"))
|
||||||
|
|
||||||
|
files := map[string]string{
|
||||||
|
"META-INF/container.xml": `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
|
||||||
|
// dc namespace declared on <metadata>; identifiers carry no scheme attr
|
||||||
|
"OEBPS/content.opf": `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="PubID">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:language>en</dc:language>
|
||||||
|
<dc:title>A Common-Sense Guide</dc:title>
|
||||||
|
<dc:creator>Jay Wengrow</dc:creator>
|
||||||
|
<dc:publisher>The Pragmatic Bookshelf, LLC</dc:publisher>
|
||||||
|
<dc:description>Content that makes you a better programmer.</dc:description>
|
||||||
|
<dc:subject>Programming</dc:subject>
|
||||||
|
<dc:identifier id="PubID">978-1-68050-722-8</dc:identifier>
|
||||||
|
<meta name="cover" content="cover-image"/>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="cover-image" href="images/cover.jpg" media-type="image/jpeg"/>
|
||||||
|
<item id="ch1" href="ch1.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
</manifest>
|
||||||
|
<spine><itemref idref="ch1"/></spine>
|
||||||
|
</package>`,
|
||||||
|
// Malformed on purpose: unclosed tags
|
||||||
|
"OEBPS/ch1.xhtml": `<html><body><p>unclosed paragraph`,
|
||||||
|
"OEBPS/images/cover.jpg": createTestJPEGBytes(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, content := range files {
|
||||||
|
w, err := zipWriter.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte(content)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zipWriter.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExtractEPUBMetadataBrokenChapter guards the regression where one
|
||||||
|
// unparseable chapter made the extractor return nothing at all: metadata must
|
||||||
|
// come from the OPF regardless of chapter-body damage.
|
||||||
|
func TestExtractEPUBMetadataBrokenChapter(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
epubPath := filepath.Join(tmpDir, "book.epub")
|
||||||
|
if err := createPragmaticStyleEPUB(epubPath); err != nil {
|
||||||
|
t.Fatalf("failed to create test EPUB: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := NewMediaScanner(nil)
|
||||||
|
metadata, err := s.extractEPUBMetadata(epubPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extractEPUBMetadata() error: %v", err)
|
||||||
|
}
|
||||||
|
if metadata.Title != "A Common-Sense Guide" {
|
||||||
|
t.Errorf("Title = %q, want %q", metadata.Title, "A Common-Sense Guide")
|
||||||
|
}
|
||||||
|
if metadata.Author != "Jay Wengrow" {
|
||||||
|
t.Errorf("Author = %q, want %q", metadata.Author, "Jay Wengrow")
|
||||||
|
}
|
||||||
|
if metadata.Publisher != "The Pragmatic Bookshelf, LLC" {
|
||||||
|
t.Errorf("Publisher = %q, want %q", metadata.Publisher, "The Pragmatic Bookshelf, LLC")
|
||||||
|
}
|
||||||
|
if metadata.Description == "" {
|
||||||
|
t.Error("Description missing")
|
||||||
|
}
|
||||||
|
if metadata.Language != "en" {
|
||||||
|
t.Errorf("Language = %q, want %q", metadata.Language, "en")
|
||||||
|
}
|
||||||
|
// Scheme-less identifier that normalizes to a valid ISBN must be picked up
|
||||||
|
if metadata.ISBN == "" {
|
||||||
|
t.Error("ISBN missing (scheme-less dc:identifier fallback failed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseOPFContentCalibreSeries(t *testing.T) {
|
||||||
|
opf := `<?xml version="1.0"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||||
|
<dc:title>Test Book</dc:title>
|
||||||
|
<dc:creator>Some Author</dc:creator>
|
||||||
|
<dc:date>2020-03-15</dc:date>
|
||||||
|
<dc:subject>Fiction</dc:subject>
|
||||||
|
<dc:subject>Classic</dc:subject>
|
||||||
|
<dc:identifier opf:scheme="ISBN">978-3-16-148410-0</dc:identifier>
|
||||||
|
<meta name="calibre:series" content="Great Series"/>
|
||||||
|
<meta name="calibre:series_index" content="2.5"/>
|
||||||
|
</metadata>
|
||||||
|
</package>`
|
||||||
|
metadata, err := parseOPFContent([]byte(opf))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseOPFContent() error: %v", err)
|
||||||
|
}
|
||||||
|
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2 {
|
||||||
|
t.Errorf("Series = %q/%d, want Great Series/2", metadata.Series, metadata.SeriesNumber)
|
||||||
|
}
|
||||||
|
if metadata.ISBN == "" {
|
||||||
|
t.Error("schemed ISBN not extracted")
|
||||||
|
}
|
||||||
|
wantDate := time.Date(2020, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
if !metadata.PublishDate.Equal(wantDate) {
|
||||||
|
t.Errorf("PublishDate = %v, want %v", metadata.PublishDate, wantDate)
|
||||||
|
}
|
||||||
|
if len(metadata.Tags) != 2 {
|
||||||
|
t.Errorf("Tags = %v, want 2 subjects", metadata.Tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractAudiobookshelfSidecar(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
json string
|
||||||
|
validate func(t *testing.T, m *MediaMetadata)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "full sidecar",
|
||||||
|
json: `{
|
||||||
|
"title": "An Book",
|
||||||
|
"authors": ["Author One", "Author Two"],
|
||||||
|
"series": [{"series": "The Series", "sequence": "4.5"}],
|
||||||
|
"genres": ["Fantasy"],
|
||||||
|
"tags": ["tag1"],
|
||||||
|
"publishedYear": 2019,
|
||||||
|
"publisher": "ACME Books",
|
||||||
|
"description": "A very good book.",
|
||||||
|
"isbn": "978-3-16-148410-0",
|
||||||
|
"asin": "B08XYZ",
|
||||||
|
"language": "en"
|
||||||
|
}`,
|
||||||
|
validate: func(t *testing.T, m *MediaMetadata) {
|
||||||
|
if m.Title != "An Book" || m.Author != "Author One" {
|
||||||
|
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
||||||
|
}
|
||||||
|
if m.Series != "The Series" || m.SeriesNumber != 4 {
|
||||||
|
t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber)
|
||||||
|
}
|
||||||
|
if len(m.Tags) != 2 {
|
||||||
|
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
|
||||||
|
}
|
||||||
|
if m.PublishDate.Year() != 2019 {
|
||||||
|
t.Errorf("PublishDate year = %d, want 2019", m.PublishDate.Year())
|
||||||
|
}
|
||||||
|
if m.Publisher != "ACME Books" || m.Description != "A very good book." {
|
||||||
|
t.Errorf("Publisher/Description = %q/%q", m.Publisher, m.Description)
|
||||||
|
}
|
||||||
|
if m.ISBN == "" || m.ASIN != "B08XYZ" || m.Language != "en" {
|
||||||
|
t.Errorf("ISBN/ASIN/Language = %q/%q/%q", m.ISBN, m.ASIN, m.Language)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sparse sidecar (real-world Audiobookshelf export)",
|
||||||
|
json: `{"title": "Sparse (1234)", "authors": ["X"], "tags": [], "description": null}`,
|
||||||
|
validate: func(t *testing.T, m *MediaMetadata) {
|
||||||
|
if m.Title != "Sparse (1234)" || m.Author != "X" {
|
||||||
|
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
||||||
|
}
|
||||||
|
if m.Description != "" || m.Tags != nil {
|
||||||
|
t.Error("null/empty sidecar fields must stay unset")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(tt.json), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub"))
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("extractAudiobookshelfSidecar() = nil, want metadata")
|
||||||
|
}
|
||||||
|
tt.validate(t, m)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no sidecar returns nil", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub")); m != nil {
|
||||||
|
t.Errorf("extractAudiobookshelfSidecar() = %v, want nil", m)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Calibre-modeled OPF parsing. The scanner previously scraped OPF content
|
||||||
|
// with attribute-order-sensitive regexes; real books serialize attributes in
|
||||||
|
// any order (e.g. Pragmatic/Pattinson EPUBs put id before properties and
|
||||||
|
// content before name), which silently defeated cover detection. Everything
|
||||||
|
// here is parsed with encoding/xml so attribute order and namespace prefix
|
||||||
|
// choices are irrelevant.
|
||||||
|
|
||||||
|
type opfDCValue struct {
|
||||||
|
ID string `xml:"id,attr"`
|
||||||
|
Value string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type opfIdentifier struct {
|
||||||
|
Scheme string `xml:"http://www.idpf.org/2007/opf scheme,attr"`
|
||||||
|
Value string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type opfMeta struct {
|
||||||
|
ID string `xml:"id,attr"`
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Content string `xml:"content,attr"`
|
||||||
|
Property string `xml:"property,attr"`
|
||||||
|
Refines string `xml:"refines,attr"`
|
||||||
|
Value string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type opfItem struct {
|
||||||
|
ID string `xml:"id,attr"`
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
MediaType string `xml:"media-type,attr"`
|
||||||
|
Properties string `xml:"properties,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// opfDocument is a structured view of an OPF package document.
|
||||||
|
type opfDocument struct {
|
||||||
|
Metadata struct {
|
||||||
|
Titles []opfDCValue `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||||
|
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||||
|
Subjects []string `xml:"http://purl.org/dc/elements/1.1/ subject"`
|
||||||
|
Descriptions []string `xml:"http://purl.org/dc/elements/1.1/ description"`
|
||||||
|
Publishers []string `xml:"http://purl.org/dc/elements/1.1/ publisher"`
|
||||||
|
Dates []string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||||
|
Languages []string `xml:"http://purl.org/dc/elements/1.1/ language"`
|
||||||
|
Identifiers []opfIdentifier `xml:"http://purl.org/dc/elements/1.1/ identifier"`
|
||||||
|
Contributors []string `xml:"http://purl.org/dc/elements/1.1/ contributor"`
|
||||||
|
Metas []opfMeta `xml:"meta"`
|
||||||
|
} `xml:"metadata"`
|
||||||
|
Manifest struct {
|
||||||
|
Items []opfItem `xml:"item"`
|
||||||
|
} `xml:"manifest"`
|
||||||
|
Spine struct {
|
||||||
|
Itemrefs []struct {
|
||||||
|
IDRef string `xml:"idref,attr"`
|
||||||
|
} `xml:"itemref"`
|
||||||
|
} `xml:"spine"`
|
||||||
|
Guide struct {
|
||||||
|
References []struct {
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
} `xml:"reference"`
|
||||||
|
} `xml:"guide"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOPFXML(content []byte) (*opfDocument, error) {
|
||||||
|
var doc opfDocument
|
||||||
|
if err := xml.Unmarshal(content, &doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &doc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refinesFor maps an element id to its EPUB3 refining metas
|
||||||
|
// (those whose refines attribute starts with '#').
|
||||||
|
func (d *opfDocument) refinesFor(id string) []opfMeta {
|
||||||
|
var out []opfMeta
|
||||||
|
if id == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, m := range d.Metadata.Metas {
|
||||||
|
if strings.HasPrefix(m.Refines, "#") && m.Refines[1:] == id {
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// refinesProperty returns the value of the first refining meta carrying the
|
||||||
|
// given property (e.g. "title-type", "collection-type", "group-position").
|
||||||
|
func refinesProperty(metas []opfMeta, property string) (string, bool) {
|
||||||
|
for _, m := range metas {
|
||||||
|
if strings.EqualFold(m.Property, property) {
|
||||||
|
if v := strings.TrimSpace(m.Value); v != "" {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectTitle ports Calibre's read_title: prefer the dc:title refined as
|
||||||
|
// title-type "main"; fall back to the first non-empty title. A distinct
|
||||||
|
// subtitle (title-type containing "subtitle"/"sub-title") is joined onto the
|
||||||
|
// main title with ": ", exactly as Calibre stores it.
|
||||||
|
func (d *opfDocument) selectTitle() string {
|
||||||
|
var first, main, subtitle string
|
||||||
|
for _, t := range d.Metadata.Titles {
|
||||||
|
v := strings.TrimSpace(t.Value)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if first == "" {
|
||||||
|
first = v
|
||||||
|
}
|
||||||
|
tt, ok := refinesProperty(d.refinesFor(t.ID), "title-type")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.ToLower(tt) {
|
||||||
|
case "main":
|
||||||
|
if main == "" {
|
||||||
|
main = v
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
l := strings.ToLower(tt)
|
||||||
|
if strings.Contains(l, "subtitle") || strings.Contains(l, "sub-title") {
|
||||||
|
if subtitle == "" {
|
||||||
|
subtitle = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title := main
|
||||||
|
if title == "" {
|
||||||
|
title = first
|
||||||
|
}
|
||||||
|
if subtitle != "" && subtitle != title {
|
||||||
|
title = title + ": " + subtitle
|
||||||
|
}
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSeries ports Calibre's read_series: EPUB3 belongs-to-collection (with a
|
||||||
|
// collection-type=series refine and group-position index) first, then the
|
||||||
|
// classic calibre:series / calibre:series_index metas.
|
||||||
|
func (d *opfDocument) readSeries() (series string, index float64) {
|
||||||
|
for _, m := range d.Metadata.Metas {
|
||||||
|
if !strings.EqualFold(m.Property, "belongs-to-collection") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(m.Value)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
refines := d.refinesFor(m.ID)
|
||||||
|
if ct, ok := refinesProperty(refines, "collection-type"); !ok || !strings.EqualFold(ct, "series") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if gp, ok := refinesProperty(refines, "group-position"); ok {
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSpace(gp), 64); err == nil {
|
||||||
|
index = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name, index
|
||||||
|
}
|
||||||
|
for _, m := range d.Metadata.Metas {
|
||||||
|
switch m.Name {
|
||||||
|
case "calibre:series":
|
||||||
|
series = m.Content
|
||||||
|
case "calibre:series_index":
|
||||||
|
if v, err := strconv.ParseFloat(strings.TrimSpace(m.Content), 64); err == nil {
|
||||||
|
index = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return series, index
|
||||||
|
}
|
||||||
|
|
||||||
|
// itemByID returns manifest items with id, href and media-type, keyed by id.
|
||||||
|
func (d *opfDocument) itemByID() map[string]opfItem {
|
||||||
|
m := make(map[string]opfItem, len(d.Manifest.Items))
|
||||||
|
for _, it := range d.Manifest.Items {
|
||||||
|
if it.ID != "" && it.Href != "" && it.MediaType != "" {
|
||||||
|
m[it.ID] = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// firstSpineItem returns the manifest item for the first spine idref.
|
||||||
|
func (d *opfDocument) firstSpineItem() (opfItem, bool) {
|
||||||
|
if len(d.Spine.Itemrefs) == 0 {
|
||||||
|
return opfItem{}, false
|
||||||
|
}
|
||||||
|
item, ok := d.itemByID()[d.Spine.Itemrefs[0].IDRef]
|
||||||
|
return item, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRasterMedia reports whether a manifest media-type is an image but not an
|
||||||
|
// (X)HTML document - Calibre's guard against cover *pages* masquerading as
|
||||||
|
// cover images.
|
||||||
|
func isRasterMedia(mediaType string) bool {
|
||||||
|
mt := strings.ToLower(strings.TrimSpace(mediaType))
|
||||||
|
if mt == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(mt, "xml") || strings.Contains(mt, "html") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(mt, "image/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRasterCoverInOPF ports Calibre's read_raster_cover resolution order:
|
||||||
|
// 1. manifest item with properties containing "cover-image"
|
||||||
|
// 2. <meta name="cover" content="ID"> resolved through the manifest
|
||||||
|
// 3. the first spine item being a raster image itself (store manga)
|
||||||
|
//
|
||||||
|
// Returns the OPF-relative href of the cover image, or "".
|
||||||
|
func (d *opfDocument) findRasterCoverInOPF() string {
|
||||||
|
// 1. properties="cover-image" (space-separated property list)
|
||||||
|
for _, it := range d.Manifest.Items {
|
||||||
|
for _, prop := range strings.Fields(it.Properties) {
|
||||||
|
if strings.EqualFold(prop, "cover-image") && isRasterMedia(it.MediaType) {
|
||||||
|
return it.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. meta name="cover" content=<manifest image id>
|
||||||
|
byID := d.itemByID()
|
||||||
|
for _, m := range d.Metadata.Metas {
|
||||||
|
if !strings.EqualFold(m.Name, "cover") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it, ok := byID[strings.TrimSpace(m.Content)]; ok && isRasterMedia(it.MediaType) {
|
||||||
|
return it.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. first spine item is itself an image (jpeg/webp/png per Calibre)
|
||||||
|
if it, ok := d.firstSpineItem(); ok {
|
||||||
|
mt := strings.ToLower(it.MediaType)
|
||||||
|
if mt == "image/jpeg" || mt == "image/webp" || mt == "image/png" {
|
||||||
|
return it.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverPageHref returns the OPF-relative href of the cover *page* document to
|
||||||
|
// mine for an embedded image: the guide's type="cover" reference when
|
||||||
|
// present, otherwise the first spine item (Calibre renders the latter).
|
||||||
|
func (d *opfDocument) coverPageHref() string {
|
||||||
|
for _, ref := range d.Guide.References {
|
||||||
|
if strings.EqualFold(ref.Type, "cover") && ref.Href != "" {
|
||||||
|
return ref.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if it, ok := d.firstSpineItem(); ok {
|
||||||
|
if it.Href != "" && !isRasterMedia(it.MediaType) {
|
||||||
|
return it.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// isISBNLike reports whether a bare identifier value is shaped like an ISBN
|
||||||
|
// (digits, optional hyphens/spaces, optional trailing X; 10 or 13
|
||||||
|
// significant characters). Guards the scheme-less dc:identifier fallback
|
||||||
|
// against URLs and UUIDs sharing the same slot.
|
||||||
|
func isISBNLike(v string) bool {
|
||||||
|
digits := 0
|
||||||
|
for i, r := range v {
|
||||||
|
switch {
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
digits++
|
||||||
|
case r == '-' || r == ' ':
|
||||||
|
// separator
|
||||||
|
case (r == 'X' || r == 'x') && i == len(v)-1:
|
||||||
|
digits++ // ISBN-10 check character
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return digits == 10 || digits == 13
|
||||||
|
}
|
||||||
|
|
||||||
|
// findImageReferenceInPage extracts the first raster image reference from a
|
||||||
|
// cover (X)HTML page: <img src="..."> or SVG <image xlink:href="...">.
|
||||||
|
// Token-based parsing keeps it tolerant of mixed namespaces and fragments.
|
||||||
|
// Returns the reference relative to the page document, or "".
|
||||||
|
func findImageReferenceInPage(pageContent []byte) string {
|
||||||
|
decoder := xml.NewDecoder(bytes.NewReader(pageContent))
|
||||||
|
for {
|
||||||
|
tok, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
start, ok := tok.(xml.StartElement)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.ToLower(start.Name.Local) {
|
||||||
|
case "img":
|
||||||
|
for _, a := range start.Attr {
|
||||||
|
if strings.EqualFold(a.Name.Local, "src") && strings.TrimSpace(a.Value) != "" {
|
||||||
|
return strings.TrimSpace(a.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "image":
|
||||||
|
for _, a := range start.Attr {
|
||||||
|
if strings.EqualFold(a.Name.Local, "href") && strings.TrimSpace(a.Value) != "" {
|
||||||
|
return strings.TrimSpace(a.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// helper to build an EPUB zip from a file map for cover tests
|
||||||
|
func writeEPUB(t *testing.T, path string, files map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w := zip.NewWriter(f)
|
||||||
|
mimetype, err := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mimetype.Write([]byte("application/epub+zip"))
|
||||||
|
for name, content := range files {
|
||||||
|
fw, err := w.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := fw.Write([]byte(content)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerXML = `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`
|
||||||
|
|
||||||
|
const tinyJPEG = "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
|
||||||
|
|
||||||
|
// TestFindCoverInOPFAttributeOrder guards the regression where attribute
|
||||||
|
// order defeated regex scraping: this OPF mirrors Grand Central's "3 Days to
|
||||||
|
// Live" serialization (href before id, content before name on the meta tag).
|
||||||
|
func TestFindCoverInOPFAttributeOrder(t *testing.T) {
|
||||||
|
opf := `<?xml version="1.0"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:title>3 Days to Live</dc:title>
|
||||||
|
<meta content="cover-image" name="cover"/>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
<item href="images/9781538752760.jpg" id="cover-image" media-type="image/jpeg" properties="cover-image"/>
|
||||||
|
</manifest>
|
||||||
|
<spine/>
|
||||||
|
</package>`
|
||||||
|
files := map[string]string{
|
||||||
|
"META-INF/container.xml": containerXML,
|
||||||
|
"OEBPS/package.opf": opf,
|
||||||
|
"OEBPS/images/9781538752760.jpg": tinyJPEG,
|
||||||
|
}
|
||||||
|
epubPath := filepath.Join(t.TempDir(), "book.epub")
|
||||||
|
writeEPUB(t, epubPath, files)
|
||||||
|
|
||||||
|
s := NewMediaScanner(nil)
|
||||||
|
coverPath, err := s.extractEPUBCover(epubPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||||
|
}
|
||||||
|
if coverPath == "" {
|
||||||
|
t.Fatal("cover not extracted - attribute order still defeats resolution")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(coverPath); err != nil {
|
||||||
|
t.Fatalf("cover file not written: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindCoverInOPFCoverPage covers books that declare no raster cover at
|
||||||
|
// all: the classic EPUB2/Adobe structure where cover.xhtml wraps the image
|
||||||
|
// (here via SVG), reachable through the guide reference or first spine item.
|
||||||
|
func TestFindCoverInOPFCoverPage(t *testing.T) {
|
||||||
|
opf := `<?xml version="1.0"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:title>Old Adobe Book</dc:title>
|
||||||
|
</metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="coverpage" href="text/cover.xhtml" media-type="application/xhtml+xml"/>
|
||||||
|
<item id="coverimg" href="art/cover-wrap.jpg" media-type="image/jpeg"/>
|
||||||
|
</manifest>
|
||||||
|
<spine><itemref idref="coverpage"/></spine>
|
||||||
|
<guide><reference type="cover" href="text/cover.xhtml"/></guide>
|
||||||
|
</package>`
|
||||||
|
coverPage := `<?xml version="1.0"?>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<body>
|
||||||
|
<div><svg xmlns="http://www.w3.org/2000/svg"><image xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="../art/cover-wrap.jpg"/></svg></div>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
files := map[string]string{
|
||||||
|
"META-INF/container.xml": containerXML,
|
||||||
|
"OEBPS/package.opf": opf,
|
||||||
|
"OEBPS/text/cover.xhtml": coverPage,
|
||||||
|
"OEBPS/art/cover-wrap.jpg": tinyJPEG,
|
||||||
|
}
|
||||||
|
epubPath := filepath.Join(t.TempDir(), "adobe.epub")
|
||||||
|
writeEPUB(t, epubPath, files)
|
||||||
|
|
||||||
|
s := NewMediaScanner(nil)
|
||||||
|
coverPath, err := s.extractEPUBCover(epubPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||||
|
}
|
||||||
|
if coverPath == "" {
|
||||||
|
t.Fatal("cover-page fallback failed to find SVG-wrapped image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindCoverInOPFImageFirstSpine covers store manga whose first spine
|
||||||
|
// item is a raster image itself (Calibre's third resolution step).
|
||||||
|
func TestFindCoverInOPFImageFirstSpine(t *testing.T) {
|
||||||
|
opf := `<?xml version="1.0"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Manga Vol 1</dc:title></metadata>
|
||||||
|
<manifest>
|
||||||
|
<item id="p1" href="pages/0001.jpg" media-type="image/jpeg"/>
|
||||||
|
</manifest>
|
||||||
|
<spine><itemref idref="p1"/></spine>
|
||||||
|
</package>`
|
||||||
|
files := map[string]string{
|
||||||
|
"META-INF/container.xml": containerXML,
|
||||||
|
"OEBPS/package.opf": opf,
|
||||||
|
"OEBPS/pages/0001.jpg": tinyJPEG,
|
||||||
|
}
|
||||||
|
epubPath := filepath.Join(t.TempDir(), "manga.epub")
|
||||||
|
writeEPUB(t, epubPath, files)
|
||||||
|
|
||||||
|
s := NewMediaScanner(nil)
|
||||||
|
coverPath, err := s.extractEPUBCover(epubPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||||
|
}
|
||||||
|
if coverPath == "" {
|
||||||
|
t.Fatal("image-first spine cover not detected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseOPFContentTitleTypeAndSeries covers EPUB3 refines-based title
|
||||||
|
// selection (main + subtitle joined Calibre-style) and belongs-to-collection
|
||||||
|
// series with collection-type and group-position refines.
|
||||||
|
func TestParseOPFContentTitleTypeAndSeries(t *testing.T) {
|
||||||
|
opf := `<?xml version="1.0"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:title id="t1">The Main Title</dc:title>
|
||||||
|
<dc:title id="t2">The Subtitle</dc:title>
|
||||||
|
<meta refines="#t1" property="title-type">main</meta>
|
||||||
|
<meta refines="#t2" property="title-type">subtitle</meta>
|
||||||
|
<dc:subject>Programming</dc:subject>
|
||||||
|
<dc:subject>Algorithms</dc:subject>
|
||||||
|
<dc:identifier>urn:isbn:978-3-16-148410-0</dc:identifier>
|
||||||
|
<meta id="coll1" property="belongs-to-collection">Great Series</meta>
|
||||||
|
<meta refines="#coll1" property="collection-type">series</meta>
|
||||||
|
<meta refines="#coll1" property="group-position">4.5</meta>
|
||||||
|
</metadata>
|
||||||
|
</package>`
|
||||||
|
metadata, err := parseOPFContent([]byte(opf))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseOPFContent() error: %v", err)
|
||||||
|
}
|
||||||
|
if want := "The Main Title: The Subtitle"; metadata.Title != want {
|
||||||
|
t.Errorf("Title = %q, want %q", metadata.Title, want)
|
||||||
|
}
|
||||||
|
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4 {
|
||||||
|
t.Errorf("Series = %q/%d, want Great Series/4", metadata.Series, metadata.SeriesNumber)
|
||||||
|
}
|
||||||
|
if metadata.ISBN == "" {
|
||||||
|
t.Error("urn:isbn: identifier not extracted")
|
||||||
|
}
|
||||||
|
if metadata.Genre != "Programming" {
|
||||||
|
t.Errorf("Genre = %q, want first subject %q", metadata.Genre, "Programming")
|
||||||
|
}
|
||||||
|
if len(metadata.Tags) != 2 {
|
||||||
|
t.Errorf("Tags = %v, want both subjects", metadata.Tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseOPFContentSubjectsWithCommas verifies subject headings keep their
|
||||||
|
// embedded commas as single tags (Library of Congress style headings).
|
||||||
|
func TestParseOPFContentSubjectsWithCommas(t *testing.T) {
|
||||||
|
opf := `<?xml version="1.0"?>
|
||||||
|
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||||
|
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
|
<dc:title>A Study in Scarlet</dc:title>
|
||||||
|
<dc:subject>Holmes, Sherlock (Fictitious character) -- Fiction</dc:subject>
|
||||||
|
</metadata>
|
||||||
|
</package>`
|
||||||
|
metadata, err := parseOPFContent([]byte(opf))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseOPFContent() error: %v", err)
|
||||||
|
}
|
||||||
|
if len(metadata.Tags) != 1 {
|
||||||
|
t.Errorf("Tags = %v, want exactly 1 unsplit subject heading", metadata.Tags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveOPFPath checks URL decoding and posix normalization of
|
||||||
|
// OPF-relative hrefs.
|
||||||
|
func TestResolveOPFPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
opfPath, href, want string
|
||||||
|
}{
|
||||||
|
{"OEBPS/package.opf", "images/cover.jpg", "OEBPS/images/cover.jpg"},
|
||||||
|
{"package.opf", "cover.jpg", "cover.jpg"},
|
||||||
|
{"OEBPS/package.opf", "../cover.jpg", "cover.jpg"},
|
||||||
|
{"OEBPS/package.opf", "my%20covers/a%20cover.jpg", "OEBPS/my covers/a cover.jpg"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := resolveOPFPath(tt.opfPath, tt.href); got != tt.want {
|
||||||
|
t.Errorf("resolveOPFPath(%q, %q) = %q, want %q", tt.opfPath, tt.href, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,13 @@ type SaveHighlightRequest struct {
|
|||||||
EndPosition string
|
EndPosition string
|
||||||
Color string
|
Color string
|
||||||
NoteText string
|
NoteText string
|
||||||
|
// HighlightID, when valid, targets that exact row (web PUTs edit by
|
||||||
|
// id): the save LWWs against it directly under its stored dedup key.
|
||||||
|
// The computed key depends on fields that legitimately change — the
|
||||||
|
// stored CFI drifts range→point shape after device echoes, and the
|
||||||
|
// user can edit the selection text — so a key-based upsert would mint
|
||||||
|
// a duplicate beside the very row being edited.
|
||||||
|
HighlightID pgtype.UUID
|
||||||
PercentageStart float64
|
PercentageStart float64
|
||||||
PercentageEnd float64
|
PercentageEnd float64
|
||||||
EpubcfiStart string
|
EpubcfiStart string
|
||||||
@@ -90,6 +97,35 @@ type SaveHighlightResult struct {
|
|||||||
|
|
||||||
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
|
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
|
||||||
dedupKey := req.DedupKey
|
dedupKey := req.DedupKey
|
||||||
|
|
||||||
|
// Web edits arrive with the row id from the URL: resolve by id first
|
||||||
|
// and LWW against that row under its stored key. Content-derived keys
|
||||||
|
// are for lookups by identity (device pushes carry no row ids); an
|
||||||
|
// edit must never re-derive identity from (possibly edited) content.
|
||||||
|
if req.HighlightID.Valid {
|
||||||
|
byID, idErr := s.db.GetMediaHighlight(ctx, req.HighlightID)
|
||||||
|
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("query highlight by id: %w", idErr)
|
||||||
|
}
|
||||||
|
if idErr == nil {
|
||||||
|
if byID.UserID != req.UserID || byID.MediaItemID != req.MediaItemID {
|
||||||
|
return nil, fmt.Errorf("highlight %s belongs to another user or media item", req.HighlightID)
|
||||||
|
}
|
||||||
|
if dedupKey == "" {
|
||||||
|
dedupKey = byID.DedupKey.String
|
||||||
|
}
|
||||||
|
if byID.Deleted.Bool {
|
||||||
|
if !incomingNewerThanTombstone(req.ModifiedAt, byID.DeletedAt, byID.LastModifiedAt) {
|
||||||
|
return &SaveHighlightResult{Highlight: byID, Outcome: SaveOutcomeDeleted}, nil
|
||||||
|
}
|
||||||
|
// Newer than the tombstone: a deliberate re-create. Resurrect
|
||||||
|
// via the LWW update (which clears deleted/deleted_at).
|
||||||
|
}
|
||||||
|
return s.applyLWW(ctx, req, byID, dedupKey)
|
||||||
|
}
|
||||||
|
// No row with that id: fall through to identity-based resolution.
|
||||||
|
}
|
||||||
|
|
||||||
if dedupKey == "" {
|
if dedupKey == "" {
|
||||||
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
||||||
}
|
}
|
||||||
@@ -186,17 +222,38 @@ func (s *AnnotationService) applyLWW(
|
|||||||
|
|
||||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||||
|
|
||||||
|
// Web edits carry no device locators (the web reader never had a CRE
|
||||||
|
// xpointer) and may carry no CFI either: keep the stored ones so
|
||||||
|
// device-native serve-back and round-trip identity survive a web-side
|
||||||
|
// note/color edit instead of being wiped to empty.
|
||||||
|
startPosition := req.StartPosition
|
||||||
|
if startPosition == "" {
|
||||||
|
startPosition = existing.StartPosition.String
|
||||||
|
}
|
||||||
|
endPosition := req.EndPosition
|
||||||
|
if endPosition == "" {
|
||||||
|
endPosition = existing.EndPosition.String
|
||||||
|
}
|
||||||
|
epubcfiStart := req.EpubcfiStart
|
||||||
|
if epubcfiStart == "" {
|
||||||
|
epubcfiStart = existing.EpubcfiStart.String
|
||||||
|
}
|
||||||
|
epubcfiEnd := req.EpubcfiEnd
|
||||||
|
if epubcfiEnd == "" {
|
||||||
|
epubcfiEnd = existing.EpubcfiEnd.String
|
||||||
|
}
|
||||||
|
|
||||||
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
SelectionText: req.SelectionText,
|
SelectionText: req.SelectionText,
|
||||||
StartPosition: pgText(req.StartPosition),
|
StartPosition: pgText(startPosition),
|
||||||
EndPosition: pgText(req.EndPosition),
|
EndPosition: pgText(endPosition),
|
||||||
Color: pgText(req.Color),
|
Color: pgText(req.Color),
|
||||||
NoteText: pgText(req.NoteText),
|
NoteText: pgText(req.NoteText),
|
||||||
PercentageStart: pgFloat8(req.PercentageStart),
|
PercentageStart: pgFloat8(req.PercentageStart),
|
||||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
EpubcfiStart: pgText(epubcfiStart),
|
||||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
EpubcfiEnd: pgText(epubcfiEnd),
|
||||||
ChapterReference: pgInt4(req.ChapterReference),
|
ChapterReference: pgInt4(req.ChapterReference),
|
||||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||||
@@ -222,7 +279,9 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
|
|||||||
textEq(req.Color, existing.Color) &&
|
textEq(req.Color, existing.Color) &&
|
||||||
textEq(req.NoteText, existing.NoteText) &&
|
textEq(req.NoteText, existing.NoteText) &&
|
||||||
floatEq(req.PercentageStart, existing.PercentageStart) &&
|
floatEq(req.PercentageStart, existing.PercentageStart) &&
|
||||||
floatEq(req.PercentageEnd, existing.PercentageEnd)
|
floatEq(req.PercentageEnd, existing.PercentageEnd) &&
|
||||||
|
locatorRefresher(req.EpubcfiStart, existing.EpubcfiStart) &&
|
||||||
|
locatorRefresher(req.EpubcfiEnd, existing.EpubcfiEnd)
|
||||||
return !contentSame, !contentSame
|
return !contentSame, !contentSame
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +292,22 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
|
|||||||
return req.ModifiedAt.After(existingMod.Time), true
|
return req.ModifiedAt.After(existingMod.Time), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// locatorRefresher reports whether an incoming locator leaves the stored
|
||||||
|
// one untouched: either the push carried none (empty coalesces to the
|
||||||
|
// stored value in applyLWW) or it matches what is stored. A non-empty
|
||||||
|
// incoming locator that DIFFERS is a deliberate refresh: device echoes
|
||||||
|
// re-derive their canonical CFIs on every push, and an improvement (a
|
||||||
|
// converter fix re-landing a corrupted anchor, drift repair) must reach
|
||||||
|
// the row even when the annotation content is otherwise identical —
|
||||||
|
// without this, content-equal echoes resolve to "skip" and a bad stored
|
||||||
|
// locator can never heal.
|
||||||
|
func locatorRefresher(incoming string, existing pgtype.Text) bool {
|
||||||
|
if incoming == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return textEq(incoming, existing)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AnnotationService) TombstoneHighlight(
|
func (s *AnnotationService) TombstoneHighlight(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID, mediaItemID pgtype.UUID,
|
userID, mediaItemID pgtype.UUID,
|
||||||
@@ -687,13 +762,25 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
|
|||||||
}
|
}
|
||||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||||
|
|
||||||
|
// Web saves carry no device locators: keep the stored ones so a web
|
||||||
|
// title/note edit never wipes the device-native position (mirrors the
|
||||||
|
// highlight path's coalescing).
|
||||||
|
cfiPosition := req.CFIPosition
|
||||||
|
if cfiPosition == "" {
|
||||||
|
cfiPosition = existing.CfiPosition.String
|
||||||
|
}
|
||||||
|
position := req.Position
|
||||||
|
if position == "" {
|
||||||
|
position = existing.Position.String
|
||||||
|
}
|
||||||
|
|
||||||
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
PageNumber: pgInt4(req.PageNumber),
|
PageNumber: pgInt4(req.PageNumber),
|
||||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||||
CfiPosition: pgText(req.CFIPosition),
|
CfiPosition: pgText(cfiPosition),
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Position: pgText(req.Position),
|
Position: pgText(position),
|
||||||
Notes: pgText(req.Notes),
|
Notes: pgText(req.Notes),
|
||||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||||
@@ -718,7 +805,9 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
|
|||||||
func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
|
func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
|
||||||
if req.ModifiedAt.IsZero() {
|
if req.ModifiedAt.IsZero() {
|
||||||
contentSame := strings.EqualFold(req.Title, existing.Title) &&
|
contentSame := strings.EqualFold(req.Title, existing.Title) &&
|
||||||
textEq(req.Notes, existing.Notes)
|
textEq(req.Notes, existing.Notes) &&
|
||||||
|
locatorRefresher(req.CFIPosition, existing.CfiPosition) &&
|
||||||
|
locatorRefresher(req.Position, existing.Position)
|
||||||
return !contentSame, !contentSame
|
return !contentSame, !contentSame
|
||||||
}
|
}
|
||||||
existingMod := existing.LastModifiedAt
|
existingMod := existing.LastModifiedAt
|
||||||
|
|||||||
@@ -369,3 +369,82 @@ func TestIncomingNewerThanTombstone(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Content-equal device echoes must still count as changed when they carry
|
||||||
|
// a locator that differs from the stored one: echoes re-derive canonical
|
||||||
|
// CFIs on every push, and a better conversion (or a repair of a corrupted
|
||||||
|
// anchor) has to reach the row — otherwise the skip path discards it and
|
||||||
|
// the bad locator can never heal.
|
||||||
|
func TestCompareIncomingLocatorDriftRefreshes(t *testing.T) {
|
||||||
|
svc := &AnnotationService{}
|
||||||
|
existing := database.MediaHighlights{
|
||||||
|
SelectionText: "CHAPTER IV. “What a pity it is, Elinor,”",
|
||||||
|
Color: pgtype.Text{String: "#90caf9", Valid: true},
|
||||||
|
EpubcfiStart: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/32/3:576)", Valid: true},
|
||||||
|
EpubcfiEnd: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/4/1:93)", Valid: true},
|
||||||
|
}
|
||||||
|
base := SaveHighlightRequest{
|
||||||
|
SelectionText: existing.SelectionText,
|
||||||
|
Color: "#90caf9",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("identical content and locators skip", func(t *testing.T) {
|
||||||
|
req := base
|
||||||
|
req.EpubcfiStart = existing.EpubcfiStart.String
|
||||||
|
req.EpubcfiEnd = existing.EpubcfiEnd.String
|
||||||
|
_, changed := svc.compareIncoming(req, existing)
|
||||||
|
if changed {
|
||||||
|
t.Error("identical echo must not rewrite the row")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("better start locator refreshes despite identical content", func(t *testing.T) {
|
||||||
|
req := base
|
||||||
|
req.EpubcfiStart = "epubcfi(/6/12!/4/2[x]/2/3:0)"
|
||||||
|
req.EpubcfiEnd = existing.EpubcfiEnd.String
|
||||||
|
_, changed := svc.compareIncoming(req, existing)
|
||||||
|
if !changed {
|
||||||
|
t.Error("locator drift on a content-equal echo must trigger an update")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty incoming locators leave the row alone", func(t *testing.T) {
|
||||||
|
req := base // no CFIs: applyLWW coalesces empty to stored
|
||||||
|
_, changed := svc.compareIncoming(req, existing)
|
||||||
|
if changed {
|
||||||
|
t.Error("empty locators coalesce; must not count as drift")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmarks share the echo-refresh rule for their locators.
|
||||||
|
func TestCompareIncomingBookmarkLocatorDrift(t *testing.T) {
|
||||||
|
svc := &AnnotationService{}
|
||||||
|
existing := database.MediaBookmarks{
|
||||||
|
Title: "in CHAPTER IV.",
|
||||||
|
CfiPosition: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/2/3:0)", Valid: true},
|
||||||
|
Position: pgtype.Text{String: "/body/DocFragment[6]/body/div[1]/h2[1]/text().0", Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("same locators skip", func(t *testing.T) {
|
||||||
|
req := SaveBookmarkRequest{
|
||||||
|
Title: existing.Title,
|
||||||
|
CFIPosition: existing.CfiPosition.String,
|
||||||
|
Position: existing.Position.String,
|
||||||
|
}
|
||||||
|
if _, changed := svc.compareIncomingBookmark(req, existing); changed {
|
||||||
|
t.Error("identical bookmark echo must not rewrite the row")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("new CFI refreshes despite same title", func(t *testing.T) {
|
||||||
|
req := SaveBookmarkRequest{
|
||||||
|
Title: existing.Title,
|
||||||
|
CFIPosition: "epubcfi(/6/12!/4/2[x]/2/3:5)",
|
||||||
|
Position: existing.Position.String,
|
||||||
|
}
|
||||||
|
if _, changed := svc.compareIncomingBookmark(req, existing); !changed {
|
||||||
|
t.Error("bookmark locator drift must trigger an update")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+151
-22
@@ -12,6 +12,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
@@ -246,6 +247,11 @@ func parseElementPart(part string) (string, int) {
|
|||||||
|
|
||||||
type ConversionResult struct {
|
type ConversionResult struct {
|
||||||
EPUBCFI string
|
EPUBCFI string
|
||||||
|
// EndEPUBCFI is set when the conversion matched a context that is a
|
||||||
|
// quote of the document (text search): the range end anchor of the
|
||||||
|
// quoted text, valid across block boundaries. Selections use it as
|
||||||
|
// the highlight end; point positions ignore it.
|
||||||
|
EndEPUBCFI string
|
||||||
Href string
|
Href string
|
||||||
Percentage float64
|
Percentage float64
|
||||||
Precision string
|
Precision string
|
||||||
@@ -510,25 +516,24 @@ func (c *CFIConverter) convertByStructuralPath(body *html.Node, xp *CREXPointer,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text is the final verification: when the device sent usable words and
|
// Text is the final verification, in quote form: a usable context must
|
||||||
// they disagree with this structural landing, reject it and let text
|
// be a prefix of the document as read forward from this landing point.
|
||||||
// search / percentage decide rather than storing a confident-but-wrong CFI.
|
// Device captures are exactly that shape — a position's context is the
|
||||||
|
// text from the position onward (the plugin's walk-up may concatenate
|
||||||
|
// the heading with the paragraphs below), and a selection is the
|
||||||
|
// document text between its two anchors. The single-block containment
|
||||||
|
// checks stay as secondary acceptance for reverse shapes.
|
||||||
if usable, normalized := usableContextText(contextText); usable {
|
if usable, normalized := usableContextText(contextText); usable {
|
||||||
|
doc := documentTextFrom(body, textNode, localOffset, utf8.RuneCountInString(normalized)+64)
|
||||||
flat := blockFlattenedText(textNode)
|
flat := blockFlattenedText(textNode)
|
||||||
if flat != "" && !strings.Contains(flat, normalized) && !strings.Contains(normalized, flat) {
|
if !(strings.HasPrefix(doc, normalized) ||
|
||||||
// Compare a prefix too: device sends ~100 chars from the reader
|
strings.Contains(flat, normalized) ||
|
||||||
// position while the block may be longer.
|
strings.Contains(normalized, flat)) {
|
||||||
prefix := normalized
|
log.Printf("Bookhoard: structural landing disagrees with context in %s (reads %q vs ctx %q)",
|
||||||
if utf8.RuneCountInString(prefix) > 40 {
|
href, truncateForLog(doc, 80), truncateForLog(normalized, 80))
|
||||||
runes := []rune(prefix)
|
|
||||||
prefix = string(runes[:40])
|
|
||||||
}
|
|
||||||
if !strings.Contains(flat, prefix) {
|
|
||||||
log.Printf("Bookhoard: structural landing disagrees with context in %s (block %q vs ctx %q)", href, truncateForLog(flat, 80), truncateForLog(normalized, 80))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
spineIndex := xp.FragmentIndex - 1
|
spineIndex := xp.FragmentIndex - 1
|
||||||
cfi, err := buildCFI(spineIndex, textNode, localOffset)
|
cfi, err := buildCFI(spineIndex, textNode, localOffset)
|
||||||
@@ -592,28 +597,149 @@ func (c *CFIConverter) convertFragmentID(s string, storedPercentage float64) (*C
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// docRunePos records which text node (and rune offset within it) produced
|
||||||
|
// each rune of the flattened document text.
|
||||||
|
type docRunePos struct {
|
||||||
|
node *html.Node
|
||||||
|
off int
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenDocumentForSearch renders the document's text as a
|
||||||
|
// whitespace-normalized rune stream in document order, crossing block
|
||||||
|
// boundaries: a single space separates blocks, while inline spans join
|
||||||
|
// directly (drop-cap splits like <span>C</span>onvergence read as one
|
||||||
|
// word). Every emitted rune carries its source (node, rune offset) so
|
||||||
|
// matches map back to CFIs.
|
||||||
|
func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
|
||||||
|
var runes []rune
|
||||||
|
var poss []docRunePos
|
||||||
|
pendingSpace := false
|
||||||
|
var lastBlock *html.Node
|
||||||
|
|
||||||
|
appendNode := func(n *html.Node) {
|
||||||
|
block := findBlockParent(n)
|
||||||
|
if lastBlock != nil && block != lastBlock {
|
||||||
|
pendingSpace = true
|
||||||
|
}
|
||||||
|
lastBlock = block
|
||||||
|
off := 0
|
||||||
|
for _, r := range n.Data {
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
pendingSpace = true
|
||||||
|
} else {
|
||||||
|
if pendingSpace && len(runes) > 0 {
|
||||||
|
runes = append(runes, ' ')
|
||||||
|
poss = append(poss, docRunePos{node: n, off: off})
|
||||||
|
}
|
||||||
|
pendingSpace = false
|
||||||
|
runes = append(runes, r)
|
||||||
|
poss = append(poss, docRunePos{node: n, off: off})
|
||||||
|
}
|
||||||
|
off++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var walk func(n *html.Node)
|
||||||
|
walk = func(n *html.Node) {
|
||||||
|
if n.Type == html.TextNode {
|
||||||
|
if strings.TrimSpace(n.Data) != "" {
|
||||||
|
appendNode(n)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
walk(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(body)
|
||||||
|
return runes, poss
|
||||||
|
}
|
||||||
|
|
||||||
|
// documentTextFrom reads the whitespace-normalized document text starting
|
||||||
|
// at rune `offset` in `start` (typically a structural landing point),
|
||||||
|
// crossing block boundaries — the document "as read" from that position.
|
||||||
|
// Capped at maxRunes.
|
||||||
|
func documentTextFrom(body *html.Node, start *html.Node, offset, maxRunes int) string {
|
||||||
|
runes, poss := flattenDocumentForSearch(body)
|
||||||
|
begin := -1
|
||||||
|
for i := range poss {
|
||||||
|
if poss[i].node == start && poss[i].off >= offset {
|
||||||
|
begin = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if begin < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// The separator space before a block's first content rune shares its
|
||||||
|
// (node, offset) — skip it so the read starts on real text.
|
||||||
|
if runes[begin] == ' ' && begin+1 < len(runes) {
|
||||||
|
begin++
|
||||||
|
}
|
||||||
|
end := len(runes)
|
||||||
|
if begin+maxRunes < end {
|
||||||
|
end = begin + maxRunes
|
||||||
|
}
|
||||||
|
return string(runes[begin:end])
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CFIConverter) convertByTextSearch(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult {
|
func (c *CFIConverter) convertByTextSearch(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult {
|
||||||
normalizedCtx := normalizeWhitespace(contextText)
|
normalizedCtx := normalizeWhitespace(contextText)
|
||||||
if normalizedCtx == "" {
|
if normalizedCtx == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
match, matchOffset := findTextInNode(body, normalizedCtx)
|
// Match against the whole document flattened in reading order — the
|
||||||
if match == nil {
|
// context may span block boundaries (a selection covering several
|
||||||
log.Printf("Bookhoard: text search no match for %q in %s", normalizedCtx, href)
|
// paragraphs, a walk-up capture joining a heading with what follows).
|
||||||
|
runes, poss := flattenDocumentForSearch(body)
|
||||||
|
text := string(runes)
|
||||||
|
|
||||||
|
words := strings.Fields(normalizedCtx)
|
||||||
|
quoted := make([]string, len(words))
|
||||||
|
for i, w := range words {
|
||||||
|
quoted[i] = regexp.QuoteMeta(w)
|
||||||
|
}
|
||||||
|
pattern := strings.Join(quoted, `\s+`)
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
loc := re.FindStringIndex(text)
|
||||||
|
if loc == nil {
|
||||||
|
log.Printf("Bookhoard: text search no match for %q in %s", truncateForLog(normalizedCtx, 60), href)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startRune := utf8.RuneCountInString(text[:loc[0]])
|
||||||
|
endRune := utf8.RuneCountInString(text[:loc[1]]) // exclusive
|
||||||
spineIndex := xp.FragmentIndex - 1
|
spineIndex := xp.FragmentIndex - 1
|
||||||
cfi, err := buildCFI(spineIndex, match, matchOffset)
|
|
||||||
if err != nil || cfi == "" {
|
startPos := poss[startRune]
|
||||||
|
startCFI, err := buildCFI(spineIndex, startPos.node, startPos.off)
|
||||||
|
if err != nil || startCFI == "" {
|
||||||
log.Printf("Bookhoard: text search found match but buildCFI failed: %v", err)
|
log.Printf("Bookhoard: text search found match but buildCFI failed: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", normalizedCtx, cfi)
|
// The matched context is a quote of the document: its extent gives
|
||||||
|
// selections a true range end, across block boundaries.
|
||||||
|
endCFI := ""
|
||||||
|
if endRune > startRune && endRune <= len(poss) {
|
||||||
|
endPos := poss[endRune-1]
|
||||||
|
endOff := endPos.off + 1
|
||||||
|
if nRunes := utf8.RuneCountInString(endPos.node.Data); endOff > nRunes {
|
||||||
|
endOff = nRunes
|
||||||
|
}
|
||||||
|
if ec, err := buildCFI(spineIndex, endPos.node, endOff); err == nil && ec != "" {
|
||||||
|
endCFI = ec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", truncateForLog(normalizedCtx, 60), startCFI)
|
||||||
return &ConversionResult{
|
return &ConversionResult{
|
||||||
EPUBCFI: cfi,
|
EPUBCFI: startCFI,
|
||||||
|
EndEPUBCFI: endCFI,
|
||||||
Href: href,
|
Href: href,
|
||||||
Percentage: storedPercentage,
|
Percentage: storedPercentage,
|
||||||
Precision: "exact",
|
Precision: "exact",
|
||||||
@@ -780,7 +906,10 @@ func (c *CFIConverter) convertByPercentageOffset(body *html.Node, xp *CREXPointe
|
|||||||
EPUBCFI: cfi,
|
EPUBCFI: cfi,
|
||||||
Href: href,
|
Href: href,
|
||||||
Percentage: storedPercentage,
|
Percentage: storedPercentage,
|
||||||
Precision: "exact",
|
// This is a char-count estimate, not an exact landing: say
|
||||||
|
// so. Callers gate storage on precision, and a guess must
|
||||||
|
// never masquerade as an exact anchor.
|
||||||
|
Precision: "percentage",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -667,6 +667,96 @@ func TestReverseIgnoresSingleCharContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A position at a chapter heading sends walk-up context: the heading text
|
||||||
|
// concatenated with the paragraphs below (block walk-up in the plugin).
|
||||||
|
// The structural landing at the heading is correct and must verify — the
|
||||||
|
// context is a prefix of the document as read from the landing.
|
||||||
|
func TestWalkUpContextVerifiesAtHeading(t *testing.T) {
|
||||||
|
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||||
|
|
||||||
|
// ch10: h1[1] "Chapter 10", h1[2] "The Three C's of the New Covenant",
|
||||||
|
// then paragraphs. Position at h1[2]'s text start; the device captured
|
||||||
|
// the heading plus the following paragraph.
|
||||||
|
xp := "/body/DocFragment[2]/body/h1[2]/text().0"
|
||||||
|
ctx := "The Three C's of the New Covenant The Cleansing Life of Christ"
|
||||||
|
|
||||||
|
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("walk-up heading → %s (%s)", result.EPUBCFI, result.Precision)
|
||||||
|
if result.Precision != "structural" {
|
||||||
|
t.Fatalf("expected structural precision (quote verification), got %s (%s)", result.Precision, result.EPUBCFI)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") {
|
||||||
|
t.Errorf("collapsed to doc start: %s", result.EPUBCFI)
|
||||||
|
}
|
||||||
|
// The landing must be the heading, not a paragraph below it.
|
||||||
|
reverse, rerr := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
|
||||||
|
if rerr != nil {
|
||||||
|
t.Fatalf("reverse conversion error: %v", rerr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reverse.XPointer, "h1[2]") {
|
||||||
|
t.Errorf("expected landing in h1[2], got %s", reverse.XPointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A selection spanning blocks (heading tail into the next paragraph) must
|
||||||
|
// be findable by text search across block boundaries, and the matched
|
||||||
|
// quote's extent gives a true range end.
|
||||||
|
func TestCrossBlockSearchSpansBlocks(t *testing.T) {
|
||||||
|
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||||
|
|
||||||
|
// No element path → structural rung skipped, text search runs.
|
||||||
|
xp := "/body/DocFragment[2]/body"
|
||||||
|
ctx := "New Covenant The Cleansing Life of Christ"
|
||||||
|
|
||||||
|
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("cross-block search → %s … %s (%s)", result.EPUBCFI, result.EndEPUBCFI, result.Precision)
|
||||||
|
if result.Precision != "exact" {
|
||||||
|
t.Fatalf("expected exact text-search precision, got %s", result.Precision)
|
||||||
|
}
|
||||||
|
if result.EPUBCFI == "" || result.EndEPUBCFI == "" {
|
||||||
|
t.Fatalf("expected range anchors, got %q…%q", result.EPUBCFI, result.EndEPUBCFI)
|
||||||
|
}
|
||||||
|
if result.EPUBCFI == result.EndEPUBCFI {
|
||||||
|
t.Fatalf("range collapsed: %s", result.EPUBCFI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The start lands in the heading, the end in the following paragraph.
|
||||||
|
startRev, err1 := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
|
||||||
|
endRev, err2 := c.ConvertStandardToCRE(result.EndEPUBCFI, result.Percentage, "")
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
t.Fatalf("reverse conversions failed: %v %v", err1, err2)
|
||||||
|
}
|
||||||
|
if !strings.Contains(startRev.XPointer, "h1[2]") {
|
||||||
|
t.Errorf("expected start in h1[2], got %s", startRev.XPointer)
|
||||||
|
}
|
||||||
|
if !strings.Contains(endRev.XPointer, "p[1]") {
|
||||||
|
t.Errorf("expected end in p[1] (following paragraph), got %s", endRev.XPointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The percentage rung is a char-count estimate: it must never label its
|
||||||
|
// landing "exact". Feed it an unmatchable context so the ladder falls all
|
||||||
|
// the way through.
|
||||||
|
func TestPercentageFallbackIsHonestlyLabeled(t *testing.T) {
|
||||||
|
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||||
|
|
||||||
|
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
|
||||||
|
result, err := c.ConvertCREToStandard(xp, 0.52, "zzz qqq vvv uuu www")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("unmatchable context → %s (%s)", result.EPUBCFI, result.Precision)
|
||||||
|
if result.Precision != "percentage" {
|
||||||
|
t.Errorf("percentage rung must not claim exact, got %s", result.Precision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The bookmark route supplies no context (bookmark text is a display
|
// The bookmark route supplies no context (bookmark text is a display
|
||||||
// label, never book text), so the facade must still resolve the drop-cap
|
// label, never book text), so the facade must still resolve the drop-cap
|
||||||
// xpointer structurally instead of collapsing to the document start.
|
// xpointer structurally instead of collapsing to the document start.
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ const (
|
|||||||
|
|
||||||
type CanonicalLocator struct {
|
type CanonicalLocator struct {
|
||||||
CFI string
|
CFI string
|
||||||
|
// EndCFI carries the matched context's range end (text-search
|
||||||
|
// conversions of selections) so callers can anchor a true highlight
|
||||||
|
// range across block boundaries.
|
||||||
|
EndCFI string
|
||||||
Precision string
|
Precision string
|
||||||
Percentage float64
|
Percentage float64
|
||||||
}
|
}
|
||||||
@@ -94,7 +98,7 @@ func ConvertToCanonical(
|
|||||||
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
||||||
}
|
}
|
||||||
if result.EPUBCFI != "" {
|
if result.EPUBCFI != "" {
|
||||||
return CanonicalLocator{CFI: result.EPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
|
return CanonicalLocator{CFI: result.EPUBCFI, EndCFI: result.EndEPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
|
||||||
}
|
}
|
||||||
if result.Href != "" {
|
if result.Href != "" {
|
||||||
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
|
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Per-field user-override tracking for media item metadata.
|
||||||
|
//
|
||||||
|
// metadata_overrides is a TEXT[] column on media_items holding the names of
|
||||||
|
// columns the user has customized via the metadata editor. Library scans and
|
||||||
|
// per-book rescans MUST preserve those columns (applyMetadataOverrides);
|
||||||
|
// only the reset-to-scanned-defaults action clears the set.
|
||||||
|
|
||||||
|
const (
|
||||||
|
OverrideTitle = "title"
|
||||||
|
OverrideAuthor = "author"
|
||||||
|
OverrideISBN = "isbn"
|
||||||
|
OverrideDescription = "description"
|
||||||
|
OverrideCoverImagePath = "cover_image_path"
|
||||||
|
OverrideSeries = "series"
|
||||||
|
OverrideSeriesNumber = "series_number"
|
||||||
|
OverrideTags = "tags"
|
||||||
|
OverrideAsin = "asin"
|
||||||
|
OverrideDatePublished = "date_published"
|
||||||
|
OverridePublisher = "publisher"
|
||||||
|
OverrideContributors = "contributors"
|
||||||
|
OverrideLanguage = "language"
|
||||||
|
OverrideEdition = "edition"
|
||||||
|
OverridePageCount = "page_count"
|
||||||
|
OverrideGenre = "genre"
|
||||||
|
OverrideCopyrightYear = "copyright_year"
|
||||||
|
OverrideGoodreadsID = "goodreads_id"
|
||||||
|
OverrideOpenlibraryID = "openlibrary_id"
|
||||||
|
OverrideGoogleBooksID = "google_books_id"
|
||||||
|
OverrideMangaType = "manga_type"
|
||||||
|
OverrideReadingDirection = "reading_direction"
|
||||||
|
OverrideSeriesCount = "series_count"
|
||||||
|
OverrideVolume = "volume"
|
||||||
|
OverrideImprint = "imprint"
|
||||||
|
OverrideAgeRating = "age_rating"
|
||||||
|
OverrideWebURL = "web_url"
|
||||||
|
OverrideMetadataNotes = "metadata_notes"
|
||||||
|
OverrideCommunityRating = "community_rating"
|
||||||
|
OverrideStoryArc = "story_arc"
|
||||||
|
OverrideIsBlackAndWhite = "is_black_and_white"
|
||||||
|
OverrideAlternateInfo = "alternate_info"
|
||||||
|
OverrideScanInformation = "scan_information"
|
||||||
|
OverrideSummary = "summary"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hasOverride reports whether key is in the override set.
|
||||||
|
func hasOverride(overrides []string, key string) bool {
|
||||||
|
for _, k := range overrides {
|
||||||
|
if k == key {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeOverrides unions existing and added, preserving order and dropping
|
||||||
|
// duplicates. Returns a non-nil slice so it always satisfies NOT NULL columns.
|
||||||
|
func MergeOverrides(existing []string, added ...string) []string {
|
||||||
|
seen := make(map[string]bool, len(existing)+len(added))
|
||||||
|
merged := make([]string, 0, len(existing)+len(added))
|
||||||
|
for _, k := range existing {
|
||||||
|
if k != "" && !seen[k] {
|
||||||
|
seen[k] = true
|
||||||
|
merged = append(merged, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range added {
|
||||||
|
if k != "" && !seen[k] {
|
||||||
|
seen[k] = true
|
||||||
|
merged = append(merged, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalizers turn nullable column values into comparable strings so that
|
||||||
|
// "unset" (invalid/zero) forms compare equal regardless of which side they
|
||||||
|
// come from.
|
||||||
|
|
||||||
|
func normText(t pgtype.Text) string {
|
||||||
|
if !t.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.String
|
||||||
|
}
|
||||||
|
|
||||||
|
func normInt(i pgtype.Int4) string {
|
||||||
|
if !i.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(int64(i.Int32), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normFloat(f pgtype.Float8) string {
|
||||||
|
if !f.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(f.Float64, 'g', -1, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normBool(b pgtype.Bool) string {
|
||||||
|
if !b.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatBool(b.Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normDate(d pgtype.Date) string {
|
||||||
|
if !d.Valid {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d.Time.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normStringSlice(s []string) string {
|
||||||
|
if len(s) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(s, "\x1f")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normBytes(b []byte) string {
|
||||||
|
if len(b) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectMetadataOverridesDiff returns the keys whose value in params differs
|
||||||
|
// from the existing row. Used by the metadata editor save path to grow the
|
||||||
|
// override set with exactly the fields the user changed.
|
||||||
|
func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
|
||||||
|
var changed []string
|
||||||
|
add := func(key string, differs bool) {
|
||||||
|
if differs {
|
||||||
|
changed = append(changed, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(OverrideTitle, params.Title != existing.Title)
|
||||||
|
add(OverrideAuthor, normText(params.Author) != normText(existing.Author))
|
||||||
|
add(OverrideISBN, normText(params.Isbn) != normText(existing.Isbn))
|
||||||
|
add(OverrideDescription, normText(params.Description) != normText(existing.Description))
|
||||||
|
add(OverrideSeries, normText(params.Series) != normText(existing.Series))
|
||||||
|
add(OverrideSeriesNumber, normInt(params.SeriesNumber) != normInt(existing.SeriesNumber))
|
||||||
|
add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
|
||||||
|
add(OverrideAsin, normText(params.Asin) != normText(existing.Asin))
|
||||||
|
add(OverrideDatePublished, normDate(params.DatePublished) != normDate(existing.DatePublished))
|
||||||
|
add(OverridePublisher, normText(params.Publisher) != normText(existing.Publisher))
|
||||||
|
add(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors))
|
||||||
|
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
|
||||||
|
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
|
||||||
|
add(OverridePageCount, normInt(params.PageCount) != normInt(existing.PageCount))
|
||||||
|
add(OverrideGenre, normText(params.Genre) != normText(existing.Genre))
|
||||||
|
add(OverrideCopyrightYear, normInt(params.CopyrightYear) != normInt(existing.CopyrightYear))
|
||||||
|
add(OverrideGoodreadsID, normText(params.GoodreadsID) != normText(existing.GoodreadsID))
|
||||||
|
add(OverrideOpenlibraryID, normText(params.OpenlibraryID) != normText(existing.OpenlibraryID))
|
||||||
|
add(OverrideGoogleBooksID, normText(params.GoogleBooksID) != normText(existing.GoogleBooksID))
|
||||||
|
add(OverrideMangaType, normText(params.MangaType) != normText(existing.MangaType))
|
||||||
|
add(OverrideReadingDirection, normText(params.ReadingDirection) != normText(existing.ReadingDirection))
|
||||||
|
add(OverrideSeriesCount, normInt(params.SeriesCount) != normInt(existing.SeriesCount))
|
||||||
|
add(OverrideVolume, normInt(params.Volume) != normInt(existing.Volume))
|
||||||
|
add(OverrideImprint, normText(params.Imprint) != normText(existing.Imprint))
|
||||||
|
add(OverrideAgeRating, normText(params.AgeRating) != normText(existing.AgeRating))
|
||||||
|
add(OverrideWebURL, normText(params.WebUrl) != normText(existing.WebUrl))
|
||||||
|
add(OverrideMetadataNotes, normText(params.MetadataNotes) != normText(existing.MetadataNotes))
|
||||||
|
add(OverrideCommunityRating, normFloat(params.CommunityRating) != normFloat(existing.CommunityRating))
|
||||||
|
add(OverrideStoryArc, normText(params.StoryArc) != normText(existing.StoryArc))
|
||||||
|
add(OverrideIsBlackAndWhite, normBool(params.IsBlackAndWhite) != normBool(existing.IsBlackAndWhite))
|
||||||
|
add(OverrideAlternateInfo, normBytes(params.AlternateInfo) != normBytes(existing.AlternateInfo))
|
||||||
|
add(OverrideScanInformation, normText(params.ScanInformation) != normText(existing.ScanInformation))
|
||||||
|
add(OverrideSummary, normText(params.Summary) != normText(existing.Summary))
|
||||||
|
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// DetectMetadataOverrides returns the union of the existing override set and
|
||||||
|
// any fields whose incoming (user-submitted) values differ from the stored
|
||||||
|
// row. Overrides accumulate: a field stays protected until an explicit reset,
|
||||||
|
// even if a later save reverts the value.
|
||||||
|
func DetectMetadataOverrides(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
|
||||||
|
return MergeOverrides(existing.MetadataOverrides, detectMetadataOverridesDiff(params, existing)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyMetadataOverrides restores every overridden field in params from the
|
||||||
|
// existing row so scanner updates cannot clobber user customizations. Derived
|
||||||
|
// search columns are restored together with their base column.
|
||||||
|
func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing database.MediaItems) {
|
||||||
|
o := existing.MetadataOverrides
|
||||||
|
|
||||||
|
if hasOverride(o, OverrideTitle) {
|
||||||
|
params.Title = existing.Title
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideAuthor) {
|
||||||
|
params.Author = existing.Author
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideISBN) {
|
||||||
|
params.Isbn = existing.Isbn
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideDescription) {
|
||||||
|
params.Description = existing.Description
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideCoverImagePath) {
|
||||||
|
params.CoverImagePath = existing.CoverImagePath
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideSeries) {
|
||||||
|
params.Series = existing.Series
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideSeriesNumber) {
|
||||||
|
params.SeriesNumber = existing.SeriesNumber
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideTags) {
|
||||||
|
params.Tags = existing.Tags
|
||||||
|
params.TagsSearch = existing.TagsSearch
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideAsin) {
|
||||||
|
params.Asin = existing.Asin
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideDatePublished) {
|
||||||
|
params.DatePublished = existing.DatePublished
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverridePublisher) {
|
||||||
|
params.Publisher = existing.Publisher
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideContributors) {
|
||||||
|
params.Contributors = existing.Contributors
|
||||||
|
params.ContributorsSearch = existing.ContributorsSearch
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideLanguage) {
|
||||||
|
params.Language = existing.Language
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideEdition) {
|
||||||
|
params.Edition = existing.Edition
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverridePageCount) {
|
||||||
|
params.PageCount = existing.PageCount
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideGenre) {
|
||||||
|
params.Genre = existing.Genre
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideCopyrightYear) {
|
||||||
|
params.CopyrightYear = existing.CopyrightYear
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideGoodreadsID) {
|
||||||
|
params.GoodreadsID = existing.GoodreadsID
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideOpenlibraryID) {
|
||||||
|
params.OpenlibraryID = existing.OpenlibraryID
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideGoogleBooksID) {
|
||||||
|
params.GoogleBooksID = existing.GoogleBooksID
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideMangaType) {
|
||||||
|
params.MangaType = existing.MangaType
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideReadingDirection) {
|
||||||
|
params.ReadingDirection = existing.ReadingDirection
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideSeriesCount) {
|
||||||
|
params.SeriesCount = existing.SeriesCount
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideVolume) {
|
||||||
|
params.Volume = existing.Volume
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideImprint) {
|
||||||
|
params.Imprint = existing.Imprint
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideAgeRating) {
|
||||||
|
params.AgeRating = existing.AgeRating
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideWebURL) {
|
||||||
|
params.WebUrl = existing.WebUrl
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideMetadataNotes) {
|
||||||
|
params.MetadataNotes = existing.MetadataNotes
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideCommunityRating) {
|
||||||
|
params.CommunityRating = existing.CommunityRating
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideStoryArc) {
|
||||||
|
params.StoryArc = existing.StoryArc
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideIsBlackAndWhite) {
|
||||||
|
params.IsBlackAndWhite = existing.IsBlackAndWhite
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideAlternateInfo) {
|
||||||
|
params.AlternateInfo = existing.AlternateInfo
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideScanInformation) {
|
||||||
|
params.ScanInformation = existing.ScanInformation
|
||||||
|
}
|
||||||
|
if hasOverride(o, OverrideSummary) {
|
||||||
|
params.Summary = existing.Summary
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMergeOverrides(t *testing.T) {
|
||||||
|
got := MergeOverrides([]string{"title", "tags"}, "tags", "description", "")
|
||||||
|
want := []string{"title", "tags", "description"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("MergeOverrides() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if MergeOverrides(nil) == nil {
|
||||||
|
t.Error("MergeOverrides(nil) must return non-nil slice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectMetadataOverrides(t *testing.T) {
|
||||||
|
existing := database.MediaItems{
|
||||||
|
Title: "Scanned Title",
|
||||||
|
Description: pgtype.Text{String: "Scanned description", Valid: true},
|
||||||
|
Tags: []string{"foo", "bar"},
|
||||||
|
}
|
||||||
|
params := database.UpdateMediaItemParams{
|
||||||
|
// unchanged
|
||||||
|
Title: "Scanned Title",
|
||||||
|
// changed
|
||||||
|
Description: pgtype.Text{String: "My custom description", Valid: true},
|
||||||
|
Tags: []string{"foo", "bar"},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := DetectMetadataOverrides(params, existing)
|
||||||
|
if !reflect.DeepEqual(got, []string{"description"}) {
|
||||||
|
t.Errorf("DetectMetadataOverrides() = %v, want [description]", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overrides accumulate: an existing override survives a later save.
|
||||||
|
existing.MetadataOverrides = []string{"publisher"}
|
||||||
|
got = DetectMetadataOverrides(params, existing)
|
||||||
|
want := []string{"publisher", "description"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("DetectMetadataOverrides() accumulate = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An untouched save detects nothing new.
|
||||||
|
noop := database.UpdateMediaItemParams{
|
||||||
|
Title: existing.Title,
|
||||||
|
Description: existing.Description,
|
||||||
|
Tags: existing.Tags,
|
||||||
|
}
|
||||||
|
got = DetectMetadataOverrides(noop, existing)
|
||||||
|
if !reflect.DeepEqual(got, []string{"publisher"}) {
|
||||||
|
t.Errorf("DetectMetadataOverrides() noop = %v, want [publisher]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectMetadataOverridesUnsetFormsEqual(t *testing.T) {
|
||||||
|
// Zero-value params must not look "changed" against NULL-ish columns.
|
||||||
|
existing := database.MediaItems{
|
||||||
|
PageCount: pgtype.Int4{Int32: 0, Valid: false},
|
||||||
|
SeriesNumber: pgtype.Int4{Int32: 5, Valid: true},
|
||||||
|
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
|
||||||
|
}
|
||||||
|
params := database.UpdateMediaItemParams{
|
||||||
|
PageCount: pgtype.Int4{Int32: 0, Valid: false},
|
||||||
|
SeriesNumber: pgtype.Int4{Int32: 0, Valid: false}, // cleared by user -> changed
|
||||||
|
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
|
||||||
|
}
|
||||||
|
got := DetectMetadataOverrides(params, existing)
|
||||||
|
if !reflect.DeepEqual(got, []string{"series_number"}) {
|
||||||
|
t.Errorf("DetectMetadataOverrides() = %v, want [series_number]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyMetadataOverrides(t *testing.T) {
|
||||||
|
existing := database.MediaItems{
|
||||||
|
Title: "Custom Title",
|
||||||
|
Description: pgtype.Text{String: "Custom desc", Valid: true},
|
||||||
|
Tags: []string{"mine"},
|
||||||
|
TagsSearch: []string{"mine"},
|
||||||
|
CoverImagePath: pgtype.Text{String: "books/x.epub.custom_cover.jpg", Valid: true},
|
||||||
|
}
|
||||||
|
params := database.UpdateMediaItemParams{
|
||||||
|
Title: "Scanned Title",
|
||||||
|
Description: pgtype.Text{String: "Scanned desc", Valid: true},
|
||||||
|
Tags: []string{"scanned"},
|
||||||
|
TagsSearch: []string{"scanned"},
|
||||||
|
CoverImagePath: pgtype.Text{String: "books/x.epub.cover.jpg", Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only title and tags overridden; scanned description and cover win.
|
||||||
|
existing.MetadataOverrides = []string{"title", "tags"}
|
||||||
|
ApplyMetadataOverrides(¶ms, existing)
|
||||||
|
|
||||||
|
if params.Title != "Custom Title" {
|
||||||
|
t.Errorf("Title = %q, want %q", params.Title, "Custom Title")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(params.Tags, []string{"mine"}) || !reflect.DeepEqual(params.TagsSearch, []string{"mine"}) {
|
||||||
|
t.Error("Tags/TagsSearch must be restored together")
|
||||||
|
}
|
||||||
|
if params.Description.String != "Scanned desc" {
|
||||||
|
t.Errorf("Description = %q, want scanned value", params.Description.String)
|
||||||
|
}
|
||||||
|
if params.CoverImagePath.String != "books/x.epub.cover.jpg" {
|
||||||
|
t.Errorf("CoverImagePath = %q, want scanned value", params.CoverImagePath.String)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -34,6 +34,23 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
if archivedCount > 0 {
|
||||||
|
<div class="card p-4 mb-6 flex items-center justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold" style="color: var(--text-primary)">
|
||||||
|
Archived items: { archivedCount }
|
||||||
|
</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary)">
|
||||||
|
Files missing from disk for two consecutive scans. Reading history is kept
|
||||||
|
unless purged; items return automatically if their files come back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary">
|
||||||
|
@Icon("trash", "h-4 w-4")
|
||||||
|
Purge Archived Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div id="libraries-container">
|
<div id="libraries-container">
|
||||||
@LibraryList(user, libraries, users)
|
@LibraryList(user, libraries, users)
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+369
-338
File diff suppressed because it is too large
Load Diff
@@ -107,10 +107,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
if user.Role == "admin" {
|
||||||
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
||||||
@Icon("edit", "h-4 w-4")
|
@Icon("edit", "h-4 w-4")
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Rating -->
|
<!-- Rating -->
|
||||||
<div class="mb-5" @mouseleave="ratingHover = 0">
|
<div class="mb-5" @mouseleave="ratingHover = 0">
|
||||||
@@ -376,6 +378,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
if user.Role == "admin" {
|
||||||
|
<div class="md:col-span-2 lg:col-span-3">
|
||||||
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Location</p>
|
||||||
|
<p class="font-mono text-xs break-all select-all" style="color: var(--text-secondary)">{ book.FileLocation }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
||||||
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||||
@@ -426,7 +434,9 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
</div>
|
</div>
|
||||||
@ProgressSyncModal(user, book)
|
@ProgressSyncModal(user, book)
|
||||||
@NotesHighlightsModal(user, book)
|
@NotesHighlightsModal(user, book)
|
||||||
|
if user.Role == "admin" {
|
||||||
@MetadataEditorModal(book)
|
@MetadataEditorModal(book)
|
||||||
|
}
|
||||||
@ErrorToast(errorMessage)
|
@ErrorToast(errorMessage)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -290,25 +290,6 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<input type="file" id="cover-upload-input" accept="image/jpeg,image/png,image/webp" class="hidden"
|
<input type="file" id="cover-upload-input" accept="image/jpeg,image/png,image/webp" class="hidden"
|
||||||
@change="handleCoverUpload($event)" />
|
@change="handleCoverUpload($event)" />
|
||||||
<div class="w-64 space-y-2">
|
<div class="w-64 space-y-2">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary w-full"
|
|
||||||
@click="generateCover()"
|
|
||||||
x-show="coverGenerating"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
@Icon("refresh", "h-4 w-4")
|
|
||||||
Generating...
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary w-full"
|
|
||||||
@click="generateCover()"
|
|
||||||
x-show="!coverGenerating"
|
|
||||||
>
|
|
||||||
@Icon("refresh", "h-4 w-4")
|
|
||||||
Generate Cover
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost w-full"
|
class="btn btn-ghost w-full"
|
||||||
@@ -605,11 +586,42 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
class="input opacity-60" />
|
class="input opacity-60" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Path</label>
|
||||||
|
<input type="text" value={ book.FileLocation } readonly
|
||||||
|
class="input opacity-60 font-mono text-xs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="rescanBook()"
|
||||||
|
:disabled="rescanning"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
>
|
||||||
|
<span x-show="!rescanning" class="inline-flex items-center gap-2">@Icon("sync", "h-4 w-4")<span>Rescan</span></span>
|
||||||
|
<svg x-show="rescanning" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="resetMetadata()"
|
||||||
|
:disabled="resetting"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
title="Discard all manual edits and restore scanned metadata"
|
||||||
|
>
|
||||||
|
<span x-show="!resetting" class="inline-flex items-center gap-2">@Icon("refresh", "h-4 w-4")<span>Reset to Scanned</span></span>
|
||||||
|
<svg x-show="resetting" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
<button
|
<button
|
||||||
@click="hideMetadataEditor()"
|
@click="hideMetadataEditor()"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
@@ -626,4 +638,5 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -689,23 +689,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"absolute inset-0 bg-black bg-opacity-40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center\"><span class=\"text-white text-sm font-semibold\">Click to upload</span></div></div><input type=\"file\" id=\"cover-upload-input\" accept=\"image/jpeg,image/png,image/webp\" class=\"hidden\" @change=\"handleCoverUpload($event)\"><div class=\"w-64 space-y-2\"><button type=\"button\" class=\"btn btn-primary w-full\" @click=\"generateCover()\" x-show=\"coverGenerating\" disabled>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "<div class=\"absolute inset-0 bg-black bg-opacity-40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center\"><span class=\"text-white text-sm font-semibold\">Click to upload</span></div></div><input type=\"file\" id=\"cover-upload-input\" accept=\"image/jpeg,image/png,image/webp\" class=\"hidden\" @change=\"handleCoverUpload($event)\"><div class=\"w-64 space-y-2\"><button type=\"button\" class=\"btn btn-ghost w-full\" @click=\"removeCover()\" x-show=\"hasExistingCover || newCoverPreview\">Remove Cover</button></div></div><div class=\"flex-1 min-w-0 space-y-2\"><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('basic')\"><span class=\"font-semibold\">Basic Info</span> <span class=\"inline-flex\"><span x-show=\"openSections.basic\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "Generating...</button> <button type=\"button\" class=\"btn btn-primary w-full\" @click=\"generateCover()\" x-show=\"!coverGenerating\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
|
||||||
if templ_7745c5c3_Err != nil {
|
|
||||||
return templ_7745c5c3_Err
|
|
||||||
}
|
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Generate Cover</button> <button type=\"button\" class=\"btn btn-ghost w-full\" @click=\"removeCover()\" x-show=\"hasExistingCover || newCoverPreview\">Remove Cover</button></div></div><div class=\"flex-1 min-w-0 space-y-2\"><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('basic')\"><span class=\"font-semibold\">Basic Info</span> <span class=\"inline-flex\"><span x-show=\"openSections.basic\">")
|
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -713,7 +697,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "</span> <span x-show=\"!openSections.basic\" x-cloak>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</span> <span x-show=\"!openSections.basic\" x-cloak>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -721,59 +705,59 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</span></span></button><div x-show=\"openSections.basic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Title</label> <input type=\"text\" name=\"title\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "</span></span></button><div x-show=\"openSections.basic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Title</label> <input type=\"text\" name=\"title\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var34 string
|
var templ_7745c5c3_Var34 string
|
||||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 336, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 317, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Author</label> <input type=\"text\" name=\"author\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Author</label> <input type=\"text\" name=\"author\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var35 string
|
var templ_7745c5c3_Var35 string
|
||||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Author))
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Author))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 340, Col: 74}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 321, Col: 74}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Description</label> <textarea name=\"description\" rows=\"3\" class=\"input\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Description</label> <textarea name=\"description\" rows=\"3\" class=\"input\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var36 string
|
var templ_7745c5c3_Var36 string
|
||||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Description))
|
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Description))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 345, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 326, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Summary</label> <textarea name=\"summary\" rows=\"2\" class=\"input\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Summary</label> <textarea name=\"summary\" rows=\"2\" class=\"input\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var37 string
|
var templ_7745c5c3_Var37 string
|
||||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Summary))
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.Summary))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 350, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 331, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Tags</label><div class=\"flex flex-wrap gap-1.5 mb-2\"><template x-for=\"(tag, idx) in editorTags\" :key=\"idx\"><span class=\"chip\"><span x-text=\"tag\"></span> <button type=\"button\" class=\"hover:bg-surface-hover rounded leading-none\" @click=\"removeEditorTag(idx)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "</textarea></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Tags</label><div class=\"flex flex-wrap gap-1.5 mb-2\"><template x-for=\"(tag, idx) in editorTags\" :key=\"idx\"><span class=\"chip\"><span x-text=\"tag\"></span> <button type=\"button\" class=\"hover:bg-surface-hover rounded leading-none\" @click=\"removeEditorTag(idx)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -781,43 +765,43 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</button></span></template></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "</button></span></template></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
for _, tag := range book.Tags {
|
for _, tag := range book.Tags {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span data-editor-tag=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "<span data-editor-tag=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var38 string
|
var templ_7745c5c3_Var38 string
|
||||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(tag)
|
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(tag)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 363, Col: 36}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 344, Col: 36}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\" class=\"hidden\"></span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\" class=\"hidden\"></span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<div><input type=\"text\" x-model=\"tagSearch\" @input.debounce.300ms=\"searchEditorTags()\" @keydown=\"onTagKeydown($event)\" @blur=\"hideEditorTagDropdown()\" placeholder=\"Add tag...\" class=\"input\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"mt-1 w-full rounded-lg border max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-secondary); border-color: var(--border); box-shadow: var(--shadow-card);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center hover:bg-surface-hover\" :class=\"highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''\" @click=\"selectEditorTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Community Rating (0-10)</label> <input type=\"number\" name=\"community_rating\" min=\"0\" max=\"10\" step=\"0.1\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<div><input type=\"text\" x-model=\"tagSearch\" @input.debounce.300ms=\"searchEditorTags()\" @keydown=\"onTagKeydown($event)\" @blur=\"hideEditorTagDropdown()\" placeholder=\"Add tag...\" class=\"input\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"mt-1 w-full rounded-lg border max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-secondary); border-color: var(--border); box-shadow: var(--shadow-card);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center hover:bg-surface-hover\" :class=\"highlightedTagIndex === tagSuggestions.indexOf(sug) ? 'bg-surface-hover' : ''\" @click=\"selectEditorTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Community Rating (0-10)</label> <input type=\"number\" name=\"community_rating\" min=\"0\" max=\"10\" step=\"0.1\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var39 string
|
var templ_7745c5c3_Var39 string
|
||||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f", book.CommunityRating.Float64))
|
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f", book.CommunityRating.Float64))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 397, Col: 66}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 378, Col: 66}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('publication')\"><span class=\"font-semibold\">Publication</span> <span class=\"inline-flex\"><span x-show=\"openSections.publication\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('publication')\"><span class=\"font-semibold\">Publication</span> <span class=\"inline-flex\"><span x-show=\"openSections.publication\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -825,7 +809,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span> <span x-show=\"!openSections.publication\" x-cloak>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span> <span x-show=\"!openSections.publication\" x-cloak>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -833,85 +817,85 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span></span></button><div x-show=\"openSections.publication\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Publisher</label> <input type=\"text\" name=\"publisher\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</span></span></button><div x-show=\"openSections.publication\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Publisher</label> <input type=\"text\" name=\"publisher\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var40 string
|
var templ_7745c5c3_Var40 string
|
||||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Publisher))
|
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Publisher))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 415, Col: 80}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 396, Col: 80}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Date Published</label> <input type=\"date\" name=\"date_published\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Date Published</label> <input type=\"date\" name=\"date_published\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var41 string
|
var templ_7745c5c3_Var41 string
|
||||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatDateForInput(book.DatePublished))
|
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatDateForInput(book.DatePublished))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 420, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 401, Col: 55}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Edition</label> <input type=\"text\" name=\"edition\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Edition</label> <input type=\"text\" name=\"edition\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var42 string
|
var templ_7745c5c3_Var42 string
|
||||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Edition))
|
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Edition))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 425, Col: 76}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 406, Col: 76}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Language</label> <input type=\"text\" name=\"language\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Language</label> <input type=\"text\" name=\"language\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var43 string
|
var templ_7745c5c3_Var43 string
|
||||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Language))
|
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Language))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 429, Col: 78}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 410, Col: 78}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Genre</label> <input type=\"text\" name=\"genre\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Genre</label> <input type=\"text\" name=\"genre\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var44 string
|
var templ_7745c5c3_Var44 string
|
||||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Genre))
|
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Genre))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 433, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 414, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Copyright Year</label> <input type=\"number\" name=\"copyright_year\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Copyright Year</label> <input type=\"number\" name=\"copyright_year\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var45 string
|
var templ_7745c5c3_Var45 string
|
||||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.CopyrightYear.Int32))
|
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.CopyrightYear.Int32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 438, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 419, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('series')\"><span class=\"font-semibold\">Series</span> <span class=\"inline-flex\"><span x-show=\"openSections.series\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('series')\"><span class=\"font-semibold\">Series</span> <span class=\"inline-flex\"><span x-show=\"openSections.series\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -919,7 +903,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "</span> <span x-show=\"!openSections.series\" x-cloak>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</span> <span x-show=\"!openSections.series\" x-cloak>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -927,59 +911,59 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</span></span></button><div x-show=\"openSections.series\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Series</label> <input type=\"text\" name=\"series\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</span></span></button><div x-show=\"openSections.series\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Series</label> <input type=\"text\" name=\"series\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var46 string
|
var templ_7745c5c3_Var46 string
|
||||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Series))
|
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Series))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 456, Col: 74}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 437, Col: 74}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"input\"></div><div class=\"grid grid-cols-3 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Number</label> <input type=\"number\" name=\"series_number\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" class=\"input\"></div><div class=\"grid grid-cols-3 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Number</label> <input type=\"number\" name=\"series_number\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var47 string
|
var templ_7745c5c3_Var47 string
|
||||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesNumber.Int32))
|
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesNumber.Int32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 462, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 443, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Count</label> <input type=\"number\" name=\"series_count\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Count</label> <input type=\"number\" name=\"series_count\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var48 string
|
var templ_7745c5c3_Var48 string
|
||||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesCount.Int32))
|
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.SeriesCount.Int32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 468, Col: 59}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 449, Col: 59}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var48)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Volume</label> <input type=\"number\" name=\"volume\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Volume</label> <input type=\"number\" name=\"volume\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var49 string
|
var templ_7745c5c3_Var49 string
|
||||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.Volume.Int32))
|
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.Volume.Int32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 474, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 455, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var49)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" class=\"input\"></div></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('identifiers')\"><span class=\"font-semibold\">Identifiers</span> <span class=\"inline-flex\"><span x-show=\"openSections.identifiers\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "\" class=\"input\"></div></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('identifiers')\"><span class=\"font-semibold\">Identifiers</span> <span class=\"inline-flex\"><span x-show=\"openSections.identifiers\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -987,7 +971,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</span> <span x-show=\"!openSections.identifiers\" x-cloak>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</span> <span x-show=\"!openSections.identifiers\" x-cloak>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -995,85 +979,85 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</span></span></button><div x-show=\"openSections.identifiers\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">ISBN</label> <input type=\"text\" name=\"isbn\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</span></span></button><div x-show=\"openSections.identifiers\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">ISBN</label> <input type=\"text\" name=\"isbn\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var50 string
|
var templ_7745c5c3_Var50 string
|
||||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Isbn))
|
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Isbn))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 493, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 474, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">ASIN</label> <input type=\"text\" name=\"asin\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">ASIN</label> <input type=\"text\" name=\"asin\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var51 string
|
var templ_7745c5c3_Var51 string
|
||||||
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Asin))
|
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Asin))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 497, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 478, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var51)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Goodreads ID</label> <input type=\"text\" name=\"goodreads_id\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Goodreads ID</label> <input type=\"text\" name=\"goodreads_id\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var52 string
|
var templ_7745c5c3_Var52 string
|
||||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoodreadsID))
|
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoodreadsID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 501, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 482, Col: 85}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var52)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">OpenLibrary ID</label> <input type=\"text\" name=\"openlibrary_id\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">OpenLibrary ID</label> <input type=\"text\" name=\"openlibrary_id\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var53 string
|
var templ_7745c5c3_Var53 string
|
||||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.OpenlibraryID))
|
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.OpenlibraryID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 505, Col: 89}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 486, Col: 89}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var53)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Google Books ID</label> <input type=\"text\" name=\"google_books_id\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Google Books ID</label> <input type=\"text\" name=\"google_books_id\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var54 string
|
var templ_7745c5c3_Var54 string
|
||||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoogleBooksID))
|
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.GoogleBooksID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 509, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 490, Col: 90}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Web URL</label> <input type=\"url\" name=\"web_url\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Web URL</label> <input type=\"url\" name=\"web_url\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var55 string
|
var templ_7745c5c3_Var55 string
|
||||||
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.WebUrl))
|
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.WebUrl))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 513, Col: 74}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 494, Col: 74}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var55)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\" class=\"input\"></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('comic')\"><span class=\"font-semibold\">Comic/Manga</span> <span class=\"inline-flex\"><span x-show=\"openSections.comic\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1081,7 +1065,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</span> <span x-show=\"!openSections.comic\" x-cloak>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</span> <span x-show=\"!openSections.comic\" x-cloak>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1089,186 +1073,186 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\" selected=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</span></span></button><div x-show=\"openSections.comic\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label for=\"manga_type\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Manga Type</label> <select id=\"manga_type\" name=\"manga_type\" class=\"input\"><option value=\"unknown\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var56 string
|
var templ_7745c5c3_Var56 string
|
||||||
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown")
|
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "unknown")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 531, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 512, Col: 85}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\">Unknown</option> <option value=\"no\" selected=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\">Unknown</option> <option value=\"no\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var57 string
|
var templ_7745c5c3_Var57 string
|
||||||
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no")
|
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "no")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 532, Col: 75}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 513, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">No</option> <option value=\"yes\" selected=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\">No</option> <option value=\"yes\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var58 string
|
var templ_7745c5c3_Var58 string
|
||||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes")
|
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 533, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 514, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\">Yes</option> <option value=\"yes_and_right_to_left\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var59 string
|
var templ_7745c5c3_Var59 string
|
||||||
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left")
|
templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MangaType) == "yes_and_right_to_left")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 534, Col: 113}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 515, Col: 113}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\" selected=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\">Yes (Right to Left)</option></select></div><div><label for=\"reading_direction\" class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Reading Direction</label> <select id=\"reading_direction\" name=\"reading_direction\" class=\"input\"><option value=\"auto\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var60 string
|
var templ_7745c5c3_Var60 string
|
||||||
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto")
|
templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "auto")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 540, Col: 86}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 521, Col: 86}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">Auto</option> <option value=\"ltr\" selected=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "\">Auto</option> <option value=\"ltr\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var61 string
|
var templ_7745c5c3_Var61 string
|
||||||
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr")
|
templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "ltr")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 541, Col: 84}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 522, Col: 84}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\">Left to Right</option> <option value=\"rtl\" selected=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\">Left to Right</option> <option value=\"rtl\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var62 string
|
var templ_7745c5c3_Var62 string
|
||||||
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl")
|
templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "rtl")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 542, Col: 84}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 523, Col: 84}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\">Right to Left</option> <option value=\"vertical\" selected=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">Right to Left</option> <option value=\"vertical\" selected=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var63 string
|
var templ_7745c5c3_Var63 string
|
||||||
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical")
|
templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ReadingDirection) == "vertical")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 543, Col: 94}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 524, Col: 94}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "\">Vertical</option></select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Age Rating</label> <input type=\"text\" name=\"age_rating\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var64 string
|
var templ_7745c5c3_Var64 string
|
||||||
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
|
templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.AgeRating))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 548, Col: 81}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 529, Col: 81}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Story Arc</label> <input type=\"text\" name=\"story_arc\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var65 string
|
var templ_7745c5c3_Var65 string
|
||||||
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
|
templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.StoryArc))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 552, Col: 79}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 533, Col: 79}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var65)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Imprint</label> <input type=\"text\" name=\"imprint\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var66 string
|
var templ_7745c5c3_Var66 string
|
||||||
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
|
templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.Imprint))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 556, Col: 76}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 537, Col: 76}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var66)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Scan Information</label> <input type=\"text\" name=\"scan_information\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var67 string
|
var templ_7745c5c3_Var67 string
|
||||||
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
|
templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.ScanInformation))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 560, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 541, Col: 93}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var67)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Metadata Notes</label> <textarea name=\"metadata_notes\" rows=\"2\" class=\"input\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var68 string
|
var templ_7745c5c3_Var68 string
|
||||||
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
|
templ_7745c5c3_Var68, templ_7745c5c3_Err = templ.JoinStringErrs(textToString(book.MetadataNotes))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 565, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 546, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var68))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 116, "</textarea></div><div class=\"flex items-center gap-2\"><input type=\"checkbox\" name=\"is_black_and_white\" id=\"is_black_and_white\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if book.IsBlackAndWhite.Bool {
|
if book.IsBlackAndWhite.Bool {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, " checked")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, " checked")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 118, " class=\"rounded\"> <label for=\"is_black_and_white\" class=\"text-sm\" style=\"color: var(--text-secondary);\">Black & White</label></div></div></div><div class=\"rounded-xl border overflow-hidden\" style=\"border-color: var(--border);\"><button type=\"button\" class=\"w-full px-4 py-3 flex justify-between items-center hover:bg-surface-hover\" style=\"color: var(--text-primary);\" @click=\"toggleSection('technical')\"><span class=\"font-semibold\">Technical</span> <span class=\"inline-flex\"><span x-show=\"openSections.technical\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1276,7 +1260,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "</span> <span x-show=\"!openSections.technical\" x-cloak>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "</span> <span x-show=\"!openSections.technical\" x-cloak>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1284,59 +1268,88 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "</span></span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "</span></span></button><div x-show=\"openSections.technical\" x-transition class=\"p-4 space-y-3 border-t\" style=\"border-color: var(--border);\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Page Count</label> <input type=\"number\" name=\"page_count\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var69 string
|
var templ_7745c5c3_Var69 string
|
||||||
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
|
templ_7745c5c3_Var69, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", book.PageCount.Int32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 589, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 570, Col: 56}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var69)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, "\" class=\"input\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Contributors (comma-separated)</label> <input type=\"text\" name=\"contributors\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var70 string
|
var templ_7745c5c3_Var70 string
|
||||||
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
|
templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.ResolveAttributeValue(stringSliceToString(book.Contributors))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 594, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 575, Col: 93}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var70)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "\" class=\"input\"></div><div class=\"grid grid-cols-2 gap-3\"><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">Format</label> <input type=\"text\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var71 string
|
var templ_7745c5c3_Var71 string
|
||||||
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
|
templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.ResolveAttributeValue(textToString(book.MimeType))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 599, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 580, Col: 62}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var71)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "\" readonly class=\"input opacity-60\"></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Size</label> <input type=\"text\" value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var72 string
|
var templ_7745c5c3_Var72 string
|
||||||
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
|
templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%.1f MB", float64(book.FileSize.Int64)/1024/1024))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 604, Col: 98}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 585, Col: 97}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var72)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" readonly class=\"input opacity-60\"></div></div></div></div></div></div><div class=\"flex justify-end space-x-3 p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "\" readonly class=\"input opacity-60\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-1\" style=\"color: var(--text-secondary);\">File Path</label> <input type=\"text\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var73 string
|
||||||
|
templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.FileLocation)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/book_detail_modals.templ`, Line: 591, Col: 51}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var73)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "\" readonly class=\"input opacity-60 font-mono text-xs\"></div></div></div></div></div><div class=\"flex items-center justify-between p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><div class=\"flex items-center gap-2\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("sync", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "<span>Rescan</span></span> <svg x-show=\"rescanning\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button> <button @click=\"resetMetadata()\" :disabled=\"resetting\" class=\"btn btn-secondary\" title=\"Discard all manual edits and restore scanned metadata\"><span x-show=\"!resetting\" class=\"inline-flex items-center gap-2\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("refresh", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<span>Reset to Scanned</span></span> <svg x-show=\"resetting\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button></div><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1344,7 +1357,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "Save</button></div></div></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "Save</button></div></div></div></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
+381
-350
File diff suppressed because it is too large
Load Diff
+11
-54
@@ -5,7 +5,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) string {
|
// The init config carries only immutable book metadata. Reading state
|
||||||
|
// (position, bookmarks, annotations) is never embedded: the reader fetches
|
||||||
|
// it from the APIs at open time, so the page can never carry — nor write
|
||||||
|
// back — a stale snapshot of it.
|
||||||
|
func readerInitExpr(metadata ReaderMetadata) string {
|
||||||
config := map[string]interface{}{
|
config := map[string]interface{}{
|
||||||
"mediaItemId": metadata.MediaItemID,
|
"mediaItemId": metadata.MediaItemID,
|
||||||
"fileUrl": metadata.FileURL,
|
"fileUrl": metadata.FileURL,
|
||||||
@@ -13,42 +17,11 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress, bookmarks
|
|||||||
"readingDirection": metadata.ReadingDirection,
|
"readingDirection": metadata.ReadingDirection,
|
||||||
"mangaType": metadata.MangaType,
|
"mangaType": metadata.MangaType,
|
||||||
}
|
}
|
||||||
if progress.Percentage > 0 {
|
|
||||||
config["savedPercentage"] = progress.Percentage / 100
|
|
||||||
}
|
|
||||||
if progress.EpubCfi != "" {
|
|
||||||
config["savedCfi"] = progress.EpubCfi
|
|
||||||
}
|
|
||||||
// Fixed-layout & comic formats: the page index is the canonical, exact
|
|
||||||
// locator (pages are fixed images). Pass it so the reader restores by page.
|
|
||||||
if (metadata.FormatGroup == "fixed_layout" || metadata.FormatGroup == "comic_archive") && progress.CurrentPage > 0 {
|
|
||||||
config["savedPage"] = progress.CurrentPage
|
|
||||||
if progress.TotalPages > 0 {
|
|
||||||
config["savedTotalPages"] = progress.TotalPages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(bookmarks) > 0 {
|
|
||||||
items := make([]map[string]interface{}, 0, len(bookmarks))
|
|
||||||
for _, b := range bookmarks {
|
|
||||||
var page any
|
|
||||||
if b.PageNumber != nil {
|
|
||||||
page = *b.PageNumber
|
|
||||||
}
|
|
||||||
items = append(items, map[string]interface{}{
|
|
||||||
"id": b.ID,
|
|
||||||
"title": b.Title,
|
|
||||||
"positionLabel": b.Position,
|
|
||||||
"cfi": b.CfiPosition,
|
|
||||||
"page": page,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
config["bookmarks"] = items
|
|
||||||
}
|
|
||||||
jsonBytes, _ := json.Marshal(config)
|
jsonBytes, _ := json.Marshal(config)
|
||||||
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
|
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookmarks []Bookmark) {
|
templ Reader(user User, metadata ReaderMetadata) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -64,7 +37,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
|
|||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
x-data="readerShell"
|
x-data="readerShell"
|
||||||
x-init={ readerInitExpr(metadata, progress, bookmarks) }
|
x-init={ readerInitExpr(metadata) }
|
||||||
class={ "theme-" + user.Theme + " h-screen overflow-hidden" }
|
class={ "theme-" + user.Theme + " h-screen overflow-hidden" }
|
||||||
>
|
>
|
||||||
<!-- Reading surface: edge-to-edge. Chrome overlays translucently;
|
<!-- Reading surface: edge-to-edge. Chrome overlays translucently;
|
||||||
@@ -96,7 +69,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ReaderChrome(metadata, progress)
|
@ReaderChrome(metadata)
|
||||||
|
|
||||||
<!-- Drawer scrim -->
|
<!-- Drawer scrim -->
|
||||||
<div
|
<div
|
||||||
@@ -311,7 +284,7 @@ templ Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookm
|
|||||||
</html>
|
</html>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
|
templ ReaderChrome(metadata ReaderMetadata) {
|
||||||
<div id="reader-chrome" class="transition-opacity duration-300" :class="chromeVisible ? 'opacity-100' : 'chrome-hidden opacity-0 pointer-events-none'">
|
<div id="reader-chrome" class="transition-opacity duration-300" :class="chromeVisible ? 'opacity-100' : 'chrome-hidden opacity-0 pointer-events-none'">
|
||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
<div id="reader-topbar" class="fixed top-0 left-0 right-0 border-b z-40 pt-[env(safe-area-inset-top)] reader-glass">
|
<div id="reader-topbar" class="fixed top-0 left-0 right-0 border-b z-40 pt-[env(safe-area-inset-top)] reader-glass">
|
||||||
@@ -365,17 +338,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
|
|||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="w-px h-6 reader-sep"></div>
|
<div class="w-px h-6 reader-sep"></div>
|
||||||
<div id="progress-display" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
|
<div id="progress-display" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[4rem] max-w-[5rem] sm:max-w-none text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
|
||||||
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain">
|
<span class="hidden sm:inline" x-text="progressLabel"></span><span x-text="progressMain">—</span>
|
||||||
if progress.FormatGroup == "reflowable" {
|
|
||||||
if metadata.EstimatedPages > 0 {
|
|
||||||
{ fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages) }
|
|
||||||
} else {
|
|
||||||
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-px h-6 reader-sep"></div>
|
<div class="w-px h-6 reader-sep"></div>
|
||||||
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
|
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
|
||||||
@@ -467,13 +430,7 @@ templ ReaderChrome(metadata ReaderMetadata, progress ReadingProgress) {
|
|||||||
<!-- Progress + TOC -->
|
<!-- Progress + TOC -->
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div id="progress-display-fx" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3.5rem] text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
|
<div id="progress-display-fx" @click="cycleProgressMode()" :title="progressTooltip()" class="text-sm min-w-[3.5rem] text-center cursor-pointer truncate whitespace-nowrap overflow-hidden">
|
||||||
<span x-text="progressMain">
|
<span x-text="progressMain">—</span>
|
||||||
if progress.FormatGroup == "reflowable" {
|
|
||||||
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
|
|
||||||
} else {
|
|
||||||
{ fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages) }
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
|
<button @click="toggleTOC()" class="p-1.5 sm:p-2 rounded-lg hover:bg-gray-700" title="Table of Contents (t)">📖</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+39
-128
File diff suppressed because one or more lines are too long
@@ -249,6 +249,46 @@ function stopScanStatusPolling(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Purge all archived items (files missing from disk for 2+ scans). Exposed on
|
||||||
|
// window for the library admin page's inline button; reading history is
|
||||||
|
// deleted with the rows, so a confirm dialog guards it.
|
||||||
|
async function purgeArchivedItems(): Promise<void> {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Permanently delete all archived items? Their reading progress, notes, and highlights will be lost.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/media-items/purge-archived", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Failed to purge archived items");
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
showToast(
|
||||||
|
`Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
setTimeout(() => window.location.reload(), 700);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(
|
||||||
|
e instanceof Error ? e.message : "Failed to purge archived items",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(window as any).purgeArchivedItems = purgeArchivedItems;
|
||||||
|
|
||||||
export {
|
export {
|
||||||
hideScanProgress,
|
hideScanProgress,
|
||||||
loadWatchStatus,
|
loadWatchStatus,
|
||||||
|
|||||||
+66
-37
@@ -1,6 +1,5 @@
|
|||||||
import { Alpine } from "./alpine";
|
import { Alpine } from "./alpine";
|
||||||
import { showToast } from "./toast";
|
import { showToast } from "./toast";
|
||||||
import { generateCoverBlob } from "./cover-generator";
|
|
||||||
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
import { searchTagSuggestions, type TagSuggestion } from "./tag-dropdown";
|
||||||
|
|
||||||
function getMediaId(): string {
|
function getMediaId(): string {
|
||||||
@@ -91,12 +90,13 @@ export {
|
|||||||
|
|
||||||
interface MetadataEditorState {
|
interface MetadataEditorState {
|
||||||
openSections: Record<string, boolean>;
|
openSections: Record<string, boolean>;
|
||||||
coverGenerating: boolean;
|
|
||||||
hasExistingCover: boolean;
|
hasExistingCover: boolean;
|
||||||
newCoverPreview: string;
|
newCoverPreview: string;
|
||||||
coverFile: Blob | null;
|
coverFile: Blob | null;
|
||||||
coverAction: string;
|
coverAction: string;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
rescanning: boolean;
|
||||||
|
resetting: boolean;
|
||||||
userRating: number;
|
userRating: number;
|
||||||
ratingHover: number;
|
ratingHover: number;
|
||||||
ratingSaving: boolean;
|
ratingSaving: boolean;
|
||||||
@@ -107,9 +107,10 @@ interface MetadataEditorState {
|
|||||||
showMetadataEditor(): void;
|
showMetadataEditor(): void;
|
||||||
hideMetadataEditor(): void;
|
hideMetadataEditor(): void;
|
||||||
handleCoverUpload(event: Event): void;
|
handleCoverUpload(event: Event): void;
|
||||||
generateCover(): Promise<void>;
|
|
||||||
removeCover(): void;
|
removeCover(): void;
|
||||||
saveMetadata(): Promise<void>;
|
saveMetadata(): Promise<void>;
|
||||||
|
rescanBook(): Promise<void>;
|
||||||
|
resetMetadata(): Promise<void>;
|
||||||
starFill(i: number): string;
|
starFill(i: number): string;
|
||||||
ratingText(): string;
|
ratingText(): string;
|
||||||
setRating(value: number): Promise<void>;
|
setRating(value: number): Promise<void>;
|
||||||
@@ -128,12 +129,6 @@ Alpine.data("bookDetail", () => {
|
|||||||
coverImg?.src &&
|
coverImg?.src &&
|
||||||
!coverImg.src.includes("placeholder-book.svg");
|
!coverImg.src.includes("placeholder-book.svg");
|
||||||
|
|
||||||
const coverPreviewEl = document.querySelector(
|
|
||||||
".aspect-\\[2\\/3\\] img",
|
|
||||||
) as HTMLImageElement | null;
|
|
||||||
const coverSrc = coverPreviewEl?.src || "";
|
|
||||||
const fileUrl = coverSrc && !coverSrc.includes("placeholder") ? coverSrc : "";
|
|
||||||
|
|
||||||
const initialTags: string[] = [];
|
const initialTags: string[] = [];
|
||||||
const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]");
|
const tagBadges = document.querySelectorAll("#metadata-editor-modal [data-editor-tag]");
|
||||||
tagBadges.forEach((el) => {
|
tagBadges.forEach((el) => {
|
||||||
@@ -143,12 +138,13 @@ Alpine.data("bookDetail", () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
openSections: { basic: true } as Record<string, boolean>,
|
openSections: { basic: true } as Record<string, boolean>,
|
||||||
coverGenerating: false,
|
|
||||||
hasExistingCover: !!hasCover,
|
hasExistingCover: !!hasCover,
|
||||||
newCoverPreview: "",
|
newCoverPreview: "",
|
||||||
coverFile: null as Blob | null,
|
coverFile: null as Blob | null,
|
||||||
coverAction: "keep",
|
coverAction: "keep",
|
||||||
saving: false,
|
saving: false,
|
||||||
|
rescanning: false,
|
||||||
|
resetting: false,
|
||||||
userRating: 0,
|
userRating: 0,
|
||||||
ratingHover: 0,
|
ratingHover: 0,
|
||||||
ratingSaving: false,
|
ratingSaving: false,
|
||||||
@@ -459,33 +455,6 @@ Alpine.data("bookDetail", () => {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
},
|
},
|
||||||
|
|
||||||
async generateCover() {
|
|
||||||
this.coverGenerating = true;
|
|
||||||
try {
|
|
||||||
const formatGroup =
|
|
||||||
document
|
|
||||||
.querySelector('[data-format-group]')
|
|
||||||
?.getAttribute("data-format-group") || "reflowable";
|
|
||||||
|
|
||||||
const blob = await generateCoverBlob(fileUrl, formatGroup);
|
|
||||||
if (!blob) return;
|
|
||||||
|
|
||||||
this.coverFile = blob;
|
|
||||||
this.coverAction = "upload";
|
|
||||||
|
|
||||||
const preview = document.getElementById(
|
|
||||||
"metadata-cover-preview",
|
|
||||||
) as HTMLImageElement;
|
|
||||||
if (preview) {
|
|
||||||
preview.src = URL.createObjectURL(blob);
|
|
||||||
}
|
|
||||||
this.newCoverPreview = URL.createObjectURL(blob);
|
|
||||||
showToast("Cover generated successfully", "success");
|
|
||||||
} finally {
|
|
||||||
this.coverGenerating = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
removeCover() {
|
removeCover() {
|
||||||
this.coverAction = "remove";
|
this.coverAction = "remove";
|
||||||
this.coverFile = null;
|
this.coverFile = null;
|
||||||
@@ -559,6 +528,66 @@ Alpine.data("bookDetail", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async rescanBook() {
|
||||||
|
if (this.rescanning) return;
|
||||||
|
this.rescanning = true;
|
||||||
|
const mediaId = getMediaId();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/media-items/${mediaId}/rescan`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Failed to rescan book");
|
||||||
|
}
|
||||||
|
showToast("Book rescanned successfully", "success");
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(
|
||||||
|
e instanceof Error ? e.message : "Failed to rescan book",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.rescanning = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async resetMetadata() {
|
||||||
|
if (this.resetting) return;
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"Reset all metadata to scanned defaults? Manual edits and custom covers will be discarded.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.resetting = true;
|
||||||
|
const mediaId = getMediaId();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/media-items/${mediaId}/rescan?reset_overrides=true`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: getAuthHeader() },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || "Failed to reset metadata");
|
||||||
|
}
|
||||||
|
showToast("Metadata reset to scanned defaults", "success");
|
||||||
|
setTimeout(() => window.location.reload(), 500);
|
||||||
|
} catch (e) {
|
||||||
|
showToast(
|
||||||
|
e instanceof Error ? e.message : "Failed to reset metadata",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.resetting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async searchEditorTags() {
|
async searchEditorTags() {
|
||||||
const libraryId = document.body.getAttribute("data-library-id") || "";
|
const libraryId = document.body.getAttribute("data-library-id") || "";
|
||||||
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {
|
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { showToast } from "./toast";
|
|
||||||
|
|
||||||
function getToken(): string {
|
|
||||||
return localStorage.getItem("token") || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateCoverBlob(
|
|
||||||
fileUrl: string,
|
|
||||||
formatGroup: string,
|
|
||||||
): Promise<Blob | null> {
|
|
||||||
try {
|
|
||||||
const View = (await import("foliate-js/view.js")).default;
|
|
||||||
const view = new View();
|
|
||||||
|
|
||||||
const resp = await fetch(fileUrl, {
|
|
||||||
headers: { Authorization: `Bearer ${getToken()}` },
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
showToast("Failed to fetch book file for cover generation", "error");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await resp.blob();
|
|
||||||
const file = new File([blob], "book", { type: blob.type });
|
|
||||||
|
|
||||||
const pdfOptions =
|
|
||||||
formatGroup === "fixed_layout"
|
|
||||||
? {
|
|
||||||
pdf: {
|
|
||||||
cMapUrl: "/static/vendor/pdfjs/cmaps/",
|
|
||||||
standardFontDataUrl: "/static/vendor/pdfjs/standard_fonts/",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {};
|
|
||||||
|
|
||||||
await view.open(file, pdfOptions);
|
|
||||||
|
|
||||||
if (!view.book?.sections?.length) {
|
|
||||||
showToast("Could not read book sections", "error");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formatGroup === "fixed_layout") {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
await view.renderer?.renderPage(view.book.sections[0], canvas);
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
canvas.toBlob(
|
|
||||||
(b) => resolve(b),
|
|
||||||
"image/jpeg",
|
|
||||||
0.85,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const coverHref = view.book.cover;
|
|
||||||
if (coverHref) {
|
|
||||||
const coverBlob = await coverHref.blob();
|
|
||||||
if (coverBlob.type.startsWith("image/")) {
|
|
||||||
return coverBlob;
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
const c = document.createElement("canvas");
|
|
||||||
c.width = img.naturalWidth;
|
|
||||||
c.height = img.naturalHeight;
|
|
||||||
c.getContext("2d")?.drawImage(img, 0, 0);
|
|
||||||
c.toBlob((b) => resolve(b), "image/jpeg", 0.85);
|
|
||||||
};
|
|
||||||
img.onerror = () => resolve(null);
|
|
||||||
img.src = URL.createObjectURL(coverBlob);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast("No cover found in book file", "error");
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Cover generation failed:", e);
|
|
||||||
showToast("Cover generation failed", "error");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+198
-28
@@ -406,6 +406,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
tapZonesEnabled: true as boolean,
|
tapZonesEnabled: true as boolean,
|
||||||
tapZoneSize: 30 as number,
|
tapZoneSize: 30 as number,
|
||||||
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
|
tapZoneTimer: null as ReturnType<typeof setTimeout> | null,
|
||||||
|
// Any live text selection, host document or content iframe. Fed by
|
||||||
|
// selectionchange listeners (touch devices); tap zones stand down
|
||||||
|
// while one exists.
|
||||||
|
anySelection: false as boolean,
|
||||||
highlightItems: [] as {
|
highlightItems: [] as {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -478,7 +482,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
tocItems: [] as any[],
|
tocItems: [] as any[],
|
||||||
mediaItemId: "" as string,
|
mediaItemId: "" as string,
|
||||||
saveTimeout: null as ReturnType<typeof setTimeout> | null,
|
saveTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||||
initTime: 0 as number,
|
// Position last known to be stored (the restore at open time, or the
|
||||||
|
// last successful save). Relocations that don't move from it are never
|
||||||
|
// written back, so a restored position can't clobber a newer device
|
||||||
|
// push — while swipe/scroll paging (handled inside foliate, with no
|
||||||
|
// wrapper method to flag) still saves normally.
|
||||||
|
lastSyncedCfi: "" as string,
|
||||||
|
lastSyncedFraction: -1 as number,
|
||||||
|
lastCfi: "" as string,
|
||||||
contextText: "" as string,
|
contextText: "" as string,
|
||||||
readingTheme: "light" as string,
|
readingTheme: "light" as string,
|
||||||
readingMode: "light" as string,
|
readingMode: "light" as string,
|
||||||
@@ -560,20 +571,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
formatGroup: string;
|
formatGroup: string;
|
||||||
readingDirection: string;
|
readingDirection: string;
|
||||||
mangaType: string;
|
mangaType: string;
|
||||||
savedPercentage?: number;
|
|
||||||
savedCfi?: string;
|
|
||||||
savedPage?: number;
|
|
||||||
savedTotalPages?: number;
|
|
||||||
bookmarks?: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
positionLabel: string;
|
|
||||||
cfi: string;
|
|
||||||
page: number | null;
|
|
||||||
}[];
|
|
||||||
}) {
|
}) {
|
||||||
this.mediaItemId = config.mediaItemId;
|
this.mediaItemId = config.mediaItemId;
|
||||||
this.bookmarkItems = config.bookmarks ?? [];
|
// Reading state (position, bookmarks, annotations) is never baked
|
||||||
|
// into the rendered page: the web reader is intrinsically tied to
|
||||||
|
// the server, so it reads all of it from the APIs at open time —
|
||||||
|
// a device sync between render and open can never be shadowed by a
|
||||||
|
// stale snapshot.
|
||||||
this.isComic = config.formatGroup === "comic_archive";
|
this.isComic = config.formatGroup === "comic_archive";
|
||||||
// Reading flow for comics is a per-book preference (a webtoon title
|
// Reading flow for comics is a per-book preference (a webtoon title
|
||||||
// vs. a paged manga volume); read before the renderer is chosen.
|
// vs. a paged manga volume); read before the renderer is chosen.
|
||||||
@@ -691,6 +695,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
// out to the host document, so the viewport listeners miss them).
|
// out to the host document, so the viewport listeners miss them).
|
||||||
if (window.matchMedia("(pointer: coarse)").matches) {
|
if (window.matchMedia("(pointer: coarse)").matches) {
|
||||||
this.attachTapZoneListeners(doc as unknown as HTMLElement, true);
|
this.attachTapZoneListeners(doc as unknown as HTMLElement, true);
|
||||||
|
// Same for selectionchange: a selection inside the iframe must
|
||||||
|
// cancel armed tap actions and feed the host-surface guard.
|
||||||
|
doc.addEventListener("selectionchange", () => {
|
||||||
|
const sel = doc.getSelection();
|
||||||
|
this.noteSelectionActivity(
|
||||||
|
!!sel && !sel.isCollapsed && !!sel.toString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Text selection → highlight popover (reflowable EPUB only;
|
// Text selection → highlight popover (reflowable EPUB only;
|
||||||
// fixed-layout highlight overlays are a later milestone).
|
// fixed-layout highlight overlays are a later milestone).
|
||||||
@@ -735,6 +747,21 @@ document.addEventListener("alpine:init", () => {
|
|||||||
() => setTimeout(checkSelection, 0),
|
() => setTimeout(checkSelection, 0),
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
|
// Clicks in the book dismiss the popover in ANY mode: iframe
|
||||||
|
// events never bubble to the host document (so the host
|
||||||
|
// outside-click dismiss never sees them), and the collapsed
|
||||||
|
// check above only covers create mode — edit mode had no
|
||||||
|
// outside-click path at all, leaving Esc as the only way out.
|
||||||
|
// Clicking a painted highlight still works: this hides, then
|
||||||
|
// foliate's show-annotation re-opens it in edit mode.
|
||||||
|
doc.addEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
() => {
|
||||||
|
if (this.selectionPopover.open)
|
||||||
|
this.hideSelectionPopover();
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
doc.addEventListener(
|
doc.addEventListener(
|
||||||
"keyup",
|
"keyup",
|
||||||
(ev: KeyboardEvent) => {
|
(ev: KeyboardEvent) => {
|
||||||
@@ -826,7 +853,13 @@ document.addEventListener("alpine:init", () => {
|
|||||||
});
|
});
|
||||||
this.view.addEventListener("show-annotation", (e: any) => {
|
this.view.addEventListener("show-annotation", (e: any) => {
|
||||||
const { value, index, range } = e.detail;
|
const { value, index, range } = e.detail;
|
||||||
const h = this.highlightItems.find((x) => x.cfi === value);
|
// Device-synced highlights are painted with a synthesized range
|
||||||
|
// CFI (renderCfi) while the stored locator stays a point CFI, so a
|
||||||
|
// click reports the render value — match either or editing
|
||||||
|
// device highlights is impossible.
|
||||||
|
const h = this.highlightItems.find(
|
||||||
|
(x) => x.cfi === value || x.renderCfi === value,
|
||||||
|
);
|
||||||
if (!h) return;
|
if (!h) return;
|
||||||
const doc = this.renderer
|
const doc = this.renderer
|
||||||
?.getContents?.()
|
?.getContents?.()
|
||||||
@@ -853,6 +886,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const { fraction, location, pageItem, cfi, tocItem, section } =
|
const { fraction, location, pageItem, cfi, tocItem, section } =
|
||||||
e.detail;
|
e.detail;
|
||||||
this.hideSelectionPopover();
|
this.hideSelectionPopover();
|
||||||
|
this.lastCfi = cfi || "";
|
||||||
this.lastRelocateDetail = {
|
this.lastRelocateDetail = {
|
||||||
fraction,
|
fraction,
|
||||||
location,
|
location,
|
||||||
@@ -898,19 +932,27 @@ document.addEventListener("alpine:init", () => {
|
|||||||
document.addEventListener("keydown", (ev: KeyboardEvent) =>
|
document.addEventListener("keydown", (ev: KeyboardEvent) =>
|
||||||
this.handleKeydown(ev),
|
this.handleKeydown(ev),
|
||||||
);
|
);
|
||||||
if (this.isFixedLayout && config.savedPage != null && config.savedPage > 0) {
|
// Reading position comes from the database, fetched fresh at open
|
||||||
|
// (the rendered page carries no snapshot of it).
|
||||||
|
const saved = await this.fetchSavedLocation();
|
||||||
|
if (this.isFixedLayout && saved.page != null && saved.page > 0) {
|
||||||
// Fixed-layout & comics: a page index is the exact, universal locator.
|
// Fixed-layout & comics: a page index is the exact, universal locator.
|
||||||
// A bare number navigates directly to the section index in foliate.
|
// A bare number navigates directly to the section index in foliate.
|
||||||
await this.view.init({ lastLocation: config.savedPage - 1 })
|
await this.view.init({ lastLocation: saved.page - 1 })
|
||||||
} else if (config.savedCfi) {
|
} else if (saved.cfi) {
|
||||||
await this.view.init({ lastLocation: config.savedCfi })
|
await this.view.init({ lastLocation: saved.cfi })
|
||||||
} else if (config.savedPercentage && config.savedPercentage > 0) {
|
} else if (saved.percentage != null && saved.percentage > 0) {
|
||||||
await this.view.init({
|
await this.view.init({
|
||||||
lastLocation: { fraction: config.savedPercentage },
|
lastLocation: { fraction: saved.percentage },
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await this.view.init({})
|
await this.view.init({})
|
||||||
}
|
}
|
||||||
|
// The position restored above (or the start of the book on a fresh
|
||||||
|
// open) is the baseline: only relocations that actually move from
|
||||||
|
// it may write progress.
|
||||||
|
this.lastSyncedCfi = this.lastCfi;
|
||||||
|
this.lastSyncedFraction = this.lastRelocateDetail?.fraction ?? -1;
|
||||||
// The renderer only knows it's a PDF once frames exist (they carry
|
// The renderer only knows it's a PDF once frames exist (they carry
|
||||||
// pdf.js onZoom), i.e. after init has rendered the first spread.
|
// pdf.js onZoom), i.e. after init has rendered the first spread.
|
||||||
// Read it now and apply the saved pointer mode — this also makes the
|
// Read it now and apply the saved pointer mode — this also makes the
|
||||||
@@ -920,9 +962,19 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
||||||
}
|
}
|
||||||
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
|
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
|
||||||
this.initTime = Date.now();
|
// A bfcache-resurrected page is stale by definition: re-baseline to
|
||||||
|
// its frozen position so it can't write that back until the user
|
||||||
|
// actually navigates again.
|
||||||
|
window.addEventListener("pageshow", (e: PageTransitionEvent) => {
|
||||||
|
if (e.persisted) {
|
||||||
|
this.lastSyncedCfi = this.lastCfi;
|
||||||
|
this.lastSyncedFraction =
|
||||||
|
this.lastRelocateDetail?.fraction ?? -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
this.fetchReadingSpeed();
|
this.fetchReadingSpeed();
|
||||||
this.refreshAnnotations();
|
this.refreshAnnotations();
|
||||||
|
this.refreshBookmarks();
|
||||||
this.setupChrome();
|
this.setupChrome();
|
||||||
this.setupTapZones();
|
this.setupTapZones();
|
||||||
},
|
},
|
||||||
@@ -985,6 +1037,26 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (!window.matchMedia("(pointer: coarse)").matches) return;
|
if (!window.matchMedia("(pointer: coarse)").matches) return;
|
||||||
const vp = document.getElementById("reader-viewport");
|
const vp = document.getElementById("reader-viewport");
|
||||||
if (vp) this.attachTapZoneListeners(vp as HTMLElement, false);
|
if (vp) this.attachTapZoneListeners(vp as HTMLElement, false);
|
||||||
|
// Host-document selections (fixed-layout/PDF text layers, margins):
|
||||||
|
// selectionchange never crosses iframe boundaries, so register per
|
||||||
|
// surface.
|
||||||
|
document.addEventListener("selectionchange", () => {
|
||||||
|
const sel = document.getSelection();
|
||||||
|
this.noteSelectionActivity(
|
||||||
|
!!sel && !sel.isCollapsed && !!sel.toString(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Record selection state and abort any armed tap action: a selection
|
||||||
|
// appearing right after finger-lift means the "tap" was actually a
|
||||||
|
// long-press selection engaging, and paging away would destroy the
|
||||||
|
// gesture the user just made.
|
||||||
|
noteSelectionActivity(hasSelection: boolean) {
|
||||||
|
this.anySelection = hasSelection;
|
||||||
|
if (hasSelection && this.tapZoneTimer) {
|
||||||
|
clearTimeout(this.tapZoneTimer);
|
||||||
|
this.tapZoneTimer = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
|
attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
|
||||||
let downX = 0;
|
let downX = 0;
|
||||||
@@ -992,6 +1064,12 @@ document.addEventListener("alpine:init", () => {
|
|||||||
let downT = 0;
|
let downT = 0;
|
||||||
let downId = -1;
|
let downId = -1;
|
||||||
let moved = false;
|
let moved = false;
|
||||||
|
// Android fires contextmenu when a long-press engages text
|
||||||
|
// selection: that press must never resolve into a tap action, even
|
||||||
|
// when it was released inside the 500ms tap window (the selection
|
||||||
|
// engaging and the guard racing is exactly how corner selections
|
||||||
|
// used to page back instead).
|
||||||
|
let longPressed = false;
|
||||||
surface.addEventListener(
|
surface.addEventListener(
|
||||||
"pointerdown",
|
"pointerdown",
|
||||||
(e: PointerEvent) => {
|
(e: PointerEvent) => {
|
||||||
@@ -1001,6 +1079,21 @@ document.addEventListener("alpine:init", () => {
|
|||||||
downT = Date.now();
|
downT = Date.now();
|
||||||
downId = e.pointerId;
|
downId = e.pointerId;
|
||||||
moved = false;
|
moved = false;
|
||||||
|
longPressed = false;
|
||||||
|
},
|
||||||
|
{ passive: true },
|
||||||
|
);
|
||||||
|
surface.addEventListener("contextmenu", () => {
|
||||||
|
longPressed = true;
|
||||||
|
}, { passive: true });
|
||||||
|
// The browser takes over the gesture (text selection, scroll) with
|
||||||
|
// pointercancel — no pointerup will follow. Drop the tracked
|
||||||
|
// pointer so stale state can never match a later touch.
|
||||||
|
surface.addEventListener(
|
||||||
|
"pointercancel",
|
||||||
|
() => {
|
||||||
|
downId = -1;
|
||||||
|
moved = false;
|
||||||
},
|
},
|
||||||
{ passive: true },
|
{ passive: true },
|
||||||
);
|
);
|
||||||
@@ -1018,7 +1111,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
(e: PointerEvent) => {
|
(e: PointerEvent) => {
|
||||||
if (e.pointerId !== downId) return;
|
if (e.pointerId !== downId) return;
|
||||||
downId = -1;
|
downId = -1;
|
||||||
if (moved || Date.now() - downT > 500) return;
|
if (moved || longPressed || Date.now() - downT > 500) return;
|
||||||
if (!this.tapZonesEnabled) return;
|
if (!this.tapZonesEnabled) return;
|
||||||
const target = e.target as HTMLElement | null;
|
const target = e.target as HTMLElement | null;
|
||||||
if (
|
if (
|
||||||
@@ -1029,6 +1122,10 @@ document.addEventListener("alpine:init", () => {
|
|||||||
return;
|
return;
|
||||||
const sel = isDoc ? (surface as any).getSelection?.() : null;
|
const sel = isDoc ? (surface as any).getSelection?.() : null;
|
||||||
if (sel?.toString?.()) return;
|
if (sel?.toString?.()) return;
|
||||||
|
// Host-surface blind spot: selections living in content iframes
|
||||||
|
// (or the host's own fixed-layout text layer) never show in a
|
||||||
|
// per-surface check — the tracked flag covers them.
|
||||||
|
if (!isDoc && this.anySelection) return;
|
||||||
// No tap actions while a fixed-layout page is zoomed — taps then
|
// No tap actions while a fixed-layout page is zoomed — taps then
|
||||||
// belong to the content (and double-tap zoom).
|
// belong to the content (and double-tap zoom).
|
||||||
if (this.isFixedLayout && this.renderer?.zoom != null) return;
|
if (this.isFixedLayout && this.renderer?.zoom != null) return;
|
||||||
@@ -1343,7 +1440,23 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
const row = await resp.json();
|
const row = await resp.json();
|
||||||
const idx = this.highlightItems.findIndex((h) => h.id === p.id);
|
const idx = this.highlightItems.findIndex((h) => h.id === p.id);
|
||||||
|
// The overlay is keyed by the value it was added with; an edit can
|
||||||
|
// change it (note/text edits change the synthesized range), so
|
||||||
|
// remove the old paint before re-adding or it ghosts.
|
||||||
|
const oldValue =
|
||||||
|
idx !== -1
|
||||||
|
? this.highlightItems[idx].renderCfi ||
|
||||||
|
this.highlightItems[idx].cfi
|
||||||
|
: "";
|
||||||
if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row);
|
if (idx !== -1) this.highlightItems[idx] = this.mapHighlightRow(row);
|
||||||
|
const newValue =
|
||||||
|
idx !== -1
|
||||||
|
? this.highlightItems[idx].renderCfi ||
|
||||||
|
this.highlightItems[idx].cfi
|
||||||
|
: "";
|
||||||
|
if (p.pdfPage < 0 && oldValue && oldValue !== newValue) {
|
||||||
|
this.view?.deleteAnnotation({ value: oldValue });
|
||||||
|
}
|
||||||
// Re-add so the overlay redraws with the new color.
|
// Re-add so the overlay redraws with the new color.
|
||||||
if (p.pdfPage >= 0) {
|
if (p.pdfPage >= 0) {
|
||||||
this.renderer?.addRectAnnotation?.({
|
this.renderer?.addRectAnnotation?.({
|
||||||
@@ -1379,7 +1492,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
if (hl?.pdfPage >= 0) {
|
if (hl?.pdfPage >= 0) {
|
||||||
this.renderer?.removeRectAnnotation?.(id);
|
this.renderer?.removeRectAnnotation?.(id);
|
||||||
} else if (hl?.cfi) {
|
} else if (hl?.cfi) {
|
||||||
this.view?.deleteAnnotation({ value: hl.cfi });
|
// Delete with the value the overlay was added by: device-synced
|
||||||
|
// highlights paint a synthesized range, not the stored point CFI.
|
||||||
|
this.view?.deleteAnnotation({
|
||||||
|
value: hl.renderCfi || hl.cfi,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
this.hideSelectionPopover();
|
this.hideSelectionPopover();
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
@@ -1453,8 +1570,52 @@ document.addEventListener("alpine:init", () => {
|
|||||||
/* ignore note errors */
|
/* ignore note errors */
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Fresh reading position from the database — the single source of
|
||||||
|
// truth at open time. Fails soft to a fresh start: change detection
|
||||||
|
// against the restored baseline guarantees merely opening (even at
|
||||||
|
// the wrong spot) can never overwrite the stored position.
|
||||||
|
async fetchSavedLocation(): Promise<{
|
||||||
|
cfi?: string;
|
||||||
|
page?: number;
|
||||||
|
percentage?: number;
|
||||||
|
}> {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token || !this.mediaItemId) return {};
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/api/media-items/${this.mediaItemId}/progress`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: "no-store",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!resp.ok) return {};
|
||||||
|
const row: any = await resp.json();
|
||||||
|
const cfi: string = row?.epubcfi?.String ?? row?.epubcfi ?? "";
|
||||||
|
const page: number = row?.current_page?.Int32 ?? row?.current_page ?? 0;
|
||||||
|
// The stored percentage is a 0-1 fraction.
|
||||||
|
const pct: number = row?.percentage?.Float64 ?? row?.percentage ?? 0;
|
||||||
|
return {
|
||||||
|
cfi: typeof cfi === "string" ? cfi : "",
|
||||||
|
page: typeof page === "number" ? page : 0,
|
||||||
|
percentage: typeof pct === "number" ? pct : 0,
|
||||||
|
};
|
||||||
|
} catch (_e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
},
|
||||||
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
||||||
if (Date.now() - this.initTime < 5000) return;
|
// Only an actual change from the last stored position writes
|
||||||
|
// progress: displaying a restored position must never overwrite a
|
||||||
|
// newer device push. Swipes and scrolls are handled inside foliate
|
||||||
|
// with no wrapper method to flag, so position — not intent — is the
|
||||||
|
// signal. Books without CFIs (fixed layout, PDF) compare fraction.
|
||||||
|
const currentCfi = cfi || "";
|
||||||
|
const changed =
|
||||||
|
currentCfi || this.lastSyncedCfi
|
||||||
|
? currentCfi !== this.lastSyncedCfi
|
||||||
|
: Math.abs(fraction - this.lastSyncedFraction) > 1e-4;
|
||||||
|
if (!changed) return;
|
||||||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||||||
this.saveTimeout = setTimeout(() => {
|
this.saveTimeout = setTimeout(() => {
|
||||||
this.saveProgress(fraction, location, cfi);
|
this.saveProgress(fraction, location, cfi);
|
||||||
@@ -1505,6 +1666,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
this.lastSyncedCfi = cfi || "";
|
||||||
|
this.lastSyncedFraction = fraction;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// silent fail — progress save is non-critical
|
// silent fail — progress save is non-critical
|
||||||
}
|
}
|
||||||
@@ -2467,11 +2630,18 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
handleKeydown(event: KeyboardEvent) {
|
handleKeydown(event: KeyboardEvent) {
|
||||||
const k = event.key;
|
const k = event.key;
|
||||||
// Never hijack keys while the user is typing in a form control.
|
// Never hijack keys while the user is typing in a form control: the
|
||||||
const tag = (event.target as HTMLElement)?.tagName;
|
// field must receive h/l page turns, +/− zoom, and caret arrows.
|
||||||
|
// Escape stays live so popovers/drawers can still be dismissed from
|
||||||
|
// the keyboard even mid-note.
|
||||||
|
const t = event.target as HTMLElement | null;
|
||||||
const typing =
|
const typing =
|
||||||
tag === "INPUT" || tag === "SELECT" || tag === "TEXTAREA";
|
t?.tagName === "INPUT" ||
|
||||||
|
t?.tagName === "SELECT" ||
|
||||||
|
t?.tagName === "TEXTAREA" ||
|
||||||
|
!!t?.isContentEditable;
|
||||||
this.pokeChrome();
|
this.pokeChrome();
|
||||||
|
if (typing && k !== "Escape") return;
|
||||||
if (k === "ArrowLeft" || k === "h") {
|
if (k === "ArrowLeft" || k === "h") {
|
||||||
if (event.altKey) {
|
if (event.altKey) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -2500,7 +2670,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
} else if (k === "F1") {
|
} else if (k === "F1") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.toggleHelp();
|
this.toggleHelp();
|
||||||
} else if (!typing) {
|
} else {
|
||||||
if (k === "t") this.toggleTOC();
|
if (k === "t") this.toggleTOC();
|
||||||
else if (k === "s") this.toggleSettings();
|
else if (k === "s") this.toggleSettings();
|
||||||
else if (k === "b") this.addBookmark();
|
else if (k === "b") this.addBookmark();
|
||||||
|
|||||||
Reference in New Issue
Block a user