Compare commits

..
11 Commits
Author SHA1 Message Date
John O'Keefe 3ab294bf81 feat(scanner): store EPUB reading direction at scan time
Release / build-and-push (push) Successful in 2m33s
Fixed-layout EPUBs lean on the reading_direction column (the web reader
forces book.dir = rtl from it when the file didn't set direction
itself), but the scanner never populated it for EPUBs - only ComicInfo
fed it. Meanwhile real Japanese EPUBs declare page-progression-direction
on the OPF spine, which foliate reads client-side but nothing stored.

Read the spine attribute in the structured OPF parser and map it into
ReadingDirection in parseOPFContent (EPUB2/3, case-insensitive, plus
'right-to-left'/'left-to-right' spellings); undeclared stays empty
rather than forcing ltr, preserving the editor's Auto default. Sidecar
OPFs are metadata-only documents without spines, so the Calibre path is
a no-op. The merge gap-fill copies an embedded-only direction into a
blank sidecar field, and hand-set values keep winning through the
existing OverrideReadingDirection protection.

Tests: declared rtl/RTL/ltr, undeclared and unknown values staying
empty, plus sidecar-wins vs embedded-fills merge cases. Existing manga
EPUBs declaring rtl (verified live in-library) pick the value up on
their next scan, feeding the API and reader config mobile clients
consume.
2026-09-13 13:13:25 -04:00
John O'Keefe e944f11415 feat(scanner): fill sparse sidecars from the PDF Info dictionary
Same gap-fill rule as the EPUB path, against the PDF's embedded Info
dictionary via a readPDFInfoDict helper reusing extractPDFMetadata's
field conventions (creator falls back to author, subject maps to
description, producer to publisher, keywords to tags, plus page count).
Sidecar values always win; unopenable PDFs skip silently.

TestMergeMetadataPDFGapFill uses an Info-bearing hand-built PDF fixture
(shared with the sidecar-cover test) and asserts the sidecar title is
kept while author/description/publisher/tags/page count fill in.
2026-09-13 13:06:48 -04:00
John O'Keefe 708598687e feat(scanner): fill sparse sidecars from embedded EPUB metadata
A sparse metadata.opf/metadata.json (title only, no description) left
books thin even when the file itself carried rich data: the embedded
extractors only ran when no sidecar existed at all. Now mergeMetadata
fills blanks from the book's own OPF - title, author, description,
publisher, language, ISBN, ASIN, series/number, publish date, tags,
contributors - while sidecar values always win and unparseable files
skip silently. Also covers .kepub, which the merge previously ignored
while the extractor already supported it.

Adds TestMergeMetadataEPUBGapFill asserting both directions: sidecar
title/author survive, embedded description/publisher/language fill in.
2026-09-13 13:05:37 -04:00
John O'Keefe aac7c72900 feat(admin): archived-items page with purge dates, restore, and purge
Admins need to see what the archive lifecycle is holding: a dedicated
/admin/archived page listing every hidden item (archived or still in
the missing grace window) with library, file path, status, and - when
retention is enabled - the exact date it will be permanently deleted
(archived_at + ARCHIVE_RETENTION_DAYS).

Per row: Restore (POST /api/media-items/:id/unarchive, clears the
archive state so it reappears; if the file is still gone the next scan
hides it again) and Delete (existing DELETE endpoint for single-row
purge with its reading history). Purge All Archived reuses the existing
bulk button. The library admin banner links to the page and keeps its
purge button; row actions use data attributes with delegated listeners
in admin.ts since this templ version has no JSFunctionCall helper.

Verified live: page 200 with purge dates shown, unarchive returned 204
and reset the row, per-item delete and bulk purge ({purged:1}) both
removed their rows with no leftovers.
2026-09-13 12:01:05 -04:00
John O'Keefe fe5ab9e5f8 feat(ui): hide missing books immediately and mark offline libraries
Two visibility fixes so the UI reflects the shelf's real state on the
next scan instead of only after the archive gate:

- Missing items disappear at once: the user-facing filters already hid
  archived rows; the same listings now also require missing_scan_count =
  0. A deleted or moved-then-not-yet-repointed file vanishes from the
  UI immediately, while purge timing stays gated on archived_at plus the
  retention window - grace protects data, not visibility. Restored
  automatically when the file returns.
- Steam Deck SD-card model for unmounted storage: resolveLibrary stats
  each library's folder roots and flags libraries with no live folder
  as Offline (LibraryData gains the field, computed at request time so
  mounts/unmounts react instantly). The bookshelf shows an empty shelf
  plus a 'storage is not connected' notice for an offline selected
  library, and both the shared LibrarySwitcher and the bookshelf's
  inline select label offline libraries with their true holding counts.
  Nothing is marked or purged while offline.
- TotalMediaCount skips offline libraries, so the 'All Books/Libraries'
  totals match what is actually visible.

Verified live: renaming uploads/Manga away produced the notice, an
empty shelf, and the offline dropdown label with a corrected total;
renaming it back restored all 38 cards with zero dirty rows.
2026-09-13 12:00:49 -04:00
John O'Keefe 249b1dfe93 feat(scanner): treat reappeared content as a move, not a duplicate
When the SHA-256 dedup found identical content already in the library,
the scan skipped the file as a duplicate - and after files moved
between folders the old row kept its stale path, cycled missing ->
archived, and the new path never took. The archive feature turned the
old destructive move behavior into a stuck move instead.

Now the dedup branch stats the old location: if it is gone, the book
was MOVED, so the row is repointed (file_path, file_size) with archive
state cleared and reading history intact. 'Skip as duplicate' only
applies when the old path still exists (a true copy). Verified live: a
moved EPUB kept its single row, followed the file, and never entered
the missing/archive cycle.
2026-09-13 12:00:19 -04:00
John O'Keefe 17216e9cc7 feat(db): archive lifecycle queries, immediate-hide filters, move support
Storage behind the scan lifecycle fixes:

- MoveMediaItemFilePath: repoints a row (path, size) and clears archive
  state when identical content reappears at a new location.
- ListHiddenMediaItems: archived OR missing rows for the admin
  archived-items page; ListMediaItemsByLibraryIncludingArchived (prior
  commit) already fed the scanner.
- All user-facing listings now hide missing items immediately, not just
  archived ones: ListMediaItems, ListMediaItemsByLibrary,
  ListMediaItemsSorted, SearchMediaItems, SearchMediaItemsUnified, the
  next_books CTE, library media counts, and the five search autocomplete
  value queries gained AND mi.missing_scan_count = 0 next to the
  archived_at filter. Purge timing is unchanged (archived_at +
  ARCHIVE_RETENTION_DAYS), so the grace period keeps protecting data
  while the UI reflects removals on the first scan.
- Detail lookups by id/path/sha stay unfiltered on purpose.
2026-09-13 11:59:41 -04:00
John O'Keefe 841db91a29 fix(scanner): extract embedded covers for sidecar-managed books
A full rescan wiped cover_image_path for every PDF/EPUB living in a
metadata.json (or metadata.opf-less) folder with no cover.jpg next to
the book: the sidecar branches returned early after findSidecarCover
missed, and mergeMetadata has no PDF/EPUB cover logic of its own. Four
books lost their thumbnails while their {file}.cover.jpg files still sat
on disk - most visibly the Audiobookshelf-managed No Starch titles.

Both sidecar branches now fall through to an embedded-cover fallback
(PDF via extractPDFCover, EPUB/KEPUB via extractEPUBCover) whenever no
sidecar cover file exists. Comics are untouched: mergeMetadata already
extracts their covers from the archive.

Adds TestSidecarCoverFallback with a hand-built one-page PDF carrying a
JPEG XObject plus a metadata.json sidecar, asserting the sidecar title
wins while the cover still comes from the file. Verified live: rescans
restored all four dereferenced covers with no leftover rows.
2026-09-13 11:59:24 -04:00
John O'Keefe 1eb9c92d6a fix(scanner): nil metadata_overrides crashed Reset to Scanned with SQLSTATE 23502
Release / build-and-push (push) Successful in 2m30s
Reset to Scanned cleared the overrides row (successfully) but then set
the in-memory copy to nil before handing it to updateMediaItem. pgx
encodes a nil []string parameter as SQL NULL, so the follow-up UPDATE
wrote metadata_overrides = NULL into the column's NOT NULL constraint
and the whole rescan failed with:

  failed to update media item: ERROR: null value in column
  "metadata_overrides" of relation "media_items" violates not-null
  constraint (SQLSTATE 23502)

Two changes:

- RescanMediaItem's reset path assigns []string{} instead of nil, with a
  comment explaining the pgx nil-to-NULL encoding trap.
- updateMediaItem routes the override set through utils.MergeOverrides,
  whose contract guarantees a non-nil slice, so no caller can write
  NULL into that column again (verified against pgx v5.9.2 source: a
  scanned '{}' round-trips as non-nil in both directions; the nil could
  only come from our own assignment).

The plain Rescan path never hit this - only Reset did. Worse, the reset
is the remedy when a book's cover_image_path override pins an empty
cover, so the crash also blocked the way out of that state. After this
fix, a plain rescan on an already-reset book repopulates scanned
metadata and extracts the cover.
2026-09-13 00:12:56 -04:00
John O'Keefe 1fa8ee3a59 feat(scanner): Calibre-aligned OPF metadata extraction
Release / build-and-push (push) Successful in 3m5s
Adopt Calibre's reading conventions for the Dublin Core metadata that
parseOPFContent now pulls from the structured OPF parse:

- Titles: EPUB3 title-type selection (prefer 'main', join a distinct
  subtitle with ': ' exactly as Calibre stores it). There is no separate
  subtitle column by design - Calibre-sidecar books arrive pre-joined,
  so a column would stay empty for most libraries and force every client
  to reimplement concatenation.
- Genre: first dc:subject, mirroring the existing processGenresAndTags
  behavior of the Calibre-sidecar path; the embedded path never
  populated Genre before. Subjects stay one-element-one-tag - Library
  of Congress headings legitimately contain commas ("Holmes, Sherlock
  (Fictitious character) -- Fiction") and must not be split.
- Identifiers: urn:isbn:/urn:asin: prefixed values parse in addition to
  opf:scheme attributes, and the scheme-less fallback now requires an
  ISBN-shaped value (10/13 digits, optional separators/trailing X) so
  URIs like the Gutenberg identifiers cannot masquerade as ISBNs -
  observed live on 'A Study in Scarlet'.
- Series: EPUB3 belongs-to-collection with collection-type=series and
  group-position refines, ahead of the classic calibre:series metas.
- Audiobookshelf metadata.json sidecars join their subtitle field into
  the title the same way.

Tests cover title-type main+subtitle joining, belongs-to-collection
series with fractional group-position, urn:isbn extraction, genre/tag
parity, and comma preservation inside subject headings.
2026-09-12 23:45:18 -04:00
John O'Keefe 72d167005f fix(scanner): resolve EPUB covers via structured OPF parsing, Calibre chain
The cover lookup scraped the OPF with attribute-order-sensitive regexes.
Real books serialize attributes in any order - Grand Central's '3 Days to
Live' puts href before id on manifest items and content before name on
the cover meta - so all three regex paths missed and the book fell
through to filename guessing, extracting no cover at all. Attribute
order is meaningless in XML; the regexes were never safe.

Replace them with a structured parse (encoding/xml, namespace and
attribute-order agnostic; see the new media_scanner_opf.go) and follow
Calibre's read_raster_cover resolution order:

1. manifest item with properties=cover-image (non-(X)HTML media only)
2. <meta name=cover> resolved through the manifest, same media guard
3. first spine item that is itself a raster image (store manga)
4. NEW cover-page fallback: books declaring no raster cover at all -
   the classic EPUB2/Adobe cover.xhtml wrapper - are mined for
   <img src> / SVG <image xlink:href> references (Calibre renders the
   page with Qt; extracting the referenced image covers the practical
   cases without a rendering engine)
5. existing zip filename guessing stays as the last resort, and the old
   regex chain survives as findCoverInOPFLegacy for OPFs too malformed
   for a real XML parse.

Hrefs are now URL-decoded and posix-normalized against the OPF's own
path (path.Join semantics), so '../art/cover.jpg' from a nested cover
page and %20-encoded names resolve correctly.

Tests: attribute-order chaos modeled on the failing Patterson book,
SVG-wrapped cover pages via guide references, image-first spines, and
path resolution edge cases. Verified live against the real
'3 Days to Live' EPUB, which previously produced no cover.
2026-09-12 23:45:01 -04:00
22 changed files with 2284 additions and 249 deletions
+8
View File
@@ -347,6 +347,10 @@ type Querier interface {
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error) ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
// Items hidden from libraries: either missing from disk (waiting on the
// second scan that archives them) or already archived and pending purge.
// Feeds the admin archived-items page.
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error)
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)
@@ -366,6 +370,10 @@ type Querier interface {
ListUsers(ctx context.Context) ([]ListUsersRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk. // First consecutive scan that cannot find the file on disk.
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
// Content reappeared at a new location while the old path vanished: treat as
// a move. Repoints the row (and refreshes file-level fields) so reading
// history follows the book; archive/missing state is cleared separately.
MoveMediaItemFilePath(ctx context.Context, arg MoveMediaItemFilePathParams) error
// Manual bulk purge from the library admin page. // Manual bulk purge from the library admin page.
PurgeAllArchivedMediaItems(ctx context.Context) ([]PurgeAllArchivedMediaItemsRow, error) PurgeAllArchivedMediaItems(ctx context.Context) ([]PurgeAllArchivedMediaItemsRow, error)
// Retention sweep at library-scan time: hard-delete archived items older // Retention sweep at library-scan time: hard-delete archived items older
+200
View File
@@ -3097,6 +3097,7 @@ next_books AS (
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 mi.archived_at IS NULL WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND ($3::uuid IS NULL OR mi.library_id = $3::uuid) 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
@@ -7884,6 +7885,8 @@ 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 AND mi.archived_at IS NULL LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.missing_scan_count = 0
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
GROUP BY l.id GROUP BY l.id
` `
@@ -8415,6 +8418,174 @@ func (q *Queries) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]
return items, nil return items, nil
} }
const ListHiddenMediaItems = `-- name: ListHiddenMediaItems :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.archived_at IS NOT NULL OR mi.missing_scan_count > 0
ORDER BY mi.archived_at NULLS LAST, mi.updated_at DESC
`
type ListHiddenMediaItemsRow 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"`
}
// Items hidden from libraries: either missing from disk (waiting on the
// second scan that archives them) or already archived and pending purge.
// Feeds the admin archived-items page.
func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error) {
rows, err := q.db.Query(ctx, ListHiddenMediaItems)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListHiddenMediaItemsRow{}
for rows.Next() {
var i ListHiddenMediaItemsRow
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.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 ListLibraries = `-- name: ListLibraries :many const ListLibraries = `-- name: ListLibraries :many
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description
FROM libraries l FROM libraries l
@@ -8470,6 +8641,7 @@ 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 WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2 ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2
` `
@@ -8641,6 +8813,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
WHERE mi.library_id = $1 WHERE mi.library_id = $1
AND mi.archived_at IS NULL AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.created_at DESC ORDER BY mi.created_at DESC
` `
@@ -9156,6 +9329,7 @@ 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 WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = $1 AND mi.library_id = $1
ORDER BY ORDER BY
CASE CASE
@@ -9805,6 +9979,26 @@ func (q *Queries) MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) erro
return err return err
} }
const MoveMediaItemFilePath = `-- name: MoveMediaItemFilePath :exec
UPDATE media_items SET file_path = $2, file_size = $3, missing_scan_count = 0,
archived_at = NULL, updated_at = NOW()
WHERE id = $1
`
type MoveMediaItemFilePathParams struct {
ID pgtype.UUID `db:"id" json:"id"`
FilePath string `db:"file_path" json:"file_path"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
}
// Content reappeared at a new location while the old path vanished: treat as
// a move. Repoints the row (and refreshes file-level fields) so reading
// history follows the book; archive/missing state is cleared separately.
func (q *Queries) MoveMediaItemFilePath(ctx context.Context, arg MoveMediaItemFilePathParams) error {
_, err := q.db.Exec(ctx, MoveMediaItemFilePath, arg.ID, arg.FilePath, arg.FileSize)
return err
}
const PurgeAllArchivedMediaItems = `-- name: PurgeAllArchivedMediaItems :many const PurgeAllArchivedMediaItems = `-- name: PurgeAllArchivedMediaItems :many
DELETE FROM media_items DELETE FROM media_items
WHERE archived_at IS NOT NULL WHERE archived_at IS NOT NULL
@@ -10407,6 +10601,7 @@ 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.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -10466,6 +10661,7 @@ 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.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -10525,6 +10721,7 @@ 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.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -10582,6 +10779,7 @@ 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 mi.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -10827,6 +11025,7 @@ 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 mi.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -11112,6 +11311,7 @@ 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.archived_at IS NULL
AND mi.missing_scan_count = 0
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
+31
View File
@@ -142,6 +142,8 @@ 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 AND mi.archived_at IS NULL LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.missing_scan_count = 0
WHERE COALESCE(lv.is_visible, true) = true WHERE COALESCE(lv.is_visible, true) = true
GROUP BY l.id; GROUP BY l.id;
@@ -161,6 +163,7 @@ 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 WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -170,6 +173,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
WHERE mi.library_id = $1 WHERE mi.library_id = $1
AND mi.archived_at IS NULL AND mi.archived_at IS NULL
AND mi.missing_scan_count = 0
ORDER BY mi.created_at DESC; ORDER BY mi.created_at DESC;
-- name: ListMediaItemsByLibraryIncludingArchived :many -- name: ListMediaItemsByLibraryIncludingArchived :many
@@ -186,6 +190,7 @@ 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 WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
AND mi.library_id = sqlc.narg('library_id') AND mi.library_id = sqlc.narg('library_id')
ORDER BY ORDER BY
CASE CASE
@@ -309,6 +314,14 @@ WHERE id = $1;
UPDATE media_items SET missing_scan_count = missing_scan_count + 1, updated_at = NOW() UPDATE media_items SET missing_scan_count = missing_scan_count + 1, updated_at = NOW()
WHERE id = $1; WHERE id = $1;
-- name: MoveMediaItemFilePath :exec
-- Content reappeared at a new location while the old path vanished: treat as
-- a move. Repoints the row (and refreshes file-level fields) so reading
-- history follows the book; archive/missing state is cleared separately.
UPDATE media_items SET file_path = $2, file_size = $3, missing_scan_count = 0,
archived_at = NULL, updated_at = NOW()
WHERE id = $1;
-- name: ArchiveMediaItem :exec -- name: ArchiveMediaItem :exec
-- Second consecutive missing scan: hide the item from all browsing while -- Second consecutive missing scan: hide the item from all browsing while
-- preserving reading history in case the file returns. -- preserving reading history in case the file returns.
@@ -337,6 +350,17 @@ RETURNING id, title;
-- name: CountArchivedMediaItems :one -- name: CountArchivedMediaItems :one
SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL; SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL;
-- name: ListHiddenMediaItems :many
-- Items hidden from libraries: either missing from disk (waiting on the
-- second scan that archives them) or already archived and pending purge.
-- Feeds the admin archived-items page.
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.archived_at IS NOT NULL OR mi.missing_scan_count > 0
ORDER BY mi.archived_at NULLS LAST, mi.updated_at DESC;
-- name: DeleteMediaItem :exec -- name: DeleteMediaItem :exec
DELETE FROM media_items WHERE id = $1; DELETE FROM media_items WHERE id = $1;
@@ -486,6 +510,7 @@ 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 mi.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -559,6 +584,7 @@ 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 mi.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -693,6 +719,7 @@ 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.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -711,6 +738,7 @@ 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.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -746,6 +774,7 @@ 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.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -764,6 +793,7 @@ 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.archived_at IS NULL
AND mi.missing_scan_count = 0
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
@@ -2512,6 +2542,7 @@ next_books AS (
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 mi.archived_at IS NULL WHERE mi.archived_at IS NULL
AND mi.missing_scan_count = 0
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 (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
+24
View File
@@ -1330,6 +1330,30 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
return c.JSON(http.StatusOK, item) return c.JSON(http.StatusOK, item)
} }
// UnarchiveMediaItem handles POST /api/media-items/:id/unarchive (admin
// only). Manually restores a hidden/archived item to library visibility
// without waiting for its file to return. If the file is still gone, the
// next scan hides it again.
func (mh *MediaHandler) UnarchiveMediaItem(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.ClearMediaItemArchive(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.NoContent(http.StatusNoContent)
}
// PurgeArchivedMediaItems handles POST /api/media-items/purge-archived // PurgeArchivedMediaItems handles POST /api/media-items/purge-archived
// (admin only). Hard-deletes every archived item (files missing from disk for // (admin only). Hard-deletes every archived item (files missing from disk for
// 2+ scans) together with its reading history. The archive retention window // 2+ scans) together with its reading history. The archive retention window
+61
View File
@@ -12,6 +12,7 @@ import (
"strconv" "strconv"
"time" "time"
"bookhoard/internal/config"
"bookhoard/internal/database" "bookhoard/internal/database"
"bookhoard/internal/handlers" "bookhoard/internal/handlers"
"bookhoard/internal/services" "bookhoard/internal/services"
@@ -295,6 +296,18 @@ func registerFrontendRoutes(cfg *Config) {
libraryID := libRes.LibraryID libraryID := libRes.LibraryID
libData := libRes.Libraries libData := libRes.Libraries
// Steam Deck behavior: an offline library (all folders missing, e.g.
// an unmounted external drive) shows an empty shelf with a notice
// instead of querying items. Nothing is marked or purged.
if !libRes.IsAll {
for _, l := range libData {
if l.ID == libraryID && l.Offline {
errorMsg = l.Name + " is currently unavailable - its storage is not connected. Your books are safe and will return when the drive is reattached."
break
}
}
}
// Fetch saved filters for SSR // Fetch saved filters for SSR
userUUID, _ := uuid.Parse(user.ID) userUUID, _ := uuid.Parse(user.ID)
var savedFilters []database.SavedFilters var savedFilters []database.SavedFilters
@@ -836,6 +849,54 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
})) }))
frontendProtected.GET("/admin/archived", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
rows, err := cfg.Queries.ListHiddenMediaItems(c.Request().Context())
if err != nil {
log.Printf("ListHiddenMediaItems failed: %v", err)
rows = []database.ListHiddenMediaItemsRow{}
}
retentionDays := config.ArchiveRetentionDays()
items := make([]templates.ArchivedItem, 0, len(rows))
for _, row := range rows {
itemUUID, _ := uuid.FromBytes(row.ID.Bytes[0:16])
item := templates.ArchivedItem{
ID: itemUUID.String(),
Title: row.Title,
Author: getText(row.Author),
LibraryName: row.LibraryName,
FilePath: row.FilePath,
MissingScans: row.MissingScanCount,
}
if row.ArchivedAt.Valid {
archived := row.ArchivedAt.Time
item.ArchivedAt = &archived
switch {
case retentionDays <= 0:
item.Status = "Archived - kept until manually purged (retention disabled)"
default:
purge := archived.AddDate(0, 0, retentionDays)
item.PurgeAt = &purge
item.Status = "Archived - permanently deleted " + purge.Format("Jan 2, 2006")
}
} else {
item.Status = fmt.Sprintf("Missing from disk - archives on the next scan (%d/2)", row.MissingScanCount)
}
items = append(items, item)
}
var buf bytes.Buffer
if err := templates.AdminArchived(user, items, retentionDays).Render(c.Request().Context(), &buf); err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
}))
frontendProtected.GET("/admin/library", handlers.AdminMiddleware(func(c *echo.Context) error { frontendProtected.GET("/admin/library", handlers.AdminMiddleware(func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg) user, err := getTemplateUserWithTheme(c, cfg)
if err != nil { if err != nil {
+19
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"log" "log"
"net/url" "net/url"
"os"
"time" "time"
"bookhoard/internal/database" "bookhoard/internal/database"
@@ -132,12 +133,30 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
res.Libraries = make([]templates.LibraryData, len(libraries)) res.Libraries = make([]templates.LibraryData, len(libraries))
for i, lib := range libraries { for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
// Steam Deck SD-card behavior: a library whose folders are all
// missing (unmounted drive) renders offline - hidden contents, no
// marking, no purging - and returns when the storage does.
offline := false
folders, folderErr := cfg.Queries.GetLibraryFolders(c.Request().Context(), lib.ID)
if folderErr != nil || len(folders) == 0 {
offline = true
} else {
anyFolderExists := false
for _, folder := range folders {
if _, statErr := os.Stat(folder.FolderPath); statErr == nil {
anyFolderExists = true
break
}
}
offline = !anyFolderExists
}
res.Libraries[i] = templates.LibraryData{ res.Libraries[i] = templates.LibraryData{
ID: libUUID.String(), ID: libUUID.String(),
Name: lib.Name, Name: lib.Name,
Description: getText(lib.Description), Description: getText(lib.Description),
TypeName: lib.TypeName, TypeName: lib.TypeName,
MediaCount: countMap[libUUID.String()], MediaCount: countMap[libUUID.String()],
Offline: offline,
} }
} }
+1
View File
@@ -63,6 +63,7 @@ func registerMediaRoutes(cfg *Config) {
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem) 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) admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem)
// Shelf management (protected) // Shelf management (protected)
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf) protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
+321 -78
View File
@@ -23,8 +23,10 @@ import (
_ "image/png" _ "image/png"
"io" "io"
"io/fs" "io/fs"
"net/url"
"os" "os"
"os/exec" "os/exec"
"path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
@@ -793,6 +795,39 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
LibraryID: libraryID, LibraryID: libraryID,
}) })
if err == nil && existingByHash.ID.Valid { if err == nil && existingByHash.ID.Valid {
// Same content at a different path. If the old location is gone,
// the book was MOVED: repoint the row so history follows it and
// the archive pass doesn't cycle it into missing/archived. Only
// treat it as a duplicate copy when the old path still exists.
oldPathStillExists := false
for _, folder := range s.folders {
if _, statErr := os.Stat(filepath.Join(folder, existingByHash.FilePath)); statErr == nil {
oldPathStillExists = true
break
}
}
if !oldPathStillExists {
if err := s.db.MoveMediaItemFilePath(ctx, database.MoveMediaItemFilePathParams{
ID: existingByHash.ID,
FilePath: s.getRelativePath(path),
FileSize: pgtype.Int8{Int64: info.Size(), Valid: true},
}); err != nil {
fmt.Printf("Warning: failed to repoint moved media item %s -> %s: %v\n", existingByHash.FilePath, path, err)
} else {
fmt.Printf("[MOVE] Item content moved: %q -> %s (history preserved)\n", existingByHash.FilePath, path)
s.logger.LogDelete(fmt.Sprintf("[MOVE] Repointed item '%s' from %q to %s", existingByHash.Title, existingByHash.FilePath, path))
}
if s.forceRescan {
moved := existingByHash
moved.FilePath = s.getRelativePath(path)
moved.ArchivedAt = pgtype.Timestamptz{}
moved.MissingScanCount = 0
_ = s.updateMediaItem(ctx, moved, path)
}
return false, nil
}
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. // Content returned (possibly at a new path): restore archived rows.
@@ -1005,6 +1040,27 @@ func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
return metadata return metadata
} }
// applyEmbeddedCoverFallback extracts a cover from the media file itself when
// a metadata sidecar (metadata.opf / metadata.json) supplied the metadata but
// no sidecar cover (cover.jpg etc.) exists. Without this, a full rescan would
// clear cover_image_path for sidecar-managed PDFs and EPUBs - mergeMetadata
// has no PDF/EPUB cover logic of its own.
func (s *MediaScanner) applyEmbeddedCoverFallback(path string, metadata *MediaMetadata) {
if metadata.CoverPath != "" {
return
}
switch strings.ToLower(filepath.Ext(path)) {
case ".pdf":
if coverPath, err := s.extractPDFCover(path); err == nil && coverPath != "" {
metadata.CoverPath = s.getRelativePath(coverPath)
}
case ".epub", ".kepub":
if coverPath, err := s.extractEPUBCover(path); err == nil && coverPath != "" {
metadata.CoverPath = s.getRelativePath(coverPath)
}
}
}
// extractAudiobookshelfSidecar checks for and parses an Audiobookshelf-style // extractAudiobookshelfSidecar checks for and parses an Audiobookshelf-style
// metadata.json sidecar next to the media file. Only fields with a matching // metadata.json sidecar next to the media file. Only fields with a matching
// media_items column are mapped; narrators, subtitle, explicit, abridged and // media_items column are mapped; narrators, subtitle, explicit, abridged and
@@ -1023,6 +1079,7 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
var sidecar struct { var sidecar struct {
Title string `json:"title"` Title string `json:"title"`
Subtitle string `json:"subtitle"`
Authors []string `json:"authors"` Authors []string `json:"authors"`
Series []struct { Series []struct {
Series string `json:"series"` Series string `json:"series"`
@@ -1046,6 +1103,11 @@ func extractAudiobookshelfSidecar(path string) *MediaMetadata {
metadata := &MediaMetadata{ metadata := &MediaMetadata{
Title: strings.TrimSpace(sidecar.Title), 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 { if len(sidecar.Authors) > 0 {
metadata.Author = strings.TrimSpace(sidecar.Authors[0]) metadata.Author = strings.TrimSpace(sidecar.Authors[0])
} }
@@ -1099,7 +1161,7 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
ext := strings.ToLower(filepath.Ext(path)) ext := strings.ToLower(filepath.Ext(path))
// For EPUB files // For EPUB files
if ext == ".epub" { if ext == ".epub" || ext == ".kepub" {
book, err := epub.ReadBook(path) book, err := epub.ReadBook(path)
if err == nil { if err == nil {
genreTags := extractGenreTagsFromEPUB(book) genreTags := extractGenreTagsFromEPUB(book)
@@ -1115,6 +1177,76 @@ func (s *MediaScanner) mergeMetadata(path string, calibreMetadata *MediaMetadata
metadata.PageCount = int32(pageCount) metadata.PageCount = int32(pageCount)
} }
} }
// Gap-fill: sidecar-sourced metadata wins, but blanks are filled from
// the book's own OPF so a sparse metadata.opf/metadata.json doesn't
// hide data the file carries. Never overwrites sidecar values.
if embedded, err := s.extractEPUBMetadata(path); err == nil && embedded != nil {
if metadata.Title == "" && embedded.Title != "" {
metadata.Title = embedded.Title
}
if metadata.Author == "" && embedded.Author != "" {
metadata.Author = embedded.Author
}
if metadata.Description == "" && embedded.Description != "" {
metadata.Description = embedded.Description
}
if metadata.Publisher == "" && embedded.Publisher != "" {
metadata.Publisher = embedded.Publisher
}
if metadata.Language == "" && embedded.Language != "" {
metadata.Language = embedded.Language
}
if metadata.ISBN == "" && embedded.ISBN != "" {
metadata.ISBN = embedded.ISBN
}
if metadata.ASIN == "" && embedded.ASIN != "" {
metadata.ASIN = embedded.ASIN
}
if metadata.Series == "" && embedded.Series != "" {
metadata.Series = embedded.Series
}
if metadata.SeriesNumber == 0 && embedded.SeriesNumber != 0 {
metadata.SeriesNumber = embedded.SeriesNumber
}
if metadata.ReadingDirection == "" && embedded.ReadingDirection != "" {
metadata.ReadingDirection = embedded.ReadingDirection
}
if metadata.PublishDate.IsZero() && !embedded.PublishDate.IsZero() {
metadata.PublishDate = embedded.PublishDate
}
if len(metadata.Tags) == 0 && len(embedded.Tags) > 0 {
metadata.Tags = embedded.Tags
}
if len(metadata.Contributors) == 0 && len(embedded.Contributors) > 0 {
metadata.Contributors = embedded.Contributors
}
}
}
// For PDFs: fill blanks from the embedded Info dictionary. Same rule as
// EPUBs - sidecar values win, the file only fills gaps.
if ext == ".pdf" {
if embedded, err := s.readPDFInfoDict(path); err == nil && embedded != nil {
if metadata.Title == "" && embedded.Title != "" {
metadata.Title = embedded.Title
}
if metadata.Author == "" && embedded.Author != "" {
metadata.Author = embedded.Author
}
if metadata.Description == "" && embedded.Description != "" {
metadata.Description = embedded.Description
}
if metadata.Publisher == "" && embedded.Publisher != "" {
metadata.Publisher = embedded.Publisher
}
if len(metadata.Tags) == 0 && len(embedded.Tags) > 0 {
metadata.Tags = embedded.Tags
}
if metadata.PageCount == 0 && embedded.PageCount > 0 {
metadata.PageCount = embedded.PageCount
}
}
} }
// For comic archives, try to extract ComicInfo.xml // For comic archives, try to extract ComicInfo.xml
@@ -1406,6 +1538,7 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
if coverPath != "" { if coverPath != "" {
calibreMetadata.CoverPath = s.getRelativePath(coverPath) calibreMetadata.CoverPath = s.getRelativePath(coverPath)
} }
s.applyEmbeddedCoverFallback(path, calibreMetadata)
return s.mergeMetadata(path, calibreMetadata) return s.mergeMetadata(path, calibreMetadata)
} }
@@ -1420,6 +1553,7 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
if coverPath != "" { if coverPath != "" {
abMetadata.CoverPath = s.getRelativePath(coverPath) abMetadata.CoverPath = s.getRelativePath(coverPath)
} }
s.applyEmbeddedCoverFallback(path, abMetadata)
return s.mergeMetadata(path, abMetadata) return s.mergeMetadata(path, abMetadata)
} }
@@ -1719,80 +1853,81 @@ func (s *MediaScanner) parseCalibreMetadataOPF(opfPath string) (*MediaMetadata,
// parseOPFContent parses an OPF document (Dublin Core metadata) into // parseOPFContent parses an OPF document (Dublin Core metadata) into
// MediaMetadata. Used for both Calibre metadata.opf sidecars and the OPF // MediaMetadata. Used for both Calibre metadata.opf sidecars and the OPF
// embedded inside an EPUB - the dc:* vocabulary is identical. Namespace-aware // embedded inside an EPUB - the dc:* vocabulary is identical. Parsing is
// parsing means it tolerates wherever the xmlns:dc declaration lives. // 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) { func parseOPFContent(content []byte) (*MediaMetadata, error) {
// Define XML structure for parsing with full Dublin Core namespace URLs opf, err := parseOPFXML(content)
var opf struct { if err != nil {
XMLName xml.Name `xml:"package"`
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
if err := xml.NewDecoder(bytes.NewReader(content)).Decode(&opf); err != nil {
return nil, fmt.Errorf("failed to parse OPF XML: %v", err) 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
}
// Reading direction from the OPF spine's page-progression-direction.
// Sidecar OPFs are metadata-only documents without a spine, so this is a
// no-op for them and only fires on real books.
if dir := opf.pageProgressionDirection(); dir != "" {
metadata.ReadingDirection = dir
} }
// 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 // Language
if len(opf.Metadata.Language) > 0 && opf.Metadata.Language[0] != "" { if len(md.Languages) > 0 && md.Languages[0] != "" {
metadata.Language = opf.Metadata.Language[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.
for _, id := range md.Identifiers {
value := strings.TrimSpace(id.Value) value := strings.TrimSpace(id.Value)
switch strings.ToUpper(id.Scheme) { 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(value) metadata.ISBN = utils.NormalizeISBNSafe(value)
case "ASIN": case "ASIN":
@@ -1801,28 +1936,30 @@ func parseOPFContent(content []byte) (*MediaMetadata, error) {
// 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: default:
// EPUB3 identifiers often carry no opf:scheme attribute; // EPUB3 identifiers often carry no opf:scheme attribute; accept a
// accept a bare value that normalizes to a valid ISBN. // bare value only when it is ISBN-shaped (rejects the URIs and
if metadata.ISBN == "" && id.Scheme == "" { // UUIDs that commonly share the identifier list).
if normalized := utils.NormalizeISBNSafe(value); normalized != "" { if metadata.ISBN == "" && scheme == "" && isISBNLike(value) {
if normalized, err := utils.NormalizeISBN(value); err == nil {
metadata.ISBN = normalized 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":
@@ -1949,7 +2086,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"
@@ -1961,7 +2138,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])
} }
} }
@@ -1975,7 +2152,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])
} }
} }
} }
@@ -1984,18 +2161,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
@@ -2109,6 +2302,51 @@ func findSidecarCover(mediaPath string) string {
return "" return ""
} }
// readPDFInfoDict reads a PDF's embedded Info dictionary into MediaMetadata,
// using the same field conventions as extractPDFMetadata (creator falls back
// to author, subject maps to description, producer to publisher, keywords to
// tags). No cover or filename logic - purely the document's own metadata, for
// filling sidecar gaps in mergeMetadata.
func (s *MediaScanner) readPDFInfoDict(path string) (*MediaMetadata, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer func() {
if err := f.Close(); err != nil {
fmt.Printf("Warning: failed to close PDF file %s: %v\n", path, err)
}
}()
pdfInfo, err := pdfcpuapi.PDFInfo(f, filepath.Base(path), nil, false, nil)
if err != nil {
return nil, err
}
metadata := &MediaMetadata{}
if pdfInfo.Title != "" {
metadata.Title = pdfInfo.Title
}
if pdfInfo.Author != "" {
metadata.Author = pdfInfo.Author
} else if pdfInfo.Creator != "" {
metadata.Author = pdfInfo.Creator
}
if pdfInfo.Subject != "" {
metadata.Description = pdfInfo.Subject
}
if pdfInfo.Producer != "" {
metadata.Publisher = pdfInfo.Producer
}
if len(pdfInfo.Keywords) > 0 {
metadata.Tags = utils.NormalizeTags(pdfInfo.Keywords)
}
if pdfInfo.PageCount > 0 {
metadata.PageCount = int32(pdfInfo.PageCount)
}
return metadata, nil
}
func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) { func (s *MediaScanner) extractPDFMetadata(path string) (*MediaMetadata, error) {
metadata := &MediaMetadata{} metadata := &MediaMetadata{}
@@ -2786,8 +3024,10 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
} }
// Keep user-customized fields, and keep the override set itself intact. // Keep user-customized fields, and keep the override set itself intact.
// MergeOverrides guarantees a non-nil slice: a nil []string would encode
// as SQL NULL and violate metadata_overrides' NOT NULL constraint.
utils.ApplyMetadataOverrides(&params, existing) utils.ApplyMetadataOverrides(&params, existing)
params.MetadataOverrides = existing.MetadataOverrides params.MetadataOverrides = utils.MergeOverrides(existing.MetadataOverrides)
_, err = s.db.UpdateMediaItem(ctx, params) _, err = s.db.UpdateMediaItem(ctx, params)
return err return err
@@ -2808,7 +3048,10 @@ func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.U
if err := s.db.ClearMediaItemMetadataOverrides(ctx, mediaItemID); err != nil { if err := s.db.ClearMediaItemMetadataOverrides(ctx, mediaItemID); err != nil {
return fmt.Errorf("failed to clear metadata overrides: %w", err) return fmt.Errorf("failed to clear metadata overrides: %w", err)
} }
item.MetadataOverrides = nil // Empty - not nil: pgx encodes a nil []string parameter as SQL NULL,
// which would violate the column's NOT NULL constraint when
// updateMediaItem writes the row back.
item.MetadataOverrides = []string{}
} }
folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID) folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID)
@@ -3,6 +3,7 @@ package services
import ( import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"fmt"
"image" "image"
"image/jpeg" "image/jpeg"
"os" "os"
@@ -230,3 +231,180 @@ func TestExtractAudiobookshelfSidecar(t *testing.T) {
} }
}) })
} }
// createTestPDFWithImage builds a minimal one-page PDF embedding a JPEG
// XObject, so pdfcpu-based cover extraction has an image to find.
func createTestPDFWithImage() []byte {
jpegData := []byte(createTestJPEGBytes())
content := "q 100 0 0 100 0 0 cm /Im0 Do Q"
var buf bytes.Buffer
offsets := []int{0} // object numbers are 1-based
buf.WriteString("%PDF-1.4\n")
writeObj := func(n int, body func(w *bytes.Buffer)) {
offsets = append(offsets, buf.Len())
fmt.Fprintf(&buf, "%d 0 obj\n", n)
body(&buf)
buf.WriteString("endobj\n")
}
writeObj(1, func(w *bytes.Buffer) { w.WriteString("<< /Type /Catalog /Pages 2 0 R >>\n") })
writeObj(2, func(w *bytes.Buffer) { w.WriteString("<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n") })
writeObj(3, func(w *bytes.Buffer) {
w.WriteString("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\n")
})
writeObj(4, func(w *bytes.Buffer) {
fmt.Fprintf(w, "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n", len(jpegData))
w.Write(jpegData)
w.WriteString("\nendstream\n")
})
writeObj(5, func(w *bytes.Buffer) {
fmt.Fprintf(w, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content)
})
writeObj(6, func(w *bytes.Buffer) {
// Info dictionary deliberately richer than the sidecars in gap-fill
// tests: gap-fill must use these, never overwrite with them.
w.WriteString("<< /Title (Embedded Title) /Author (Embedded Author) /Subject (Embedded Subject) /Producer (Embedded Producer) /Keywords (embedded-kw) >>\n")
})
xrefStart := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
for _, off := range offsets[1:] {
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R /Info 6 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xrefStart)
return buf.Bytes()
}
// TestSidecarCoverFallback guards the regression where a metadata.json
// sidecar (no cover.jpg next to the book) made extractMetadata return an
// empty CoverPath for PDFs - mergeMetadata has no PDF/EPUB cover logic, so a
// full rescan wiped cover_image_path for sidecar-managed books.
func TestSidecarCoverFallback(t *testing.T) {
dir := t.TempDir()
pdfPath := filepath.Join(dir, "book.pdf")
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(`{"title":"Sidecar Book","authors":["A"]}`), 0644); err != nil {
t.Fatal(err)
}
s := NewMediaScanner(nil)
s.folders = []string{dir} // SetFolders requires a live DB; tests only need path relativization
metadata, err := s.extractMetadata(pdfPath)
if err != nil {
t.Fatalf("extractMetadata() error: %v", err)
}
if metadata.Title != "Sidecar Book" {
t.Errorf("Title = %q, want sidecar title", metadata.Title)
}
if metadata.CoverPath == "" {
t.Fatal("CoverPath empty - sidecar branch skipped embedded cover extraction")
}
if _, err := os.Stat(filepath.Join(dir, metadata.CoverPath)); err != nil {
t.Errorf("extracted cover not on disk: %v", err)
}
}
// TestMergeMetadataEPUBGapFill verifies that a sparse sidecar (metadata.opf
// with only a title) gets its blanks filled from the book's own embedded OPF
// while sidecar-set fields are never overwritten.
func TestMergeMetadataEPUBGapFill(t *testing.T) {
dir := t.TempDir()
epubPath := filepath.Join(dir, "gappy.epub")
f, err := os.Create(epubPath)
if err != nil {
t.Fatal(err)
}
zipWriter := zip.NewWriter(f)
mimetype, _ := zipWriter.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
mimetype.Write([]byte("application/epub+zip"))
epubFiles := 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>`,
"OEBPS/content.opf": `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="u">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>Embedded Title</dc:title>
<dc:creator>Embedded Author</dc:creator>
<dc:description>Embedded description from the book.</dc:description>
<dc:publisher>Embedded Publisher</dc:publisher>
<dc:language>en</dc:language>
<dc:date>2021-06-01</dc:date>
</metadata>
<manifest/>
<spine/>
</package>`,
}
for name, content := range epubFiles {
w, err := zipWriter.Create(name)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(content))
}
if err := zipWriter.Close(); err != nil {
t.Fatal(err)
}
f.Close()
sparse := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author"}
s := NewMediaScanner(nil)
merged, err := s.mergeMetadata(epubPath, sparse)
if err != nil {
t.Fatalf("mergeMetadata() error: %v", err)
}
if merged.Title != "Sidecar Title" || merged.Author != "Sidecar Author" {
t.Errorf("sidecar values overwritten: title=%q author=%q", merged.Title, merged.Author)
}
if merged.Description != "Embedded description from the book." {
t.Errorf("Description = %q, want embedded fill", merged.Description)
}
if merged.Publisher != "Embedded Publisher" {
t.Errorf("Publisher = %q, want embedded fill", merged.Publisher)
}
if merged.Language != "en" {
t.Errorf("Language = %q, want embedded fill", merged.Language)
}
}
// TestMergeMetadataPDFGapFill verifies the same for PDFs: a sparse sidecar
// keeps its title while author/description/publisher/tags/pagecount fill in
// from the embedded Info dictionary.
func TestMergeMetadataPDFGapFill(t *testing.T) {
dir := t.TempDir()
pdfPath := filepath.Join(dir, "gappy.pdf")
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
t.Fatal(err)
}
sparse := &MediaMetadata{Title: "Kept Title"}
s := NewMediaScanner(nil)
merged, err := s.mergeMetadata(pdfPath, sparse)
if err != nil {
t.Fatalf("mergeMetadata() error: %v", err)
}
checks := []struct {
name string
got string
want string
}{
{"Title (sidecar wins)", merged.Title, "Kept Title"},
{"Author", merged.Author, "Embedded Author"},
{"Description", merged.Description, "Embedded Subject"},
{"Publisher", merged.Publisher, "Embedded Producer"},
}
for _, c := range checks {
if c.got != c.want {
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
}
}
if len(merged.Tags) == 0 {
t.Error("Tags empty, want keywords from Info dict")
}
if merged.PageCount != 1 {
t.Errorf("PageCount = %d, want 1 from Info dict", merged.PageCount)
}
}
+344
View File
@@ -0,0 +1,344 @@
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 {
PageProgressionDirection string `xml:"page-progression-direction,attr"`
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
}
// pageProgressionDirection returns the OPF spine's reading direction as
// "rtl" or "ltr", or "" when the file declares none (callers treat that as
// unknown, not as left-to-right). EPUB2/3 declare this on <spine>; it is
// what foliate reads client-side, and the DB column feeds clients (and the
// web reader's fixed-layout override) that need it up front.
func (d *opfDocument) pageProgressionDirection() string {
switch strings.ToLower(strings.TrimSpace(d.Spine.PageProgressionDirection)) {
case "rtl", "right-to-left":
return "rtl"
case "ltr", "left-to-right", "default":
return "ltr"
default:
return ""
}
}
// 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)
}
}
}
}
}
+297
View File
@@ -0,0 +1,297 @@
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)
}
}
}
// TestParseOPFContentPageProgressionDirection verifies the EPUB spine's
// page-progression-direction feeds ReadingDirection, and that an undeclared
// direction stays empty rather than forcing left-to-right.
func TestParseOPFContentPageProgressionDirection(t *testing.T) {
makeOPF := func(spineAttrs string) string {
return `<?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>Dir Test</dc:title></metadata>
<manifest><item id="p1" href="p1.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine` + spineAttrs + `><itemref idref="p1"/></spine>
</package>`
}
tests := []struct {
name string
spineAttrs string
want string
}{
{"rtl declared lowercase", ` page-progression-direction="rtl"`, "rtl"},
{"rtl declared uppercase", ` page-progression-direction="RTL"`, "rtl"},
{"ltr declared", ` page-progression-direction="ltr"`, "ltr"},
{"undeclared stays empty", ``, ""},
{"unknown value stays empty", ` page-progression-direction="sideways"`, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
metadata, err := parseOPFContent([]byte(makeOPF(tt.spineAttrs)))
if err != nil {
t.Fatalf("parseOPFContent() error: %v", err)
}
if metadata.ReadingDirection != tt.want {
t.Errorf("ReadingDirection = %q, want %q", metadata.ReadingDirection, tt.want)
}
})
}
}
// TestMergeMetadataReadingDirectionGapFill verifies the sidecar wins when it
// declares a direction, while an embedded-only direction fills the blank.
func TestMergeMetadataReadingDirectionGapFill(t *testing.T) {
dir := t.TempDir()
epubPath := filepath.Join(dir, "dir.epub")
files := map[string]string{
"META-INF/container.xml": containerXML,
"OEBPS/package.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>RTL Book</dc:title></metadata>
<manifest><item id="p1" href="p1.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine page-progression-direction="rtl"><itemref idref="p1"/></spine>
</package>`,
"OEBPS/p1.xhtml": `<html><body><p>hi</p></body></html>`,
}
writeEPUB(t, epubPath, files)
s := NewMediaScanner(nil)
// Sidecar blank -> embedded rtl fills it
merged, err := s.mergeMetadata(epubPath, &MediaMetadata{Title: "Sidecar"})
if err != nil {
t.Fatalf("mergeMetadata() error: %v", err)
}
if merged.ReadingDirection != "rtl" {
t.Errorf("ReadingDirection = %q, want embedded rtl fill", merged.ReadingDirection)
}
// Sidecar ltr wins over embedded rtl
merged, err = s.mergeMetadata(epubPath, &MediaMetadata{Title: "Sidecar", ReadingDirection: "ltr"})
if err != nil {
t.Fatalf("mergeMetadata() error: %v", err)
}
if merged.ReadingDirection != "ltr" {
t.Errorf("ReadingDirection = %q, want sidecar ltr preserved", merged.ReadingDirection)
}
}
+103
View File
@@ -0,0 +1,103 @@
package templates
templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Archived Items - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/admin/archived")
<main class="p-8">
<div class="max-w-5xl">
<div class="mb-8">
<div class="flex items-center justify-between gap-4 flex-wrap">
<div>
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("library", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Archived Items</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">
Books hidden because their files are missing from disk. Reading history is
kept in case a file returns; moves are detected automatically.
if retentionDays > 0 {
Archived items are permanently deleted after { retentionDays } days
(ARCHIVE_RETENTION_DAYS).
} else {
Archived items are kept until manually purged (retention disabled).
}
</p>
</div>
if len(items) > 0 {
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary">
@Icon("trash", "h-4 w-4")
Purge All Archived
</button>
}
</div>
</div>
if len(items) == 0 {
<div class="card p-8 text-center">
<p style="color: var(--text-secondary)">Nothing is archived or missing. All clear.</p>
</div>
} else {
<div class="card overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left" style="border-color: var(--border); color: var(--text-secondary);">
<th class="px-4 py-3 font-semibold">Book</th>
<th class="px-4 py-3 font-semibold">Library</th>
<th class="px-4 py-3 font-semibold">Status</th>
<th class="px-4 py-3 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody>
for _, item := range items {
<tr class="border-b" style="border-color: color-mix(in srgb, var(--text-primary) 5%, transparent);">
<td class="px-4 py-3">
<p class="font-medium" style="color: var(--text-primary)">{ item.Title }</p>
if item.Author != "" {
<p style="color: var(--text-secondary)">{ item.Author }</p>
}
<p class="font-mono text-xs break-all" style="color: var(--text-secondary)">{ item.FilePath }</p>
</td>
<td class="px-4 py-3" style="color: var(--text-primary)">{ item.LibraryName }</td>
<td class="px-4 py-3" style="color: var(--text-secondary)">{ item.Status }</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<button
type="button"
data-unarchive={ item.ID }
class="btn btn-ghost text-xs"
title="Restore to libraries now; if the file is still gone the next scan hides it again"
>
@Icon("refresh", "h-3.5 w-3.5")
Restore
</button>
<button
type="button"
data-delete-archived={ item.ID }
class="btn btn-ghost text-xs"
title="Delete this item and its reading history permanently"
>
@Icon("trash", "h-3.5 w-3.5")
Delete
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</main>
</body>
</html>
}
+244
View File
@@ -0,0 +1,244 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.1020
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Archived Items - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Header(user, "/admin/archived").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"max-w-5xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Archived Items</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Books hidden because their files are missing from disk. Reading history is kept in case a file returns; moves are detected automatically. ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if retentionDays > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Archived items are permanently deleted after ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 30, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " days (ARCHIVE_RETENTION_DAYS).")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Archived items are kept until manually purged (retention disabled).")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(items) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<button type=\"button\" onclick=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "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, 9, "Purge All Archived</button>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if len(items) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card p-8 text-center\"><p style=\"color: var(--text-secondary)\">Nothing is archived or missing. All clear.</p></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"card overflow-hidden\"><table class=\"w-full text-sm\"><thead><tr class=\"border-b text-left\" style=\"border-color: var(--border); color: var(--text-secondary);\"><th class=\"px-4 py-3 font-semibold\">Book</th><th class=\"px-4 py-3 font-semibold\">Library</th><th class=\"px-4 py-3 font-semibold\">Status</th><th class=\"px-4 py-3 font-semibold text-right\">Actions</th></tr></thead> <tbody>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, item := range items {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<tr class=\"border-b\" style=\"border-color: color-mix(in srgb, var(--text-primary) 5%, transparent);\"><td class=\"px-4 py-3\"><p class=\"font-medium\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 65, Col: 82}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if item.Author != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 67, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</p>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<p class=\"font-mono text-xs break-all\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 69, Col: 103}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</p></td><td class=\"px-4 py-3\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 71, Col: 86}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"px-4 py-3\" style=\"color: var(--text-secondary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 72, Col: 83}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</td><td class=\"px-4 py-3 text-right whitespace-nowrap\"><button type=\"button\" data-unarchive=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 76, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" class=\"btn btn-ghost text-xs\" title=\"Restore to libraries now; if the file is still gone the next scan hides it again\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("refresh", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "Restore</button> <button type=\"button\" data-delete-archived=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 85, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"btn btn-ghost text-xs\" title=\"Delete this item and its reading history permanently\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("trash", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Delete</button></td></tr>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</tbody></table></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</div></main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+5
View File
@@ -38,18 +38,23 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCou
<div class="card p-4 mb-6 flex items-center justify-between gap-4 flex-wrap"> <div class="card p-4 mb-6 flex items-center justify-between gap-4 flex-wrap">
<div> <div>
<p class="font-semibold" style="color: var(--text-primary)"> <p class="font-semibold" style="color: var(--text-primary)">
<a href="/admin/archived" class="hover:underline" style="color: var(--text-primary); text-decoration: none;">
Archived items: { archivedCount } Archived items: { archivedCount }
</a>
</p> </p>
<p class="text-sm" style="color: var(--text-secondary)"> <p class="text-sm" style="color: var(--text-secondary)">
Files missing from disk for two consecutive scans. Reading history is kept Files missing from disk for two consecutive scans. Reading history is kept
unless purged; items return automatically if their files come back. unless purged; items return automatically if their files come back.
</p> </p>
</div> </div>
<div class="flex items-center gap-2">
<a href="/admin/archived" class="btn btn-secondary">View Archived</a>
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary"> <button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary">
@Icon("trash", "h-4 w-4") @Icon("trash", "h-4 w-4")
Purge Archived Now Purge Archived Now
</button> </button>
</div> </div>
</div>
} }
<div id="libraries-container"> <div id="libraries-container">
@LibraryList(user, libraries, users) @LibraryList(user, libraries, users)
+38 -38
View File
@@ -58,20 +58,20 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if archivedCount > 0 { if archivedCount > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<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: ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<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)\"><a href=\"/admin/archived\" class=\"hover:underline\" style=\"color: var(--text-primary); text-decoration: none;\">Archived items: ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(archivedCount) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(archivedCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 41, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 42, Col: 41}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</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\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</a></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><div class=\"flex items-center gap-2\"><a href=\"/admin/archived\" class=\"btn btn-secondary\">View Archived</a> <button type=\"button\" onclick=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -79,7 +79,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Purge Archived Now</button></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "Purge Archived Now</button></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -211,7 +211,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 224, Col: 92} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 229, Col: 92}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -229,7 +229,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 226, Col: 95} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 231, Col: 95}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -251,7 +251,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 231, Col: 26} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 236, Col: 26}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -273,7 +273,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 236, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 241, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -291,7 +291,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan") templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 242, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 247, Col: 58}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -312,7 +312,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 252, Col: 32} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 257, Col: 32}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -333,7 +333,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 261, Col: 44} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 266, Col: 44}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -408,7 +408,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath) templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 283, Col: 99} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 288, Col: 99}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -421,7 +421,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders") templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 286, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 291, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -434,7 +434,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"folder_path": "` + folder.FolderPath + `"}`) templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"folder_path": "` + folder.FolderPath + `"}`)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 287, Col: 64} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 292, Col: 64}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -447,7 +447,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID) templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 288, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 293, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -478,7 +478,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders") templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 298, Col: 80} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 303, Col: 80}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -491,7 +491,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 298, Col: 124} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 303, Col: 124}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -504,7 +504,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var18 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 302, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 307, Col: 37}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -517,7 +517,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 309, Col: 32} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 314, Col: 32}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -530,7 +530,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 310, Col: 52} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 315, Col: 52}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -587,7 +587,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility") templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 339, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 344, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -600,7 +600,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var22 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 340, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 345, Col: 47}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -613,7 +613,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID) templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 342, Col: 49} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 347, Col: 49}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -626,7 +626,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var24 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username) templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 345, Col: 76} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 350, Col: 76}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -639,7 +639,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var25 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email) templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 346, Col: 75} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 351, Col: 75}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -667,7 +667,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var26 templ.SafeURL var templ_7745c5c3_Var26 templ.SafeURL
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues") templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 355, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 360, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -694,7 +694,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var27 string var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount) templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 360, Col: 24} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 365, Col: 24}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -721,7 +721,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var28 string var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 369, Col: 28} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 374, Col: 28}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -734,7 +734,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var29 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name) templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 370, Col: 33} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 375, Col: 33}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -747,7 +747,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var30 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 371, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 376, Col: 40}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -768,7 +768,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var31 string var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID) templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 379, Col: 30} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 384, Col: 30}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -781,7 +781,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
var templ_7745c5c3_Var32 string var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name) templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 380, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 385, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -839,7 +839,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var34 string var templ_7745c5c3_Var34 string
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath) templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 395, Col: 89} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 400, Col: 89}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -857,7 +857,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var35 string var templ_7745c5c3_Var35 string
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID) templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 401, Col: 117} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 406, Col: 117}
} }
_, 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 {
@@ -888,7 +888,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var36 string var templ_7745c5c3_Var36 string
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID) templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 414, Col: 118} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 419, Col: 118}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -909,7 +909,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var37 string var templ_7745c5c3_Var37 string
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name) templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 419, Col: 40} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 424, Col: 40}
} }
_, 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 {
@@ -927,7 +927,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var38 string var templ_7745c5c3_Var38 string
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput) templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 426, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 431, Col: 35}
} }
_, 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 {
@@ -940,7 +940,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
var templ_7745c5c3_Var39 string var templ_7745c5c3_Var39 string
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath) templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 427, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 432, Col: 35}
} }
_, 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 {
+6
View File
@@ -149,7 +149,13 @@ templ BookShelf(
<option value="">All Books ({ TotalMediaCount(libraries) })</option> <option value="">All Books ({ TotalMediaCount(libraries) })</option>
} }
for _, lib := range libraries { for _, lib := range libraries {
if lib.Offline {
if lib.ID == currentLibraryID { if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) storage offline</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount }) storage offline</option>
}
} else if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option> <option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else { } else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option> <option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
+130 -40
View File
@@ -203,6 +203,7 @@ func BookShelf(
} }
} }
for _, lib := range libraries { for _, lib := range libraries {
if lib.Offline {
if lib.ID == currentLibraryID { if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -211,7 +212,7 @@ func BookShelf(
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -224,7 +225,7 @@ func BookShelf(
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 57} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 57}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -237,13 +238,13 @@ func BookShelf(
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 77} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 154, Col: 77}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ")</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ") — storage offline</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -255,7 +256,7 @@ func BookShelf(
var templ_7745c5c3_Var9 string var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 35} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 156, Col: 35}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -268,7 +269,7 @@ func BookShelf(
var templ_7745c5c3_Var10 string var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 48} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 156, Col: 48}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -281,20 +282,109 @@ func BookShelf(
var templ_7745c5c3_Var11 string var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 156, Col: 68}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
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, 28, ")</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ") — storage offline</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" selected>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 56}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 159, Col: 76}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ")</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 34}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 161, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ")</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><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\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(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><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><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\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(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><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -303,53 +393,53 @@ func BookShelf(
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if errorMessage != "" { if errorMessage != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var12 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 269, Col: 59} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 275, Col: 59}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
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, 31, "</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if count > 0 { if count > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit)) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 278, Col: 126} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 284, Col: 126}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if offset <= 0 { if offset <= 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " disabled") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " disabled")
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, 36, ">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -357,43 +447,43 @@ func BookShelf(
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, 37, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string var templ_7745c5c3_Var20 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1) templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 287, Col: 32} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 293, Col: 32}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
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, 38, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string var templ_7745c5c3_Var21 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit)) templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 292, Col: 126} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 298, Col: 126}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if offset+limit >= count { if offset+limit >= count {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " disabled") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " disabled")
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, 41, "><span>Next</span>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "><span>Next</span>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -401,12 +491,12 @@ func BookShelf(
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, 42, "</button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</button>")
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, 43, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -414,7 +504,7 @@ func BookShelf(
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, 44, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+6
View File
@@ -15,7 +15,13 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
> >
<option value="">All Libraries ({ TotalMediaCount(libData) })</option> <option value="">All Libraries ({ TotalMediaCount(libData) })</option>
for _, lib := range libData { for _, lib := range libData {
if lib.Offline {
if lib.ID == currentLibraryID { if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) storage offline</option>
} else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount }) storage offline</option>
}
} else if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option> <option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
} else { } else {
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option> <option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
+109 -19
View File
@@ -36,7 +36,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var2 string var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData)) templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 63} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 16, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -47,6 +47,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
for _, lib := range libData { for _, lib := range libData {
if lib.Offline {
if lib.ID == currentLibraryID { if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -55,7 +56,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var3 string var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 20, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -68,7 +69,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var4 string var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 51} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 20, Col: 51}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -81,13 +82,13 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var5 string var templ_7745c5c3_Var5 string
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 20, Col: 71}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ")</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ") — storage offline</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -99,7 +100,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var6 string var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID) templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 29} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 22, Col: 29}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -112,7 +113,7 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var7 string var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name) templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 42} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 22, Col: 42}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
@@ -125,24 +126,113 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
var templ_7745c5c3_Var8 string var templ_7745c5c3_Var8 string
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount) templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 21, Col: 62} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 22, Col: 62}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ")</option>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ") — storage offline</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
} else if lib.ID == currentLibraryID {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var9 string
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 25, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" selected>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var10 string
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 25, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var11 string
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 25, Col: 70}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ")</option>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<option value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var12 string
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 27, Col: 28}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 27, Col: 41}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 27, Col: 61}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, ")</option>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</select></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</select></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(actions) > 0 { if len(actions) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex items-center gap-2\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"flex items-center gap-2\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -152,12 +242,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -181,12 +271,12 @@ func DashboardActions() templ.Component {
}() }()
} }
ctx = templ.InitializeContext(ctx) ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var9 := templ.GetChildren(ctx) templ_7745c5c3_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var9 == nil { if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var9 = templ.NopComponent templ_7745c5c3_Var15 = templ.NopComponent
} }
ctx = templ.ClearChildren(ctx) ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<button data-action=\"open-dashboard-settings\" class=\"icon-btn\" title=\"Customize Dashboard\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -194,7 +284,7 @@ func DashboardActions() 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, 16, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</button> <button data-action=\"reload-page\" class=\"icon-btn\" title=\"Refresh\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -202,7 +292,7 @@ func DashboardActions() 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, 17, "</button>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "</button>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
+18
View File
@@ -41,12 +41,30 @@ type LibraryData struct {
TypeValue string TypeValue string
MediaCount int64 MediaCount int64
FolderCount int FolderCount int
// Offline is true when none of the library's folders exist on disk
// (e.g. an unmounted external drive). Contents stay intact and reappear
// when the storage returns.
Offline bool
} }
type FolderData struct { type FolderData struct {
FolderPath string FolderPath string
} }
// ArchivedItem feeds the admin archived-items page: media hidden from
// libraries because their files vanished, with their pending fate.
type ArchivedItem struct {
ID string
Title string
Author string
LibraryName string
FilePath string
MissingScans int32
ArchivedAt *time.Time
PurgeAt *time.Time
Status string
}
type DirEntry struct { type DirEntry struct {
Name string Name string
Path string Path string
+6
View File
@@ -65,6 +65,12 @@ func isUserVisible(userID string, visibility []UserVisibilityData) bool {
func TotalMediaCount(libs []LibraryData) int64 { func TotalMediaCount(libs []LibraryData) int64 {
var total int64 var total int64
for _, l := range libs { for _, l := range libs {
// Unmounted storage contributes nothing visible: an offline
// library's holdings stay in the database but out of the UI
// until its folders return.
if l.Offline {
continue
}
total += l.MediaCount total += l.MediaCount
} }
return total return total
+61
View File
@@ -289,6 +289,67 @@ async function purgeArchivedItems(): Promise<void> {
(window as any).purgeArchivedItems = purgeArchivedItems; (window as any).purgeArchivedItems = purgeArchivedItems;
// Restore a hidden/archived item to library visibility without waiting for
// its file to return (POST /api/media-items/:id/unarchive). If the file is
// still gone the next scan hides it again.
async function unarchiveItem(id: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
try {
const resp = await fetch(`/api/media-items/${id}/unarchive`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to restore item");
}
showToast("Item restored to libraries", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(e instanceof Error ? e.message : "Failed to restore item", "error");
}
}
// Permanently delete one archived item and its reading history
// (DELETE /api/media-items/:id).
async function deleteArchivedItem(id: string): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (
!window.confirm(
"Permanently delete this item? Its reading progress, notes, and highlights will be lost.",
)
) {
return;
}
try {
const resp = await fetch(`/api/media-items/${id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to delete item");
}
showToast("Item deleted", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(e instanceof Error ? e.message : "Failed to delete item", "error");
}
}
// The archived-items page renders per-row buttons with data attributes
// (escaping-safe); bind them here. Module scripts run after DOM parse.
document.querySelectorAll<HTMLElement>("[data-unarchive]").forEach((el) => {
el.addEventListener("click", () => unarchiveItem(el.dataset.unarchive || ""));
});
document.querySelectorAll<HTMLElement>("[data-delete-archived]").forEach((el) => {
el.addEventListener("click", () =>
deleteArchivedItem(el.dataset.deleteArchived || ""),
);
});
export { export {
hideScanProgress, hideScanProgress,
loadWatchStatus, loadWatchStatus,