From 9f6dc2a65d07a111ad051c3bce98ac87e3e82695 Mon Sep 17 00:00:00 2001 From: Chai Date: Sun, 16 Aug 2026 18:10:49 -0400 Subject: [PATCH 01/38] fix(metadata): preserve ordered book details --- packages/core/src/utils/book-metadata.test.ts | 56 +++++++++++++++++++ packages/core/src/utils/book-metadata.ts | 32 +++++++++-- packages/core/src/utils/index.ts | 1 + sdd/task-1-report.md | 25 +++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/utils/book-metadata.test.ts create mode 100644 sdd/task-1-report.md diff --git a/packages/core/src/utils/book-metadata.test.ts b/packages/core/src/utils/book-metadata.test.ts new file mode 100644 index 000000000..b6453d839 --- /dev/null +++ b/packages/core/src/utils/book-metadata.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + mergeBookMetadataSources, + mergeMissingBookMetadataValues, +} from "./book-metadata"; + +describe("mergeBookMetadataSources", () => { + it("fills in priority order and normalizes extracted values", () => { + expect( + mergeBookMetadataSources( + { title: "My title", author: "", language: "" }, + { + title: "Catalog title", + author: "Catalog author", + language: "zh_hans", + subjects: [" Fiction ", "Fiction"], + }, + { author: "Embedded author", publisher: " Embedded Press " }, + { title: "filename" }, + ), + ).toEqual({ + title: "My title", + author: "Catalog author", + language: "zh-CN", + subjects: ["Fiction"], + publisher: "Embedded Press", + }); + }); + + it("ignores empty and invalid candidates", () => { + expect( + mergeBookMetadataSources({ title: "" }, { title: "Book", publishDate: "not-a-date" }), + ).toEqual({ title: "Book" }); + }); +}); + +it("does not copy subjects into user tags during details repair", () => { + const values = { + title: "", + author: "", + coverUrl: "", + publisher: "", + language: "", + isbn: "", + publishDate: "", + rating: null, + description: "", + reviews: [], + subjectsText: "", + tagsText: "", + groupId: "", + }; + const next = mergeMissingBookMetadataValues(values, { subjects: ["History"] }); + expect(next?.subjectsText).toBe("History"); + expect(next?.tagsText).toBe(""); +}); diff --git a/packages/core/src/utils/book-metadata.ts b/packages/core/src/utils/book-metadata.ts index fbca6c4db..bf2d93423 100644 --- a/packages/core/src/utils/book-metadata.ts +++ b/packages/core/src/utils/book-metadata.ts @@ -28,6 +28,34 @@ export interface ExtractedBookMetadata { subjects?: string[]; } +export function mergeBookMetadataSources( + ...sources: Array | ExtractedBookMetadata | null | undefined> +): Partial { + const result: Partial = {}; + const text = (key: keyof BookMeta, value: unknown) => { + if (result[key] != null || typeof value !== "string") return; + const trimmed = value.trim(); + if (trimmed) Object.assign(result, { [key]: trimmed }); + }; + + for (const source of sources) { + if (!source) continue; + text("title", source.title); + text("author", source.author); + text("publisher", source.publisher); + if (result.language == null) text("language", normalizeBookLanguage(source.language)); + if (result.isbn == null) text("isbn", normalizeIsbn(source.isbn)); + if (result.publishDate == null) text("publishDate", normalizePublishDate(source.publishDate)); + text("description", source.description); + text("coverUrl", "coverUrl" in source ? source.coverUrl : undefined); + if (result.subjects == null) { + const subjects = normalizeSubjects(source.subjects); + if (subjects.length) result.subjects = subjects; + } + } + return result; +} + export function createBookMetadataFormValues(book: Book): BookMetadataFormValues { return { title: book.meta.title || "", @@ -91,10 +119,6 @@ export function mergeMissingBookMetadataValues( next.subjectsText = subjectsText; changed = true; } - if (!next.tagsText.trim()) { - next.tagsText = subjectsText; - changed = true; - } } return changed ? next : null; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 4c23e39cb..215b937af 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -56,6 +56,7 @@ export { createBookMetadataFormValues, hasMissingBookMetadataAutoFillTargets, joinEditableList, + mergeBookMetadataSources, mergeMissingBookMetadataValues, normalizeRating, normalizeReviews, diff --git a/sdd/task-1-report.md b/sdd/task-1-report.md new file mode 100644 index 000000000..d621574d8 --- /dev/null +++ b/sdd/task-1-report.md @@ -0,0 +1,25 @@ +# Task 1 Report: Ordered metadata merge contract + +## Outcome + +Implemented the ordered metadata merge contract for book details. + +- Added `mergeBookMetadataSources`, preserving the first nonblank normalized value across sources. +- Applied language, ISBN, publish-date, and subject normalization while merging. +- Exported the merge function through `packages/core/src/utils/index.ts`. +- Stopped automatic details repair from copying subjects into user library tags. +- Added focused regression coverage for precedence, normalization, invalid candidates, and subject/tag separation. + +## TDD evidence + +The new focused test was run before production changes and failed for the expected reasons: the merge function was absent, and subject repair populated `tagsText`. After implementation, the same test passed. + +## Verification + +- `TZ=UTC pnpm --filter @readany/core test -- src/utils/book-metadata.test.ts` — 1 file, 3 tests passed. +- `TZ=UTC pnpm --filter @readany/core test` — 81 files, 587 tests passed. +- `git diff --check` — passed. + +## Concerns + +No known concerns within Task 1 scope. The merge contract intentionally handles metadata fields only; user tags remain independent from extracted subjects. From 27e5dd95c14e3745f49e1a365b8cc9a58d05e89e Mon Sep 17 00:00:00 2001 From: Chai Date: Sun, 16 Aug 2026 18:13:23 -0400 Subject: [PATCH 02/38] fix(metadata): ignore empty tags during autofill --- packages/core/src/utils/book-metadata.test.ts | 21 +++++++++++++++++++ packages/core/src/utils/book-metadata.ts | 3 +-- sdd/task-1-report.md | 16 ++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/book-metadata.test.ts b/packages/core/src/utils/book-metadata.test.ts index b6453d839..ae8d61384 100644 --- a/packages/core/src/utils/book-metadata.test.ts +++ b/packages/core/src/utils/book-metadata.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + hasMissingBookMetadataAutoFillTargets, mergeBookMetadataSources, mergeMissingBookMetadataValues, } from "./book-metadata"; @@ -54,3 +55,23 @@ it("does not copy subjects into user tags during details repair", () => { expect(next?.subjectsText).toBe("History"); expect(next?.tagsText).toBe(""); }); + +it("does not request autofill when publication metadata is complete and tags are empty", () => { + expect( + hasMissingBookMetadataAutoFillTargets({ + title: "Book", + author: "Author", + coverUrl: "", + publisher: "Publisher", + language: "en", + isbn: "9781234567890", + publishDate: "2024", + rating: null, + description: "Description", + reviews: [], + subjectsText: "History", + tagsText: "", + groupId: "", + }), + ).toBe(false); +}); diff --git a/packages/core/src/utils/book-metadata.ts b/packages/core/src/utils/book-metadata.ts index bf2d93423..b4bbd3ab2 100644 --- a/packages/core/src/utils/book-metadata.ts +++ b/packages/core/src/utils/book-metadata.ts @@ -81,8 +81,7 @@ export function hasMissingBookMetadataAutoFillTargets(values: BookMetadataFormVa !values.isbn.trim() || !values.publishDate.trim() || !values.description.trim() || - !values.subjectsText.trim() || - !values.tagsText.trim() + !values.subjectsText.trim() ); } diff --git a/sdd/task-1-report.md b/sdd/task-1-report.md index d621574d8..b701ef810 100644 --- a/sdd/task-1-report.md +++ b/sdd/task-1-report.md @@ -23,3 +23,19 @@ The new focused test was run before production changes and failed for the expect ## Concerns No known concerns within Task 1 scope. The merge contract intentionally handles metadata fields only; user tags remain independent from extracted subjects. + +## Follow-up regression: empty user tags + +Files changed: + +- `packages/core/src/utils/book-metadata.ts` +- `packages/core/src/utils/book-metadata.test.ts` +- `sdd/task-1-report.md` + +RED: `$env:TZ='UTC'; pnpm --filter @readany/core test -- src/utils/book-metadata.test.ts` — failed 1 of 4 tests because complete publication metadata with empty `tagsText` still returned `true` from `hasMissingBookMetadataAutoFillTargets`. + +GREEN: `$env:TZ='UTC'; pnpm --filter @readany/core test -- src/utils/book-metadata.test.ts` — 1 file, 4 tests passed. + +Full verification: `$env:TZ='UTC'; pnpm --filter @readany/core test` — 81 files, 588 tests passed. + +Fix: removed `tagsText` from the autofill predicate so intentionally empty user tags do not trigger repeated metadata extraction. From f3be19244391a9b95da220b0ad26c63fe80b5942 Mon Sep 17 00:00:00 2001 From: Chai Date: Sun, 16 Aug 2026 18:18:42 -0400 Subject: [PATCH 03/38] fix(metadata): retain mobile import details --- .../src/lib/book/imported-book-meta.test.ts | 51 +++++++ .../src/lib/book/imported-book-meta.ts | 18 +++ packages/app-expo/src/stores/library-store.ts | 125 +++++++++--------- 3 files changed, 133 insertions(+), 61 deletions(-) create mode 100644 packages/app-expo/src/lib/book/imported-book-meta.test.ts create mode 100644 packages/app-expo/src/lib/book/imported-book-meta.ts diff --git a/packages/app-expo/src/lib/book/imported-book-meta.test.ts b/packages/app-expo/src/lib/book/imported-book-meta.test.ts new file mode 100644 index 000000000..fce291ad0 --- /dev/null +++ b/packages/app-expo/src/lib/book/imported-book-meta.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { buildImportedBookMeta } from "./imported-book-meta"; + +describe("buildImportedBookMeta", () => { + it("persists rich extracted metadata", () => { + expect( + buildImportedBookMeta({ + existing: undefined, + opds: undefined, + embedded: { + title: "Book", + author: "Author", + publisher: "Press", + language: "en-US", + isbn: "978 1 4028 9462 6", + publishDate: "2020-4-3", + description: "Summary", + subjects: ["History"], + coverUrl: "covers/1.jpg", + }, + fallbackTitle: "file", + }), + ).toMatchObject({ + title: "Book", + author: "Author", + publisher: "Press", + language: "en", + isbn: "9781402894626", + publishDate: "2020-04-03", + description: "Summary", + subjects: ["History"], + coverUrl: "covers/1.jpg", + }); + }); + + it("preserves restored values and lets OPDS fill blanks before embedded metadata", () => { + expect( + buildImportedBookMeta({ + existing: { title: "Edited", author: "", publisher: "Saved" }, + opds: { title: "Catalog", author: "Catalog Author", publisher: "Catalog Press" }, + embedded: { author: "Embedded Author", language: "fr" }, + fallbackTitle: "file", + }), + ).toMatchObject({ + title: "Edited", + author: "Catalog Author", + publisher: "Saved", + language: "fr", + }); + }); +}); diff --git a/packages/app-expo/src/lib/book/imported-book-meta.ts b/packages/app-expo/src/lib/book/imported-book-meta.ts new file mode 100644 index 000000000..bc0315831 --- /dev/null +++ b/packages/app-expo/src/lib/book/imported-book-meta.ts @@ -0,0 +1,18 @@ +import type { BookMeta } from "@readany/core/types"; +import { mergeBookMetadataSources } from "@readany/core/utils"; +import type { ExtractedMeta } from "./metadata-extractor"; + +export function buildImportedBookMeta(input: { + existing?: Partial; + opds?: Partial; + embedded?: Partial | (ExtractedMeta & { coverUrl?: string }); + fallbackTitle: string; +}): BookMeta { + const merged = mergeBookMetadataSources( + input.existing, + input.opds, + input.embedded, + { title: input.fallbackTitle, author: "" }, + ); + return { title: merged.title || "Untitled", author: merged.author || "", ...merged }; +} diff --git a/packages/app-expo/src/stores/library-store.ts b/packages/app-expo/src/stores/library-store.ts index d9014c14c..b679e31a3 100644 --- a/packages/app-expo/src/stores/library-store.ts +++ b/packages/app-expo/src/stores/library-store.ts @@ -2,7 +2,9 @@ import { createRangeReadableFile, extractBookMetadata, extractBookMetadataFromFile, + type ExtractedMeta, } from "@/lib/book/metadata-extractor"; +import { buildImportedBookMeta } from "@/lib/book/imported-book-meta"; import { queueBook as queueAutoVectorize } from "@/lib/rag/auto-vectorize-service"; import { type ImportBooksResult, @@ -13,7 +15,7 @@ import { import * as db from "@readany/core/db/database"; import { runWithDbRetry } from "@readany/core/db/write-retry"; import { getPlatformService } from "@readany/core/services"; -import type { Book, BookGroup, LibraryFilter, SortField, SortOrder } from "@readany/core/types"; +import type { Book, BookGroup, BookMeta, LibraryFilter, SortField, SortOrder } from "@readany/core/types"; import { generateId } from "@readany/core/utils"; import { create } from "zustand"; import { debouncedSave, loadFromFS } from "./persist"; @@ -50,6 +52,12 @@ export interface RemoveBookOptions { preserveData?: boolean; } +export interface MobileImportFile { + uri: string; + name?: string; + metadata?: Partial; +} + function keepActiveGroupId(activeGroupId: string, groups: BookGroup[]): string { if (!activeGroupId) return ""; return groups.some((group) => group.id === activeGroupId) ? activeGroupId : ""; @@ -79,7 +87,7 @@ export interface LibraryState { setViewMode: (mode: LibraryViewMode) => void; setSortField: (field: SortField) => void; setSortOrder: (order: SortOrder) => void; - importBooks: (files: Array<{ uri: string; name?: string }>) => Promise; + importBooks: (files: MobileImportFile[]) => Promise; inspectDeletedBookCandidate: ( bookId: string, file: { uri: string; name?: string }, @@ -397,12 +405,11 @@ async function restoreDeletedMobileBook( ...originalBook, filePath: relativePath, format: "epub", - meta: { - ...originalBook.meta, - title: conversion.bookTitle || originalBook.meta.title || fileName.replace(/\.\w+$/i, ""), - author: originalBook.meta.author || "", - coverUrl: originalBook.meta.coverUrl, - }, + meta: buildImportedBookMeta({ + existing: originalBook.meta, + embedded: { title: conversion.bookTitle }, + fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled", + }), deletedAt: undefined, fileHash, syncStatus: "local", @@ -456,12 +463,11 @@ async function restoreDeletedMobileBook( ...originalBook, filePath: relativePath, format: "umd", - meta: { - ...originalBook.meta, - title: conversion.bookTitle || originalBook.meta.title || fileName.replace(/\.\w+$/i, ""), - author: conversion.author || originalBook.meta.author || "", - coverUrl, - }, + meta: buildImportedBookMeta({ + existing: originalBook.meta, + embedded: { title: conversion.bookTitle, author: conversion.author, coverUrl }, + fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled", + }), deletedAt: undefined, fileHash, syncStatus: "local", @@ -474,9 +480,8 @@ async function restoreDeletedMobileBook( const { relativePath } = await copyBookToAppData(bookId, ext || "epub", filePath); - let title = originalBook.meta.title || fileName.replace(/\.\w+$/i, "") || "Untitled"; - let author = originalBook.meta.author || ""; let coverUrl = originalBook.meta.coverUrl; + let embeddedMeta: (ExtractedMeta & { coverUrl?: string }) | undefined; try { const meta = await extractMobileImportMetadata({ @@ -485,17 +490,19 @@ async function restoreDeletedMobileBook( fileName, fileSize, }); - if (meta.title) title = meta.title; - if (meta.author) author = meta.author; - if (meta.coverBytes && meta.coverBytes.length > 0) { - const mimeType = meta.coverMimeType || "image/jpeg"; - const coverExt = mimeType.includes("png") ? "png" : "jpg"; - await ensureAppSubDir("covers"); - const coverRelPath = `covers/${bookId}.${coverExt}`; - await platform.writeFile(await resolveAppPath(coverRelPath), meta.coverBytes); - coverUrl = coverRelPath; + try { + const mimeType = meta.coverMimeType || "image/jpeg"; + const coverExt = mimeType.includes("png") ? "png" : "jpg"; + await ensureAppSubDir("covers"); + const coverRelPath = `covers/${bookId}.${coverExt}`; + await platform.writeFile(await resolveAppPath(coverRelPath), meta.coverBytes); + coverUrl = coverRelPath; + } catch (coverErr) { + console.warn(`[restoreDeletedMobileBook] Cover save failed for ${fileName}:`, coverErr); + } } + embeddedMeta = { ...meta, coverUrl }; } catch (metaErr) { console.warn(`[restoreDeletedMobileBook] Metadata extraction failed for ${fileName}:`, metaErr); } @@ -504,12 +511,11 @@ async function restoreDeletedMobileBook( ...originalBook, filePath: relativePath, format, - meta: { - ...originalBook.meta, - title, - author, - coverUrl, - }, + meta: buildImportedBookMeta({ + existing: originalBook.meta, + embedded: embeddedMeta, + fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled", + }), deletedAt: undefined, fileHash, syncStatus: "local", @@ -941,17 +947,17 @@ export const useLibraryStore = create((set, get) => ({ // TXT-converted EPUBs have no cover, and title is already known from converter. // Skip metadata extraction entirely — saves a full EPUB re-parse. - const title = conversion.bookTitle || fileName.replace(/\.\w+$/i, "") || "Untitled"; + const completeMeta = buildImportedBookMeta({ + existing: deletedMatch?.meta, + opds: fileInfo.metadata, + embedded: { title: conversion.bookTitle }, + fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled", + }); const book: Book = { id: bookId, filePath: relativePath, format: "epub", - meta: { - ...(deletedMatch?.meta ?? {}), - title, - author: "", - coverUrl: deletedMatch?.meta.coverUrl, - }, + meta: completeMeta, groupId: deletedMatch?.groupId, progress: deletedMatch?.progress ?? 0, currentCfi: deletedMatch?.currentCfi, @@ -989,7 +995,7 @@ export const useLibraryStore = create((set, get) => ({ if (fileHash) { duplicateIndex.byHash.set(fileHash, book); } - console.log(`[importBooks] TXT imported as EPUB: ${title}`); + console.log(`[importBooks] TXT imported as EPUB: ${completeMeta.title}`); // Auto-vectorize if enabled. Keep failures isolated so a // successful import doesn't get reported as a failed import. @@ -1063,18 +1069,17 @@ export const useLibraryStore = create((set, get) => ({ } } - const title = conversion.bookTitle || fileName.replace(/\.\w+$/i, "") || "Untitled"; - const author = conversion.author || ""; + const completeMeta = buildImportedBookMeta({ + existing: deletedMatch?.meta, + opds: fileInfo.metadata, + embedded: { title: conversion.bookTitle, author: conversion.author, coverUrl }, + fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled", + }); const book: Book = { id: bookId, filePath: relativePath, format: "umd", - meta: { - ...(deletedMatch?.meta ?? {}), - title, - author, - coverUrl: coverUrl || deletedMatch?.meta.coverUrl, - }, + meta: completeMeta, groupId: deletedMatch?.groupId, progress: deletedMatch?.progress ?? 0, currentCfi: deletedMatch?.currentCfi, @@ -1112,7 +1117,7 @@ export const useLibraryStore = create((set, get) => ({ if (fileHash) { duplicateIndex.byHash.set(fileHash, book); } - console.log(`[importBooks] UMD imported as EPUB: ${title}`); + console.log(`[importBooks] UMD imported as EPUB: ${completeMeta.title}`); try { const vmState = useVectorModelStore.getState(); @@ -1141,10 +1146,9 @@ export const useLibraryStore = create((set, get) => ({ const { relativePath } = await copyBookToAppData(bookId, ext || "epub", filePath); console.log(`[importBooks] File copied. relativePath: ${relativePath}`); - // Extract metadata (title, author, cover) from book content - let title = fileName.replace(/\.\w+$/i, "") || "Untitled"; - let author = ""; + // Extract metadata and cover from book content. let coverUrl: string | undefined; + let embeddedMeta: (ExtractedMeta & { coverUrl?: string }) | undefined; try { console.log(`[importBooks] Extracting metadata for format=${format}...`); @@ -1157,9 +1161,6 @@ export const useLibraryStore = create((set, get) => ({ console.log( `[importBooks] Metadata result: title="${meta.title}", author="${meta.author}", hasCover=${!!meta.coverBytes}, coverSize=${meta.coverBytes?.length ?? 0}`, ); - if (meta.title) title = meta.title; - if (meta.author) author = meta.author; - // Save cover image to app data if (meta.coverBytes && meta.coverBytes.length > 0) { try { @@ -1177,23 +1178,25 @@ export const useLibraryStore = create((set, get) => ({ console.warn(`[importBooks] Failed to save cover for ${fileName}:`, coverErr); } } + embeddedMeta = { ...meta, coverUrl }; } catch (metaErr) { console.warn(`[importBooks] Metadata extraction failed for ${fileName}:`, metaErr); } + const completeMeta = buildImportedBookMeta({ + existing: deletedMatch?.meta, + opds: fileInfo.metadata, + embedded: embeddedMeta, + fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled", + }); console.log( - `[importBooks] Final book: title="${title}", author="${author}", coverUrl="${coverUrl}"`, + `[importBooks] Final book: title="${completeMeta.title}", author="${completeMeta.author}", coverUrl="${completeMeta.coverUrl}"`, ); const book: Book = { id: bookId, filePath: relativePath, format, - meta: { - ...(deletedMatch?.meta ?? {}), - title, - author, - coverUrl: coverUrl || deletedMatch?.meta.coverUrl, - }, + meta: completeMeta, groupId: deletedMatch?.groupId, progress: deletedMatch?.progress ?? 0, currentCfi: deletedMatch?.currentCfi, From b7463f2f69efd0af89413b5caf97df24f9889392 Mon Sep 17 00:00:00 2001 From: Chai Date: Sun, 16 Aug 2026 18:21:51 -0400 Subject: [PATCH 04/38] fix(metadata): retain restored book details --- .../src/lib/book/imported-book-meta.test.ts | 34 +++++++++++++++++++ .../src/lib/book/imported-book-meta.ts | 7 +++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/app-expo/src/lib/book/imported-book-meta.test.ts b/packages/app-expo/src/lib/book/imported-book-meta.test.ts index fce291ad0..a5cf451f0 100644 --- a/packages/app-expo/src/lib/book/imported-book-meta.test.ts +++ b/packages/app-expo/src/lib/book/imported-book-meta.test.ts @@ -48,4 +48,38 @@ describe("buildImportedBookMeta", () => { language: "fr", }); }); + + it("retains saved rating, reviews, and counts when filling import metadata", () => { + const reviews = [ + { + id: "review-1", + content: "Keep this review", + createdAt: 1, + updatedAt: 2, + }, + ]; + + expect( + buildImportedBookMeta({ + existing: { + title: "", + author: "", + rating: 4, + reviews, + totalPages: 320, + totalChapters: 12, + }, + opds: { rating: undefined, reviews: undefined, totalPages: undefined }, + embedded: { title: "Imported", author: "Author" }, + fallbackTitle: "file", + }), + ).toMatchObject({ + title: "Imported", + author: "Author", + rating: 4, + reviews, + totalPages: 320, + totalChapters: 12, + }); + }); }); diff --git a/packages/app-expo/src/lib/book/imported-book-meta.ts b/packages/app-expo/src/lib/book/imported-book-meta.ts index bc0315831..9ce207dc9 100644 --- a/packages/app-expo/src/lib/book/imported-book-meta.ts +++ b/packages/app-expo/src/lib/book/imported-book-meta.ts @@ -14,5 +14,10 @@ export function buildImportedBookMeta(input: { input.embedded, { title: input.fallbackTitle, author: "" }, ); - return { title: merged.title || "Untitled", author: merged.author || "", ...merged }; + return { + ...input.existing, + ...merged, + title: merged.title || input.existing?.title || "Untitled", + author: merged.author || input.existing?.author || "", + }; } From 92aac49d53aa4fb4f6c2aea1c83a0d76a6e6c9b7 Mon Sep 17 00:00:00 2001 From: Chai Date: Sun, 16 Aug 2026 18:29:22 -0400 Subject: [PATCH 05/38] fix(metadata): retain desktop import details --- .../src/lib/book/imported-book-meta.test.ts | 70 +++++++ .../app/src/lib/book/imported-book-meta.ts | 56 +++++ packages/app/src/stores/library-store.ts | 197 ++++++++++-------- sdd/task-3-report.md | 26 +++ 4 files changed, 262 insertions(+), 87 deletions(-) create mode 100644 packages/app/src/lib/book/imported-book-meta.test.ts create mode 100644 packages/app/src/lib/book/imported-book-meta.ts create mode 100644 sdd/task-3-report.md diff --git a/packages/app/src/lib/book/imported-book-meta.test.ts b/packages/app/src/lib/book/imported-book-meta.test.ts new file mode 100644 index 000000000..1318fcee9 --- /dev/null +++ b/packages/app/src/lib/book/imported-book-meta.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { buildImportedBookMeta, fromDocumentMetadata } from "./imported-book-meta"; + +describe("desktop imported book metadata", () => { + it("preserves restored fields while filling blanks from rich extracted metadata", () => { + const reviews = [{ id: "review-1", content: "Keep this", createdAt: 1, updatedAt: 2 }]; + + expect( + buildImportedBookMeta({ + existing: { + title: "Edited title", + author: "", + publisher: "Saved press", + rating: 4, + reviews, + totalPages: 320, + }, + opds: { author: "Catalog author", language: "fr" }, + embedded: { + title: "Embedded title", + author: "Embedded author", + publisher: "Embedded press", + isbn: "978 1 4028 9462 6", + subjects: ["History"], + coverUrl: "covers/1.jpg", + }, + fallbackTitle: "filename", + }), + ).toMatchObject({ + title: "Edited title", + author: "Catalog author", + publisher: "Saved press", + language: "fr", + isbn: "9781402894626", + subjects: ["History"], + coverUrl: "covers/1.jpg", + rating: 4, + reviews, + totalPages: 320, + }); + }); + + it("normalizes Foliate object metadata without turning subjects into tags", () => { + expect( + fromDocumentMetadata({ + title: { en: "Object title" }, + author: { name: "Object author" }, + publisher: "Press", + language: "en-US", + identifier: "978 1 4028 9462 6", + published: "2020-4-3", + description: "Summary", + subject: [{ name: "History" }, "Science"], + }), + ).toEqual({ + title: "Object title", + author: "Object author", + publisher: "Press", + language: "en-US", + isbn: "978 1 4028 9462 6", + publishDate: "2020-4-3", + description: "Summary", + subjects: ["History", "Science"], + }); + }); + + it("keeps a single Foliate subject as a subject", () => { + expect(fromDocumentMetadata({ subject: "Fiction" }).subjects).toEqual(["Fiction"]); + }); +}); diff --git a/packages/app/src/lib/book/imported-book-meta.ts b/packages/app/src/lib/book/imported-book-meta.ts new file mode 100644 index 000000000..f544d41a3 --- /dev/null +++ b/packages/app/src/lib/book/imported-book-meta.ts @@ -0,0 +1,56 @@ +import type { BookMeta } from "@readany/core/types"; +import { type ExtractedBookMetadata, mergeBookMetadataSources } from "@readany/core/utils"; + +type EmbeddedBookMetadata = ExtractedBookMetadata & { coverUrl?: string }; + +export function buildImportedBookMeta(input: { + existing?: Partial; + opds?: Partial; + embedded?: EmbeddedBookMetadata; + fallbackTitle: string; +}): BookMeta { + const merged = mergeBookMetadataSources(input.existing, input.opds, input.embedded, { + title: input.fallbackTitle, + author: "", + }); + + return { + ...input.existing, + ...merged, + title: merged.title || input.existing?.title || "Untitled", + author: merged.author || input.existing?.author || "", + }; +} + +export function fromDocumentMetadata( + meta: Record | undefined, +): ExtractedBookMetadata { + const authorValue = meta?.author; + const subjectValues = Array.isArray(meta?.subject) + ? meta.subject + : meta?.subject == null + ? [] + : [meta.subject]; + const subjects = subjectValues + .map((value) => + typeof value === "string" ? value : String((value as { name?: string }).name || ""), + ) + .filter(Boolean); + + return { + title: + typeof meta?.title === "string" + ? meta.title + : String(Object.values((meta?.title as object) || {})[0] || ""), + author: + typeof authorValue === "string" + ? authorValue + : String((authorValue as { name?: string } | undefined)?.name || ""), + publisher: typeof meta?.publisher === "string" ? meta.publisher : undefined, + language: typeof meta?.language === "string" ? meta.language : undefined, + isbn: typeof meta?.identifier === "string" ? meta.identifier : undefined, + publishDate: typeof meta?.published === "string" ? meta.published : undefined, + description: typeof meta?.description === "string" ? meta.description : undefined, + subjects, + }; +} diff --git a/packages/app/src/stores/library-store.ts b/packages/app/src/stores/library-store.ts index 73fb2b85f..f5a7bdfc7 100644 --- a/packages/app/src/stores/library-store.ts +++ b/packages/app/src/stores/library-store.ts @@ -1,3 +1,4 @@ +import { buildImportedBookMeta, fromDocumentMetadata } from "@/lib/book/imported-book-meta"; import * as db from "@/lib/db/database"; import { triggerVectorizeBook } from "@/lib/rag/vectorize-trigger"; import { @@ -14,13 +15,12 @@ import { import { debouncedSave, loadFromFS } from "@readany/core/stores/persist"; import { useVectorModelStore } from "@readany/core/stores/vector-model-store"; import type { Book, BookGroup, LibraryFilter, SortField, SortOrder } from "@readany/core/types"; +import type { ExtractedBookMetadata } from "@readany/core/utils"; import { create } from "zustand"; -interface EpubMeta { - title: string; - author: string; +type DesktopExtractedMetadata = ExtractedBookMetadata & { coverBlob: Blob | null; -} +}; /** * Lightweight EPUB metadata + cover extraction. @@ -28,7 +28,7 @@ interface EpubMeta { * container.xml, OPF, and cover image entry. Does NOT decompress the entire ZIP. * Memory usage for a 70MB EPUB: ~1-2MB (metadata + cover image only). */ -export async function extractEpubMetadata(blob: Blob): Promise { +export async function extractEpubMetadata(blob: Blob): Promise { const { configure, ZipReader, BlobReader, TextWriter, BlobWriter } = await import( "@zip.js/zip.js" ); @@ -64,7 +64,7 @@ export async function extractEpubMetadata(blob: Blob): Promise { const containerXml = await getTextEntry("META-INF/container.xml"); if (!containerXml) { await reader.close(); - return { title: "", author: "", coverBlob: null }; + return { coverBlob: null }; } const parser = new DOMParser(); @@ -77,14 +77,21 @@ export async function extractEpubMetadata(blob: Blob): Promise { const opfXml = await getTextEntry(opfPath); if (!opfXml) { await reader.close(); - return { title: "", author: "", coverBlob: null }; + return { coverBlob: null }; } - const opfDoc = parser.parseFromString(opfXml, "text/html"); - const title = - opfDoc.querySelector("metadata dc\\:title, metadata title")?.textContent?.trim() || ""; - const author = - opfDoc.querySelector("metadata dc\\:creator, metadata creator")?.textContent?.trim() || ""; + const opfDoc = parser.parseFromString(opfXml, "application/xml"); + const metadata = + Array.from(opfDoc.getElementsByTagName("*")).find( + (element) => element.localName === "metadata", + ) ?? opfDoc.documentElement; + const elements = Array.from(metadata.getElementsByTagName("*")); + const textByLocalName = (localName: string) => + elements.find((element) => element.localName === localName)?.textContent?.trim() || ""; + const subjects = elements + .filter((element) => element.localName === "subject") + .map((element) => element.textContent?.trim() || "") + .filter(Boolean); // 3. Find cover image path from OPF let coverBlob: Blob | null = null; @@ -159,7 +166,46 @@ export async function extractEpubMetadata(blob: Blob): Promise { } await reader.close(); - return { title, author, coverBlob }; + return { + title: textByLocalName("title"), + author: textByLocalName("creator"), + publisher: textByLocalName("publisher"), + language: textByLocalName("language"), + isbn: extractIsbn(elements), + publishDate: extractPublishDate(elements), + description: textByLocalName("description"), + subjects, + coverBlob, + }; +} + +function extractIsbn(elements: Element[]): string { + for (const element of elements) { + if (element.localName !== "identifier") continue; + const scheme = + element.getAttribute("opf:scheme") || + element.getAttribute("scheme") || + element.getAttributeNS("http://www.idpf.org/2007/opf", "scheme") || + ""; + const text = element.textContent?.trim() || ""; + if (scheme.toLowerCase() === "isbn" || /(?:97[89][-\s]?)?(?:\d[-\s]?){9,12}[\dXx]/.test(text)) { + return text; + } + } + return ""; +} + +function extractPublishDate(elements: Element[]): string { + const issued = elements.find( + (element) => + element.localName === "meta" && + (element.getAttribute("property") === "dcterms:issued" || + element.getAttribute("name") === "dcterms:issued"), + ); + const issuedText = issued?.textContent?.trim(); + if (issuedText) return issuedText; + + return elements.find((element) => element.localName === "date")?.textContent?.trim() || ""; } /** Generate PDF cover by rendering the first page to canvas. @@ -405,9 +451,8 @@ async function restoreDeletedDesktopBook(bookId: string, filePath: string): Prom umd: "umd", }; const format: Book["format"] = formatMap[ext] || "epub"; - let title = originalBook.meta.title || fileName.replace(/\.\w+$/i, "") || "Untitled"; - let author = originalBook.meta.author || ""; - let coverUrl = originalBook.meta.coverUrl; + const fallbackTitle = fileName.replace(/\.\w+$/i, "") || "Untitled"; + let embeddedMeta: ExtractedBookMetadata & { coverUrl?: string } = {}; let fileHash: string | undefined; try { @@ -433,7 +478,7 @@ async function restoreDeletedDesktopBook(bookId: string, filePath: string): Prom ); const converter = new TxtToEpubConverter(); const conversion = await converter.convert({ file: txtFile }); - title = conversion.bookTitle || title; + embeddedMeta = { title: conversion.bookTitle }; const epubBytes = new Uint8Array(await conversion.file.arrayBuffer()); await mkdir(await join(await getDesktopLibraryRoot(), "books"), { recursive: true }); const relPath = `books/${bookId}.epub`; @@ -459,8 +504,7 @@ async function restoreDeletedDesktopBook(bookId: string, filePath: string): Prom const conversion = await new UmdToEpubConverter((b) => fflate.unzlibSync(b), ).convertToBytes({ file: umdFile }); - if (conversion.bookTitle) title = conversion.bookTitle; - if (conversion.author) author = conversion.author; + embeddedMeta = { title: conversion.bookTitle, author: conversion.author }; await mkdir(await join(await getDesktopLibraryRoot(), "books"), { recursive: true }); const relPath = `books/${bookId}.epub`; const dest = await resolveAppPath(relPath); @@ -476,17 +520,16 @@ async function restoreDeletedDesktopBook(bookId: string, filePath: string): Prom const epubBytes = await readFile(destPath); const blob = new Blob([epubBytes]); const epubMeta = await extractEpubMetadata(blob); - if (epubMeta.title) title = epubMeta.title; - if (epubMeta.author) author = epubMeta.author; + embeddedMeta = epubMeta; if (epubMeta.coverBlob) { - coverUrl = await saveCoverToAppData(bookId, epubMeta.coverBlob); + embeddedMeta.coverUrl = await saveCoverToAppData(bookId, epubMeta.coverBlob); } } else if (format === "pdf") { const { convertFileSrc } = await import("@tauri-apps/api/core"); const pdfUrl = convertFileSrc(destPath); const coverBlob = await generatePdfCover(pdfUrl); if (coverBlob) { - coverUrl = await saveCoverToAppData(bookId, coverBlob); + embeddedMeta.coverUrl = await saveCoverToAppData(bookId, coverBlob); } } else { const { readFile } = await import("@tauri-apps/plugin-fs"); @@ -500,22 +543,11 @@ async function restoreDeletedDesktopBook(bookId: string, filePath: string): Prom const { DocumentLoader } = await import("@/lib/reader/document-loader"); const loader = new DocumentLoader(file); const { book: bookDoc } = await loader.open(); - const meta = bookDoc.metadata; - if (meta) { - const rawTitle = - typeof meta.title === "string" - ? meta.title - : meta.title - ? Object.values(meta.title)[0] - : ""; - if (rawTitle) title = rawTitle; - const rawAuthor = typeof meta.author === "string" ? meta.author : meta.author?.name || ""; - if (rawAuthor) author = rawAuthor; - } + embeddedMeta = fromDocumentMetadata(bookDoc.metadata as unknown as Record); try { const coverBlob = await bookDoc.getCover(); if (coverBlob) { - coverUrl = await saveCoverToAppData(bookId, coverBlob); + embeddedMeta.coverUrl = await saveCoverToAppData(bookId, coverBlob); } } catch (err) { console.warn("[restoreDeletedDesktopBook] getCover failed:", err); @@ -528,7 +560,7 @@ async function restoreDeletedDesktopBook(bookId: string, filePath: string): Prom const { convertFileSrc } = await import("@tauri-apps/api/core"); const coverBlob = await generatePdfCover(convertFileSrc(destPath)); if (coverBlob) { - coverUrl = await saveCoverToAppData(bookId, coverBlob); + embeddedMeta.coverUrl = await saveCoverToAppData(bookId, coverBlob); } } catch (err) { console.warn("[Library] PDF cover generation failed:", err); @@ -540,12 +572,11 @@ async function restoreDeletedDesktopBook(bookId: string, filePath: string): Prom ...originalBook, filePath: relativePath, format, - meta: { - ...originalBook.meta, - title, - author, - coverUrl, - }, + meta: buildImportedBookMeta({ + existing: originalBook.meta, + embedded: embeddedMeta, + fallbackTitle, + }), deletedAt: undefined, fileHash, syncStatus: "local", @@ -890,9 +921,8 @@ export const useLibraryStore = create((set, get) => ({ umd: "umd", }; const format: Book["format"] = formatMap[ext] || "epub"; - let title = fileName.replace(/\.\w+$/i, "") || "Untitled"; - let author = ""; - let coverUrl: string | undefined; + const fallbackTitle = fileName.replace(/\.\w+$/i, "") || "Untitled"; + let embeddedMeta: ExtractedBookMetadata & { coverUrl?: string } = {}; let fileHash: string | undefined; try { @@ -912,11 +942,17 @@ export const useLibraryStore = create((set, get) => ({ } let deletedMatch = fileHash - ? await db.getDeletedBookByFileHash(fileHash).catch((err) => { console.warn("[Library] Failed to check deleted book by hash:", err); return null; }) + ? await db.getDeletedBookByFileHash(fileHash).catch((err) => { + console.warn("[Library] Failed to check deleted book by hash:", err); + return null; + }) : null; // Fallback: match by title if hash lookup failed (e.g. hash was null on first import) - if (!deletedMatch && title) { - deletedMatch = await db.getDeletedBookByTitle(title).catch((err) => { console.warn("[Library] Failed to check deleted book by title:", err); return null; }); + if (!deletedMatch && fallbackTitle) { + deletedMatch = await db.getDeletedBookByTitle(fallbackTitle).catch((err) => { + console.warn("[Library] Failed to check deleted book by title:", err); + return null; + }); } const bookId = deletedMatch?.id ?? crypto.randomUUID(); @@ -934,8 +970,7 @@ export const useLibraryStore = create((set, get) => ({ ); const converter = new TxtToEpubConverter(); const result = await converter.convert({ file: txtFile }); - title = result.bookTitle; - if (result.language) author = ""; + embeddedMeta = { title: result.bookTitle, language: result.language }; // Write the converted EPUB directly into the managed library location const { writeFile, mkdir } = await import("@tauri-apps/plugin-fs"); const { join } = await import("@tauri-apps/api/path"); @@ -960,8 +995,7 @@ export const useLibraryStore = create((set, get) => ({ ); const converter = new UmdToEpubConverter((b) => fflate.unzlibSync(b)); const result = await converter.convertToBytes({ file: umdFile }); - if (result.bookTitle) title = result.bookTitle; - if (result.author) author = result.author; + embeddedMeta = { title: result.bookTitle, author: result.author }; const { writeFile, mkdir } = await import("@tauri-apps/plugin-fs"); const { join } = await import("@tauri-apps/api/path"); await mkdir(await join(await getDesktopLibraryRoot(), "books"), { recursive: true }); @@ -993,19 +1027,14 @@ export const useLibraryStore = create((set, get) => ({ const epubBytes = await readFile(destPath); const blob = new Blob([epubBytes]); const epubMeta = await extractEpubMetadata(blob); - if (epubMeta.title) title = epubMeta.title; - if (epubMeta.author) author = epubMeta.author; + embeddedMeta = epubMeta; if (epubMeta.coverBlob) { - coverUrl = await saveCoverToAppData(bookId, epubMeta.coverBlob); + embeddedMeta.coverUrl = await saveCoverToAppData(bookId, epubMeta.coverBlob); } } else if (format === "pdf") { // PDF: use convertFileSrc URL so pdfjs streams from disk const { convertFileSrc } = await import("@tauri-apps/api/core"); const pdfUrl = convertFileSrc(destPath); - const coverBlob = await generatePdfCover(pdfUrl); - if (coverBlob) { - coverUrl = await saveCoverToAppData(bookId, coverBlob); - } // PDF title: try extracting from PDF metadata try { const pdfjsLib = await import("pdfjs-dist"); @@ -1017,11 +1046,19 @@ export const useLibraryStore = create((set, get) => ({ }).promise; const metadata = await pdfDoc.getMetadata(); const pdfTitle = (metadata?.info as Record)?.Title as string; - if (pdfTitle?.trim()) title = pdfTitle.trim(); + if (pdfTitle?.trim()) embeddedMeta.title = pdfTitle.trim(); pdfDoc.destroy(); } catch (err) { console.warn("[Library] PDF metadata extraction failed:", err); } + try { + const coverBlob = await generatePdfCover(pdfUrl); + if (coverBlob) { + embeddedMeta.coverUrl = await saveCoverToAppData(bookId, coverBlob); + } + } catch (err) { + console.warn("[Library] PDF cover generation failed:", err); + } } else { // Other formats (MOBI/AZW/FB2/CBZ): need DocumentLoader, load file into memory const { readFile } = await import("@tauri-apps/plugin-fs"); @@ -1033,26 +1070,14 @@ export const useLibraryStore = create((set, get) => ({ }); const loader = new DocumentLoader(file); const { book: bookDoc } = await loader.open(); - - const meta = bookDoc.metadata; - if (meta) { - const rawTitle = - typeof meta.title === "string" - ? meta.title - : meta.title - ? Object.values(meta.title)[0] - : ""; - if (rawTitle) title = rawTitle; - - const rawAuthor = - typeof meta.author === "string" ? meta.author : meta.author?.name || ""; - if (rawAuthor) author = rawAuthor; - } + embeddedMeta = fromDocumentMetadata( + bookDoc.metadata as unknown as Record, + ); try { const coverBlob = await bookDoc.getCover(); if (coverBlob) { - coverUrl = await saveCoverToAppData(bookId, coverBlob); + embeddedMeta.coverUrl = await saveCoverToAppData(bookId, coverBlob); } } catch (err) { console.warn("[importBooks] getCover failed:", err); @@ -1066,12 +1091,11 @@ export const useLibraryStore = create((set, get) => ({ id: bookId, filePath: relativePath, format, - meta: { - ...(deletedMatch?.meta ?? {}), - title, - author, - coverUrl: coverUrl || deletedMatch?.meta.coverUrl, - }, + meta: buildImportedBookMeta({ + existing: deletedMatch?.meta, + embedded: embeddedMeta, + fallbackTitle, + }), groupId: deletedMatch?.groupId, progress: deletedMatch?.progress ?? 0, currentCfi: deletedMatch?.currentCfi, @@ -1119,12 +1143,11 @@ export const useLibraryStore = create((set, get) => ({ ) { triggerVectorizeBook(book.id, relativePath, (progress) => { // Update book's vectorizeProgress so BookCard can show it - const pct = progress.totalChunks > 0 - ? progress.processedChunks / progress.totalChunks - : 0; + const pct = + progress.totalChunks > 0 ? progress.processedChunks / progress.totalChunks : 0; get().updateBook(book.id, { vectorizeProgress: pct }); }).catch((err) => { - console.warn(`[importBooks] Auto-vectorize failed for ${title}:`, err); + console.warn(`[importBooks] Auto-vectorize failed for ${book.meta.title}:`, err); }); } } catch (err) { diff --git a/sdd/task-3-report.md b/sdd/task-3-report.md new file mode 100644 index 000000000..04c2ee8e2 --- /dev/null +++ b/sdd/task-3-report.md @@ -0,0 +1,26 @@ +# Task 3 Report: Preserve rich desktop import metadata + +## Outcome + +- Desktop EPUB imports now retain publisher, language, ISBN, publication date, description, and subjects alongside title, author, and cover. +- Normal imports and deleted-book restoration use the same ordered metadata merge: saved values first, then optional catalog metadata, embedded metadata, and filename fallback. +- Existing ratings, reviews, page/chapter counts, and other saved `BookMeta` fields survive restoration. +- Foliate metadata is adapted from string and object title/author/subject shapes; extracted subjects stay in `meta.subjects` and never become library tags. +- Cover extraction failures remain non-blocking after text metadata has been captured. + +## TDD evidence + +- RED: `pnpm exec vitest run packages/app/src/lib/book/imported-book-meta.test.ts` failed because the desktop metadata helper was absent. +- RED: the added single-subject case failed with `expected [] to deeply equal ["Fiction"]`. +- GREEN: the focused test now passes with 3 tests. + +## Verification + +- `pnpm exec vitest run packages/app/src/lib/book/imported-book-meta.test.ts` - 1 file, 3 tests passed. +- `pnpm --filter app build` - passed. +- `$env:TZ='UTC'; pnpm --filter @readany/core test` - 81 files, 588 tests passed. +- `git diff --check` - passed. + +## Concerns + +No known Task 3 concerns. Vite emitted its pre-existing chunk-size and dynamic-import warnings during the successful desktop build. From e721bd00065f51d575188fa191f62b03d4ad0faf Mon Sep 17 00:00:00 2001 From: Chai Date: Sun, 16 Aug 2026 18:41:04 -0400 Subject: [PATCH 06/38] fix(metadata): repair missing legacy details --- .../app-expo/src/lib/book/auto-metadata.ts | 31 ++- .../src/lib/book/imported-book-meta.ts | 10 +- .../src/lib/book/metadata-extractor.test.ts | 261 ++++++++++++++++++ .../src/screens/BookDetailsScreen.tsx | 5 +- packages/app-expo/src/stores/library-store.ts | 13 +- .../src/components/home/BookDetailsDialog.tsx | 28 +- .../app/src/lib/book/auto-metadata.test.ts | 69 +++++ packages/app/src/lib/book/auto-metadata.ts | 18 +- packages/app/src/stores/library-store.ts | 18 +- 9 files changed, 415 insertions(+), 38 deletions(-) create mode 100644 packages/app-expo/src/lib/book/metadata-extractor.test.ts create mode 100644 packages/app/src/lib/book/auto-metadata.test.ts diff --git a/packages/app-expo/src/lib/book/auto-metadata.ts b/packages/app-expo/src/lib/book/auto-metadata.ts index 61549549c..7dcfe57c2 100644 --- a/packages/app-expo/src/lib/book/auto-metadata.ts +++ b/packages/app-expo/src/lib/book/auto-metadata.ts @@ -1,12 +1,15 @@ -import { extractBookMetadata } from "@/lib/book/metadata-extractor"; +import { + createRangeReadableFile, + extractBookMetadataFromFile, +} from "@/lib/book/metadata-extractor"; import { getPlatformService } from "@readany/core/services"; import type { Book } from "@readany/core/types"; import type { ExtractedBookMetadata } from "@readany/core/utils"; -const MOBILE_DETAILS_METADATA_MAX_BYTES = 32 * 1024 * 1024; - export async function extractLocalBookMetadata(book: Book): Promise { - if (book.syncStatus === "remote" || book.format !== "epub" || !book.filePath) return null; + if (book.syncStatus === "remote" || !isRepairableFormat(book.format) || !book.filePath) { + return null; + } try { const platform = getPlatformService(); @@ -15,21 +18,21 @@ export async function extractLocalBookMetadata(book: Book): Promise MOBILE_DETAILS_METADATA_MAX_BYTES) { - console.warn( - `[BookMetadata] Skip details metadata for large EPUB: ${book.meta.title} (${fileSize} bytes)`, - ); - return null; - } + if (fileSize == null) return null; - const fileName = book.filePath.split("/").pop() || `${book.id}.epub`; - return extractBookMetadata(await platform.readFile(filePath), book.format, fileName); + const fileName = book.filePath.split(/[\\/]/).pop() || `${book.id}.${book.format}`; + const rangeReadable = await createRangeReadableFile(filePath, fileSize); + return extractBookMetadataFromFile(rangeReadable, book.format, fileName); } catch (error) { console.warn("[BookMetadata] Failed to extract local metadata:", error); return null; } } +function isRepairableFormat(format: Book["format"]): boolean { + return format === "epub" || format === "mobi" || format === "azw" || format === "azw3"; +} + function isRelativeAppPath(path: string): boolean { return ( !path.startsWith("/") && @@ -39,8 +42,8 @@ function isRelativeAppPath(path: string): boolean { ); } -async function getMobileFileSize(path: string): Promise { +async function getMobileFileSize(path: string): Promise { const LegacyFileSystem = await import("expo-file-system/legacy"); const info = await LegacyFileSystem.getInfoAsync(path); - return info.exists && !info.isDirectory ? (info.size ?? 0) : 0; + return info.exists && !info.isDirectory ? (info.size ?? 0) : null; } diff --git a/packages/app-expo/src/lib/book/imported-book-meta.ts b/packages/app-expo/src/lib/book/imported-book-meta.ts index 9ce207dc9..0da58aa25 100644 --- a/packages/app-expo/src/lib/book/imported-book-meta.ts +++ b/packages/app-expo/src/lib/book/imported-book-meta.ts @@ -8,12 +8,10 @@ export function buildImportedBookMeta(input: { embedded?: Partial | (ExtractedMeta & { coverUrl?: string }); fallbackTitle: string; }): BookMeta { - const merged = mergeBookMetadataSources( - input.existing, - input.opds, - input.embedded, - { title: input.fallbackTitle, author: "" }, - ); + const merged = mergeBookMetadataSources(input.existing, input.opds, input.embedded, { + title: input.fallbackTitle, + author: "", + }); return { ...input.existing, ...merged, diff --git a/packages/app-expo/src/lib/book/metadata-extractor.test.ts b/packages/app-expo/src/lib/book/metadata-extractor.test.ts new file mode 100644 index 000000000..57fda8df6 --- /dev/null +++ b/packages/app-expo/src/lib/book/metadata-extractor.test.ts @@ -0,0 +1,261 @@ +import type { Book } from "@readany/core/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { extractLocalBookMetadata } from "./auto-metadata"; +import { extractBookMetadataFromFile } from "./metadata-extractor"; + +type SparseSegment = { offset: number; bytes: Uint8Array }; + +const LARGE_FILE_SIZE = 33 * 1024 * 1024; + +const mobileFile = vi.hoisted(() => ({ + exists: true, + size: 33 * 1024 * 1024, + read: (_start: number, _end: number) => new Uint8Array(), +})); + +const platform = vi.hoisted(() => ({ + getAppDataDir: vi.fn(async () => "/app"), + joinPath: vi.fn(async (...parts: string[]) => parts.join("/")), + readFile: vi.fn(async () => { + throw new Error("large repair must not read the whole file"); + }), +})); + +vi.mock("@readany/core/services", () => ({ getPlatformService: () => platform })); +vi.mock("@/lib/book/metadata-extractor", async () => import("./metadata-extractor")); +vi.mock("expo-file-system/legacy", () => ({ + EncodingType: { Base64: "base64" }, + getInfoAsync: vi.fn(async () => ({ + exists: mobileFile.exists, + isDirectory: false, + size: mobileFile.size, + })), + readAsStringAsync: vi.fn(async (_uri: string, options: { position: number; length: number }) => + Buffer.from(mobileFile.read(options.position, options.position + options.length)).toString( + "base64", + ), + ), +})); + +describe("range-readable book metadata extraction", () => { + beforeEach(() => { + vi.clearAllMocks(); + mobileFile.exists = true; + }); + + it("extracts metadata from an EPUB larger than 32 MiB using bounded slices", async () => { + const file = createLargeEpubFile(); + + await expect(extractBookMetadataFromFile(file, "epub", "large.epub")).resolves.toMatchObject({ + title: "Large Book", + publisher: "Range Press", + subjects: ["History"], + }); + expect(file.slice).toHaveBeenCalled(); + expectBoundedReads(file.slice); + }); + + it.each(["mobi", "azw", "azw3"])( + "extracts %s metadata from a large file using bounded slices", + async (format) => { + const file = createLargeMobiFile(); + + await expect( + extractBookMetadataFromFile(file, format, `book.${format}`), + ).resolves.toMatchObject({ + title: "Large MOBI", + author: "Author", + }); + expect(file.slice).toHaveBeenCalled(); + expectBoundedReads(file.slice); + }, + ); + + it("routes large local EPUB Book Details repair through range reads", async () => { + const file = createLargeEpubFile(); + mobileFile.size = file.size; + mobileFile.read = file.read; + + await expect( + extractLocalBookMetadata({ + id: "legacy-large", + filePath: "books/large.epub", + format: "epub", + syncStatus: "local", + meta: { title: "Saved title", author: "Saved author" }, + progress: 0, + addedAt: 1, + } as Book), + ).resolves.toMatchObject({ title: "Large Book", publisher: "Range Press" }); + expect(platform.readFile).not.toHaveBeenCalled(); + }); + + it.each(["mobi", "azw", "azw3"])( + "routes local %s Book Details repair through range reads", + async (format) => { + const file = createLargeMobiFile(); + mobileFile.size = file.size; + mobileFile.read = file.read; + + await expect( + extractLocalBookMetadata({ + id: `legacy-${format}`, + filePath: `books/large.${format}`, + format, + syncStatus: "local", + meta: { title: "", author: "" }, + progress: 0, + addedAt: 1, + } as Book), + ).resolves.toMatchObject({ title: "Large MOBI", author: "Author" }); + expect(platform.readFile).not.toHaveBeenCalled(); + }, + ); + + it("leaves a missing local file untouched without trying a whole-file read", async () => { + mobileFile.exists = false; + + await expect( + extractLocalBookMetadata({ + id: "missing", + filePath: "books/missing.epub", + format: "epub", + syncStatus: "local", + meta: { title: "Saved title", author: "Saved author" }, + progress: 0, + addedAt: 1, + } as Book), + ).resolves.toBeNull(); + expect(platform.readFile).not.toHaveBeenCalled(); + }); +}); + +function expectBoundedReads(slice: ReturnType) { + for (const [start = 0, end = start] of slice.mock.calls as Array<[number?, number?]>) { + expect(end - start).toBeLessThanOrEqual(256 * 1024); + } +} + +function createLargeEpubFile() { + const containerXml = encode( + '', + ); + const opfXml = encode( + "Large BookAuthorRange PressHistory", + ); + const entries = [ + { name: "META-INF/container.xml", bytes: containerXml }, + { name: "content.opf", bytes: opfXml }, + ]; + const segments: SparseSegment[] = []; + const directoryEntries: Uint8Array[] = []; + let localOffset = 0; + + for (const entry of entries) { + const name = encode(entry.name); + const local = new Uint8Array(30 + name.length + entry.bytes.length); + const localView = new DataView(local.buffer); + localView.setUint32(0, 0x04034b50, true); + localView.setUint32(18, entry.bytes.length, true); + localView.setUint32(22, entry.bytes.length, true); + localView.setUint16(26, name.length, true); + local.set(name, 30); + local.set(entry.bytes, 30 + name.length); + segments.push({ offset: localOffset, bytes: local }); + + const central = new Uint8Array(46 + name.length); + const centralView = new DataView(central.buffer); + centralView.setUint32(0, 0x02014b50, true); + centralView.setUint32(20, entry.bytes.length, true); + centralView.setUint32(24, entry.bytes.length, true); + centralView.setUint16(28, name.length, true); + centralView.setUint32(42, localOffset, true); + central.set(name, 46); + directoryEntries.push(central); + localOffset += local.length; + } + + const directory = concat(directoryEntries); + const eocd = new Uint8Array(22); + const directoryOffset = LARGE_FILE_SIZE - eocd.length - directory.length; + const eocdView = new DataView(eocd.buffer); + eocdView.setUint32(0, 0x06054b50, true); + eocdView.setUint16(8, entries.length, true); + eocdView.setUint16(10, entries.length, true); + eocdView.setUint32(12, directory.length, true); + eocdView.setUint32(16, directoryOffset, true); + segments.push({ offset: directoryOffset, bytes: directory }); + segments.push({ offset: LARGE_FILE_SIZE - eocd.length, bytes: eocd }); + return createSparseFile(LARGE_FILE_SIZE, segments); +} + +function createLargeMobiFile() { + const pdbHeader = new Uint8Array(78); + new DataView(pdbHeader.buffer).setUint16(76, 2, false); + + const recordTable = new Uint8Array(16); + const recordTableView = new DataView(recordTable.buffer); + recordTableView.setUint32(0, 256, false); + recordTableView.setUint32(8, 1024, false); + + const record = new Uint8Array(768); + const view = new DataView(record.buffer); + record.set(encode("MOBI"), 16); + view.setUint32(20, 132, false); + view.setUint32(28, 65001, false); + view.setUint32(36, 8, false); + view.setUint32(84, 300, false); + view.setUint32(88, 10, false); + view.setUint32(108, 1, false); + view.setUint32(128, 0b1000000, false); + record.set(encode("EXTH"), 148); + view.setUint32(152, 26, false); + view.setUint32(156, 1, false); + view.setUint32(160, 100, false); + view.setUint32(164, 14, false); + record.set(encode("Author"), 168); + record.set(encode("Large MOBI"), 300); + + return createSparseFile(LARGE_FILE_SIZE, [ + { offset: 0, bytes: pdbHeader }, + { offset: 78, bytes: recordTable }, + { offset: 256, bytes: record }, + ]); +} + +function createSparseFile(size: number, segments: SparseSegment[]) { + const read = (start: number, end: number) => { + const result = new Uint8Array(Math.max(0, end - start)); + for (const segment of segments) { + const overlapStart = Math.max(start, segment.offset); + const overlapEnd = Math.min(end, segment.offset + segment.bytes.length); + if (overlapEnd <= overlapStart) continue; + result.set( + segment.bytes.subarray(overlapStart - segment.offset, overlapEnd - segment.offset), + overlapStart - start, + ); + } + return result; + }; + return { + size, + read, + slice: vi.fn((start = 0, end = size) => ({ + arrayBuffer: async () => read(start, end).buffer, + })), + }; +} + +function concat(chunks: Uint8Array[]): Uint8Array { + const result = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0)); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; + } + return result; +} + +function encode(value: string): Uint8Array { + return new TextEncoder().encode(value); +} diff --git a/packages/app-expo/src/screens/BookDetailsScreen.tsx b/packages/app-expo/src/screens/BookDetailsScreen.tsx index 4def2ea85..77956841e 100644 --- a/packages/app-expo/src/screens/BookDetailsScreen.tsx +++ b/packages/app-expo/src/screens/BookDetailsScreen.tsx @@ -305,7 +305,9 @@ export function BookDetailsScreen({ route }: Props) { if (!book) return; if (hydratedBookIdRef.current === book.id) return; hydratedBookIdRef.current = book.id; - setValues(createBookMetadataFormValues(book)); + const nextValues = createBookMetadataFormValues(book); + latestValuesRef.current = nextValues; + setValues(nextValues); }, [book]); useEffect(() => { @@ -325,6 +327,7 @@ export function BookDetailsScreen({ route }: Props) { ? mergeMissingBookMetadataValues(latestValuesRef.current, metadata) : null; if (!nextValues) return; + latestValuesRef.current = nextValues; setValues(nextValues); updateBook(book.id, buildBookMetadataUpdate(book, nextValues)); }); diff --git a/packages/app-expo/src/stores/library-store.ts b/packages/app-expo/src/stores/library-store.ts index b679e31a3..3122c7682 100644 --- a/packages/app-expo/src/stores/library-store.ts +++ b/packages/app-expo/src/stores/library-store.ts @@ -1,10 +1,10 @@ +import { buildImportedBookMeta } from "@/lib/book/imported-book-meta"; import { + type ExtractedMeta, createRangeReadableFile, extractBookMetadata, extractBookMetadataFromFile, - type ExtractedMeta, } from "@/lib/book/metadata-extractor"; -import { buildImportedBookMeta } from "@/lib/book/imported-book-meta"; import { queueBook as queueAutoVectorize } from "@/lib/rag/auto-vectorize-service"; import { type ImportBooksResult, @@ -15,7 +15,14 @@ import { import * as db from "@readany/core/db/database"; import { runWithDbRetry } from "@readany/core/db/write-retry"; import { getPlatformService } from "@readany/core/services"; -import type { Book, BookGroup, BookMeta, LibraryFilter, SortField, SortOrder } from "@readany/core/types"; +import type { + Book, + BookGroup, + BookMeta, + LibraryFilter, + SortField, + SortOrder, +} from "@readany/core/types"; import { generateId } from "@readany/core/utils"; import { create } from "zustand"; import { debouncedSave, loadFromFS } from "./persist"; diff --git a/packages/app/src/components/home/BookDetailsDialog.tsx b/packages/app/src/components/home/BookDetailsDialog.tsx index 38041ff0a..70f2aefb7 100644 --- a/packages/app/src/components/home/BookDetailsDialog.tsx +++ b/packages/app/src/components/home/BookDetailsDialog.tsx @@ -25,7 +25,6 @@ import { import { Textarea } from "@/components/ui/textarea"; import { useResolvedSrc } from "@/hooks/use-resolved-src"; import { extractLocalBookMetadata } from "@/lib/book/auto-metadata"; -import { invoke } from "@tauri-apps/api/core"; import { useAppStore } from "@/stores/app-store"; import { useLibraryStore } from "@/stores/library-store"; import type { Book, BookReview } from "@readany/core/types"; @@ -40,6 +39,7 @@ import { mergeMissingBookMetadataValues, splitEditableList, } from "@readany/core/utils"; +import { invoke } from "@tauri-apps/api/core"; import type { TFunction } from "i18next"; import { BookOpen, @@ -282,12 +282,14 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo const coverSrc = useResolvedSrc(values?.coverUrl); const hydratedBookIdRef = useRef(null); const autoFilledBookIdRef = useRef(null); + const latestValuesRef = useRef(null); const autoSaveTimerRef = useRef(null); useEffect(() => { if (!open) { hydratedBookIdRef.current = null; autoFilledBookIdRef.current = null; + latestValuesRef.current = null; setEditingBasics(false); setEditingTitleField(null); setEditingReviewId(null); @@ -299,7 +301,9 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo if (!book) return; if (hydratedBookIdRef.current === book.id) return; hydratedBookIdRef.current = book.id; - setValues(createBookMetadataFormValues(book)); + const nextValues = createBookMetadataFormValues(book); + latestValuesRef.current = nextValues; + setValues(nextValues); setEditingBasics(false); setEditingTitleField(null); setEditingReviewId(null); @@ -308,6 +312,10 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo setDraftActionResult(null); }, [book, open]); + useEffect(() => { + latestValuesRef.current = values; + }, [values]); + useEffect(() => { if (!open || !book || !values) return; if (autoFilledBookIdRef.current === book.id) return; @@ -317,16 +325,19 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo let cancelled = false; void extractLocalBookMetadata(book).then((metadata) => { if (cancelled || !metadata) return; - setValues((current) => { - if (!current) return current; - return mergeMissingBookMetadataValues(current, metadata) ?? current; - }); + const nextValues = latestValuesRef.current + ? mergeMissingBookMetadataValues(latestValuesRef.current, metadata) + : null; + if (!nextValues) return; + latestValuesRef.current = nextValues; + setValues(nextValues); + updateBook(book.id, buildBookMetadataUpdate(book, nextValues)); }); return () => { cancelled = true; }; - }, [book, open, values]); + }, [book, open, updateBook, values]); const groupName = useMemo(() => { const groupId = values?.groupId ?? book?.groupId; @@ -936,7 +947,8 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo ? t("library.detailsDraftCreated", "Draft created successfully") : t("library.detailsDraftCreateFailed", "Draft creation failed")}

- {draftActionResult.ok && parseDraftCreateResult(draftActionResult)?.ok ? ( + {draftActionResult.ok && + parseDraftCreateResult(draftActionResult)?.ok ? ( + {isExpanded ? ( +
+ {description ? ( +
+ ) : null} + {publication.subjects.length ? ( +
+ {publication.subjects.slice(0, 8).map((subject) => ( + + {subject} + + ))} +
+ ) : null} + {formats.length ? ( + + ) : ( +

+ {t("library.opds.unsupportedExplanation")} +

+ )} +
+ ) : null} + + ); + }; + + const initialLoading = content.status === "idle" || (content.status === "loading" && !feed); + const showError = content.status === "error"; + + return ( +
+
+
+ +
+
+ {catalog.name} +
+

+ {feed?.title ?? t("library.opds.catalog")} +

+
+ +
+ {feed?.search ? ( +
+ setQuery(event.target.value)} + placeholder={t("library.opds.searchPlaceholder")} + aria-label={t("library.opds.searchPlaceholder")} + /> + +
+ ) : null} +
+ +
+ {initialLoading ? ( + + +

{t("library.opds.loading")}

+

+ {t("library.opds.loadingHint")} +

+
+ ) : null} + + {showError ? ( +
+ +
+

{t("library.opds.loadFailed")}

+

+ {t(`library.opds.errors.${content.error}`)} +

+
+ + {content.error === "unauthorized" ? ( + + ) : null} +
+
+
+ ) : null} + + {feed ? ( +
+ {feed.subtitle ? ( +

{feed.subtitle}

+ ) : null} + + {feed.navigation.length ? ( +
+ {t("library.opds.collections")} +
+ {feed.navigation.map((item) => ( + + ))} +
+
+ ) : null} + + {feed.facets.map((facet, index) => ( +
+ {facet.title} +
+ {facet.links.map((link) => ( + + ))} +
+
+ ))} + + {feed.publications.length ? ( +
+ {t("library.opds.books")} +
+ {feed.publications.map((publication) => + renderPublication(publication, "publication"), + )} +
+
+ ) : null} + + {feed.groups.map((group, groupIndex) => ( +
+ {group.title} + {group.navigation.length ? ( +
+ {group.navigation.map((item) => ( + + ))} +
+ ) : null} +
+ {group.publications.map((publication) => + renderPublication(publication, `group-${groupIndex}`), + )} +
+
+ ))} + + {!feed.navigation.length && !feed.publications.length && !feed.groups.length ? ( +
+ +

{t("library.opds.empty")}

+

{t("library.opds.emptyHint")}

+
+ ) : null} + + {feed.previousUrl || feed.nextUrl ? ( + + ) : null} +
+ ) : null} +
+ + {downloadState.status !== "idle" ? ( +
+
+ {downloadState.status === "downloading" || downloadState.status === "importing" ? ( + + ) : downloadState.status === "success" ? ( + + ) : ( + + )} +
+
{downloadState.title}
+
+ {downloadState.status === "downloading" + ? progress?.total + ? t("library.opds.downloadingProgress", { + percent: Math.round((progress.loaded / progress.total) * 100), + }) + : t("library.opds.downloading") + : downloadState.status === "importing" + ? t("library.opds.importing") + : downloadState.status === "success" + ? downloadState.imported + ? t("library.opds.imported") + : t("library.opds.alreadyImported") + : t(`library.opds.errors.${downloadState.error}`)} +
+
+ {downloadState.status === "downloading" ? ( + + ) : downloadState.status === "error" && lastDownload ? ( + + ) : downloadState.status === "success" ? ( + + ) : null} +
+ {downloadState.status === "downloading" || downloadState.status === "importing" ? ( +
+
+
+ ) : null} +
+ ) : null} + + !open && setFormatChoice(undefined)}> + + + {t("library.opds.chooseFormat")} + {formatChoice?.publication.title} + +
+ {formatChoice?.acquisitions.map((acquisition) => ( + + ))} +
+
+
+
+ ); +} diff --git a/packages/app/src/components/home/OpdsCatalogFormDialog.tsx b/packages/app/src/components/home/OpdsCatalogFormDialog.tsx new file mode 100644 index 000000000..7d12fc617 --- /dev/null +++ b/packages/app/src/components/home/OpdsCatalogFormDialog.tsx @@ -0,0 +1,299 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { PasswordInput } from "@/components/ui/password-input"; +import { Switch } from "@/components/ui/switch"; +import { + type OpdsCatalog, + type OpdsCatalogAuth, + type OpdsCatalogStore, + classifyOpdsUrl, +} from "@readany/core"; +import { Loader2, ShieldAlert } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +interface OpdsCatalogFormDialogProps { + open: boolean; + catalog?: OpdsCatalog; + store: OpdsCatalogStore; + onOpenChange(open: boolean): void; + onSaved(): void; +} + +export function OpdsCatalogFormDialog({ + open, + catalog, + store, + onOpenChange, + onSaved, +}: OpdsCatalogFormDialogProps) { + const { t } = useTranslation(); + const [name, setName] = useState(""); + const [url, setUrl] = useState(""); + const [auth, setAuth] = useState("anonymous"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [enabled, setEnabled] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [confirmingLocalHttp, setConfirmingLocalHttp] = useState(false); + const [error, setError] = useState(); + + useEffect(() => { + if (!open) { + setPassword(""); + return; + } + setName(catalog?.name ?? ""); + setUrl(catalog?.url ?? ""); + setAuth(catalog?.auth ?? "anonymous"); + setUsername(catalog?.username ?? ""); + setPassword(""); + setEnabled(catalog?.enabled ?? true); + setSubmitting(false); + setConfirmingLocalHttp(false); + setError(undefined); + }, [catalog, open]); + + const hasPassword = (catalog?.passwordStorage ?? "none") !== "none"; + const canSubmit = + name.trim().length > 0 && + url.trim().length > 0 && + (auth === "anonymous" || + (username.trim().length > 0 && (password.length > 0 || hasPassword))) && + !submitting; + + const persist = async () => { + if (!canSubmit) return; + setSubmitting(true); + setError(undefined); + try { + const input = { + name: name.trim(), + url: url.trim(), + auth, + enabled, + ...(auth === "basic" + ? { username: username.trim(), ...(password ? { password } : {}) } + : {}), + }; + if (catalog) await store.updateCatalog(catalog.id, input); + else await store.addCatalog(input); + setPassword(""); + onSaved(); + } catch { + setError(t("library.opds.form.saveFailed")); + } finally { + setSubmitting(false); + } + }; + + const validateAndSave = () => { + if (!canSubmit) return; + const classification = classifyOpdsUrl(url.trim()); + if (!classification.allowed) { + const key = + classification.reason === "public-http" + ? "publicHttpBlocked" + : classification.reason === "credentials-not-allowed" + ? "credentialsInUrl" + : "invalidUrl"; + setError(t(`library.opds.form.${key}`)); + return; + } + if (classification.requiresInsecureConfirmation) { + setConfirmingLocalHttp(true); + return; + } + void persist(); + }; + + return ( + + + + + {catalog ? t("library.opds.form.editTitle") : t("library.opds.form.addTitle")} + + {t("library.opds.form.subtitle")} + + +
{ + event.preventDefault(); + validateAndSave(); + }} + > +
+ + +
+ +
+ {t("library.opds.form.authentication")} +
+ {(["anonymous", "basic"] as const).map((mode) => ( + + ))} +
+
+ + {auth === "basic" ? ( +
+ + + {catalog ? ( + + {catalog.passwordStorage === "persistent" + ? t("library.opds.form.passwordStoredSecurely") + : catalog.passwordStorage === "session-only" + ? t("library.opds.form.passwordSessionOnly") + : t("library.opds.form.passwordMissing")} + + ) : null} +
+ ) : null} + +
+
+
{t("library.opds.form.enabled")}
+

+ {t("library.opds.form.enabledHint")} +

+
+ +
+ + {confirmingLocalHttp ? ( +
+
+ +
+

{t("library.opds.form.localHttpTitle")}

+

+ {t("library.opds.form.localHttpWarning")} +

+
+ + +
+
+
+
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + + + + +
+
+
+ ); +} diff --git a/packages/app/src/components/home/OpdsCatalogsDialog.tsx b/packages/app/src/components/home/OpdsCatalogsDialog.tsx new file mode 100644 index 000000000..8548bdf5a --- /dev/null +++ b/packages/app/src/components/home/OpdsCatalogsDialog.tsx @@ -0,0 +1,389 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Switch } from "@/components/ui/switch"; +import type { OpdsCatalog } from "@readany/core"; +import { + BookOpen, + ChevronRight, + EyeOff, + Globe2, + Loader2, + Pencil, + Plus, + RotateCcw, + Trash2, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { OpdsBrowser } from "./OpdsBrowser"; +import { OpdsCatalogFormDialog } from "./OpdsCatalogFormDialog"; +import { opdsDesktopRuntime } from "./opds-desktop-runtime"; + +interface OpdsCatalogsDialogProps { + open: boolean; + onOpenChange(open: boolean): void; +} + +export function OpdsCatalogsDialog({ open, onOpenChange }: OpdsCatalogsDialogProps) { + const { t } = useTranslation(); + const store = useMemo(() => opdsDesktopRuntime.getCatalogStore(), []); + const client = useMemo(() => opdsDesktopRuntime.getClient(), []); + const [catalogs, setCatalogs] = useState([]); + const [selected, setSelected] = useState(); + const [editing, setEditing] = useState(); + const [formOpen, setFormOpen] = useState(false); + const [deleting, setDeleting] = useState(); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(); + const [error, setError] = useState(); + const backHandler = useRef<(() => boolean) | undefined>(undefined); + + const syncCatalogs = useCallback(() => { + const next = store.listCatalogs({ includeHidden: true }); + setCatalogs(next); + setSelected((current) => (current ? next.find(({ id }) => id === current.id) : undefined)); + }, [store]); + + useEffect(() => { + if (!open) { + setSelected(undefined); + setEditing(undefined); + setFormOpen(false); + setDeleting(undefined); + setError(undefined); + return; + } + let active = true; + setLoading(true); + void opdsDesktopRuntime + .ensureCatalogsLoaded() + .then(() => { + if (!active) return; + syncCatalogs(); + setError(undefined); + }) + .catch(() => { + if (active) setError(t("library.opds.catalogsLoadFailed")); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [open, syncCatalogs, t]); + + const mutate = async (catalogId: string, operation: () => Promise) => { + setBusyId(catalogId); + setError(undefined); + try { + await operation(); + syncCatalogs(); + } catch { + setError(t("library.opds.catalogActionFailed")); + } finally { + setBusyId(undefined); + } + }; + + const authenticationLabel = (catalog: OpdsCatalog) => { + if (catalog.auth === "anonymous") return t("library.opds.authAnonymous"); + if (catalog.passwordStorage === "persistent") return t("library.opds.authSecure"); + if (catalog.passwordStorage === "session-only") return t("library.opds.authSession"); + return t("library.opds.authMissing"); + }; + + const visibleCatalogs = catalogs.filter((catalog) => !catalog.hidden); + const hiddenBuiltIns = catalogs.filter((catalog) => catalog.builtIn && catalog.hidden); + + return ( + <> + + { + if (!selected) return; + event.preventDefault(); + backHandler.current?.(); + }} + > + {selected ? ( + setSelected(undefined)} + onEditCredentials={() => { + if (selected.builtIn) return; + setEditing(selected); + setFormOpen(true); + }} + registerBackHandler={(handler) => { + backHandler.current = handler; + }} + /> + ) : ( + <> + +
+
+
+ + {t("library.opds.readerEyebrow")} +
+ + {t("library.opds.catalogsTitle")} + + + {t("library.opds.catalogsSubtitle")} + +
+ +
+
+ +
+
+

+ {t("library.opds.readerIntro")} +

+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {loading ? ( + + +

+ {t("library.opds.loadingCatalogs")} +

+
+ ) : ( +
+
+

+ {t("library.opds.available")} +

+
+ {visibleCatalogs.map((catalog) => { + const busy = busyId === catalog.id; + return ( +
+ +
+ {catalog.builtIn ? ( + <> + + {t("library.opds.builtInLocked")} + + + + ) : ( + <> + + {catalog.enabled + ? t("library.opds.enabled") + : t("library.opds.disabled")} + + + void mutate(catalog.id, () => + store.setCatalogEnabled(catalog.id, enabled), + ) + } + aria-label={t("library.opds.toggleCatalog", { + name: catalog.name, + })} + /> + + + + )} +
+
+ ); + })} +
+
+ + {hiddenBuiltIns.length ? ( +
+

+ {t("library.opds.hiddenPresets")} +

+
+ {hiddenBuiltIns.map((catalog) => ( +
+ + + {catalog.name} + + +
+ ))} +
+
+ ) : null} +
+ )} +
+ + )} +
+
+ + { + setFormOpen(false); + setEditing(undefined); + syncCatalogs(); + }} + /> + + !next && setDeleting(undefined)}> + + + {t("library.opds.deleteTitle")} + {t("library.opds.deleteDescription")} + + + + + + + + + ); +} diff --git a/packages/app/src/components/home/opds-desktop-request-controller.test.ts b/packages/app/src/components/home/opds-desktop-request-controller.test.ts new file mode 100644 index 000000000..03ae0d606 --- /dev/null +++ b/packages/app/src/components/home/opds-desktop-request-controller.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { createOpdsDesktopRequestController } from "./opds-desktop-request-controller"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("desktop OPDS request controller", () => { + it("aborts the previous request and suppresses its stale result", async () => { + const first = deferred(); + const controller = createOpdsDesktopRequestController({ + prepare: async () => "credentials", + }); + + const firstRun = controller.run((_credentials, signal) => { + expect(signal.aborted).toBe(false); + return first.promise; + }); + await expect(controller.run(async () => "newest")).resolves.toBe("newest"); + first.resolve("stale"); + await expect(firstRun).resolves.toBeUndefined(); + + expect(controller.isActive()).toBe(false); + }); + + it("suppresses callbacks after cancellation or disposal", async () => { + const pending = deferred(); + const controller = createOpdsDesktopRequestController({ + prepare: async () => undefined, + }); + + const run = controller.run(() => pending.promise); + controller.dispose(); + pending.resolve("late"); + await expect(run).resolves.toBeUndefined(); + + expect(controller.isActive()).toBe(false); + }); +}); diff --git a/packages/app/src/components/home/opds-desktop-request-controller.ts b/packages/app/src/components/home/opds-desktop-request-controller.ts new file mode 100644 index 000000000..c5a0d37b8 --- /dev/null +++ b/packages/app/src/components/home/opds-desktop-request-controller.ts @@ -0,0 +1,50 @@ +interface DesktopRequestControllerOptions { + prepare(signal: AbortSignal): Promise; +} + +export function createOpdsDesktopRequestController({ + prepare, +}: DesktopRequestControllerOptions) { + let sequence = 0; + let active: { id: number; controller: AbortController } | undefined; + let disposed = false; + + const cancel = () => { + sequence += 1; + active?.controller.abort(); + active = undefined; + }; + + return { + async run( + operation: (credentials: TCredentials, signal: AbortSignal) => Promise, + ): Promise { + if (disposed) return undefined; + cancel(); + const id = ++sequence; + const controller = new AbortController(); + active = { id, controller }; + const isCurrent = () => + !disposed && active?.id === id && !controller.signal.aborted && sequence === id; + try { + const credentials = await prepare(controller.signal); + if (!isCurrent()) return undefined; + const result = await operation(credentials, controller.signal); + return isCurrent() ? result : undefined; + } catch (error) { + if (!isCurrent()) return undefined; + throw error; + } finally { + if (active?.id === id) active = undefined; + } + }, + cancel, + dispose(): void { + disposed = true; + cancel(); + }, + isActive(): boolean { + return active !== undefined; + }, + }; +} diff --git a/packages/app/src/components/home/opds-desktop-runtime.ts b/packages/app/src/components/home/opds-desktop-runtime.ts new file mode 100644 index 000000000..44c1a4d80 --- /dev/null +++ b/packages/app/src/components/home/opds-desktop-runtime.ts @@ -0,0 +1,3 @@ +import { createOpdsRuntime, getPlatformService } from "@readany/core"; + +export const opdsDesktopRuntime = createOpdsRuntime(getPlatformService); diff --git a/packages/app/src/components/home/useOpdsDownload.test.ts b/packages/app/src/components/home/useOpdsDownload.test.ts index 50b5091ca..f704c7154 100644 --- a/packages/app/src/components/home/useOpdsDownload.test.ts +++ b/packages/app/src/components/home/useOpdsDownload.test.ts @@ -59,6 +59,32 @@ function dependencies() { } describe("desktop OPDS download adapter", () => { + it("announces the import point of no return after download and before library mutation", async () => { + const deps = dependencies(); + const events: string[] = []; + deps.platform.writeFile.mockImplementationOnce(async () => { + events.push("downloaded"); + }); + deps.importBooks.mockImplementationOnce(async () => { + events.push("importing"); + return { + imported: [{ id: "desktop-book" }], + skippedDuplicates: [], + failures: [], + } as unknown as ImportBooksResult; + }); + const run = createOpdsDownloadAdapter(deps as never); + + await run({ + publication, + acquisition: selected, + catalogOrigin: "https://catalog.test", + onImportStart: () => events.push("point-of-no-return"), + }); + + expect(events).toEqual(["downloaded", "point-of-no-return", "importing"]); + }); + it("passes metadata through the backward-compatible desktop input and cleans once", async () => { const deps = dependencies(); const run = createOpdsDownloadAdapter(deps as never); diff --git a/packages/app/src/components/home/useOpdsDownload.ts b/packages/app/src/components/home/useOpdsDownload.ts index 4fae565c8..bef25ac92 100644 --- a/packages/app/src/components/home/useOpdsDownload.ts +++ b/packages/app/src/components/home/useOpdsDownload.ts @@ -29,6 +29,7 @@ export interface OpdsDownloadRequest { credentials?: OpdsCredentials; signal?: AbortSignal; onProgress?: (progress: OpdsDownloadProgress) => void; + onImportStart?: () => void; } export interface OpdsImportDownloadResult { @@ -99,6 +100,7 @@ export function createOpdsDownloadAdapter(dependencies: OpdsDownloadAdapterDepen platform: dependencies.platform, destinationPath: temporaryPath, }); + request.onImportStart?.(); try { importResult = await dependencies.importBooks( [ diff --git a/packages/core/src/i18n/locales/en/library.json b/packages/core/src/i18n/locales/en/library.json index a6229cf6a..16d0381a0 100644 --- a/packages/core/src/i18n/locales/en/library.json +++ b/packages/core/src/i18n/locales/en/library.json @@ -1,5 +1,106 @@ { "library": { + "opds": { + "alreadyImported": "This book is already in your library", + "authAnonymous": "No sign-in required", + "authMissing": "Basic sign-in · Password needed", + "authSecure": "Basic sign-in · Secure storage", + "authSession": "Basic sign-in · This session only", + "available": "Available catalogs", + "back": "Back", + "books": "Books", + "browseCatalog": "Browse {{name}}", + "builtIn": "BUILT IN", + "builtInLocked": "Preset URL is read-only", + "catalog": "Catalog", + "catalogActionFailed": "The catalog could not be updated.", + "catalogsLoadFailed": "Catalogs could not be loaded.", + "catalogsSubtitle": "Browse public shelves and your own OPDS servers.", + "catalogsTitle": "Online catalogs", + "cancel": "Cancel", + "chooseFormat": "Choose format", + "collections": "Collections", + "continue": "Continue", + "delete": "Delete", + "deleteCatalog": "Delete {{name}}", + "deleteDescription": "The catalog and its securely stored password will be removed.", + "deleteTitle": "Delete this catalog?", + "disabled": "Disabled", + "done": "Done", + "downloadAndImport": "Download & import", + "downloadFormat": "Download {{format}}", + "downloading": "Downloading…", + "downloadingProgress": "Downloading {{percent}}%", + "downloadTitle": "Download {{title}}", + "editCatalog": "Edit {{name}}", + "editCredentials": "Edit credentials", + "empty": "Nothing on this shelf yet", + "emptyHint": "Try another collection or go back one level.", + "enabled": "Enabled", + "hiddenPresets": "Hidden presets", + "hideCatalog": "Hide {{name}}", + "imported": "Imported to your library", + "importing": "Adding to your library…", + "loadFailed": "Catalog unavailable", + "loading": "Loading catalog…", + "loadingCatalogs": "Opening catalogs…", + "loadingHint": "Reading this shelf and checking its available formats.", + "noCompatibleFormat": "No compatible download format", + "next": "Next", + "previous": "Previous", + "publicationDetails": "Details for {{title}}", + "readerEyebrow": "Open shelves", + "readerIntro": "Follow a catalog into its collections, choose a format, and bring the book straight to your library.", + "refresh": "Refresh", + "restore": "Restore", + "retry": "Retry", + "save": "Save", + "search": "Search", + "restoreCatalog": "Restore {{name}}", + "searchPlaceholder": "Search this catalog…", + "toggleCatalog": "Enable or disable {{name}}", + "unknownAuthor": "Unknown author", + "unsupportedExplanation": "This entry does not advertise a direct book format that ReadAny can import.", + "form": { + "addTitle": "Add catalog", + "anonymous": "Anonymous", + "authentication": "Authentication", + "basic": "Basic sign-in", + "credentialsInUrl": "Keep credentials out of the catalog URL.", + "editTitle": "Edit catalog", + "enabled": "Catalog enabled", + "enabledHint": "Disabled catalogs stay saved but cannot be browsed.", + "invalidUrl": "Enter a valid HTTPS or local HTTP catalog URL.", + "localHttpTitle": "Use a local HTTP catalog?", + "localHttpWarning": "Traffic and sign-in details can be read on your local network. Continue only if you trust it.", + "name": "Name", + "namePlaceholder": "My catalog", + "password": "Password", + "passwordMissing": "No password is saved", + "passwordSessionOnly": "Password available for this session only", + "passwordStoredSecurely": "Password saved in secure storage", + "passwordUnchanged": "Leave blank to keep saved password", + "publicHttpBlocked": "Public catalogs must use HTTPS.", + "saveFailed": "The catalog could not be saved.", + "subtitle": "Connect a book catalog without putting its password in the address.", + "url": "Catalog URL", + "username": "Username" + }, + "errors": { + "asset-too-large": "This book is too large to download safely.", + "cancelled": "The operation was cancelled.", + "download-failed": "The book download failed.", + "download-in-progress": "Another catalog download is already running.", + "import-failed": "The book downloaded, but could not be added to your library.", + "insecure-url": "This catalog address or redirect is not secure.", + "invalid-catalog": "This address did not return a valid OPDS catalog.", + "too-large": "This catalog response is too large to open safely.", + "unauthorized": "The catalog rejected the saved sign-in.", + "unreachable": "The catalog could not be reached.", + "unsupported-acquisition": "This book does not offer a format ReadAny can import.", + "unsupported-auth": "This catalog uses an authentication method ReadAny does not support yet." + } + }, "sortRecent": "Recently Opened", "sortAdded": "Date Added", "sortTitle": "Title", diff --git a/packages/core/src/i18n/locales/es/library.json b/packages/core/src/i18n/locales/es/library.json index 5dc3c17f2..3a0bbac11 100644 --- a/packages/core/src/i18n/locales/es/library.json +++ b/packages/core/src/i18n/locales/es/library.json @@ -1,5 +1,106 @@ { "library": { + "opds": { + "alreadyImported": "Este libro ya está en tu biblioteca", + "authAnonymous": "No requiere iniciar sesión", + "authMissing": "Acceso básico · Falta la contraseña", + "authSecure": "Acceso básico · Almacenamiento seguro", + "authSession": "Acceso básico · Solo esta sesión", + "available": "Catálogos disponibles", + "back": "Atrás", + "books": "Libros", + "browseCatalog": "Explorar {{name}}", + "builtIn": "INCLUIDO", + "builtInLocked": "La URL predefinida es de solo lectura", + "catalog": "Catálogo", + "catalogActionFailed": "No se pudo actualizar el catálogo.", + "catalogsLoadFailed": "No se pudieron cargar los catálogos.", + "catalogsSubtitle": "Explora colecciones públicas y tus propios servidores OPDS.", + "catalogsTitle": "Catálogos en línea", + "cancel": "Cancelar", + "chooseFormat": "Elegir formato", + "collections": "Colecciones", + "continue": "Continuar", + "delete": "Eliminar", + "deleteCatalog": "Eliminar {{name}}", + "deleteDescription": "Se eliminarán el catálogo y su contraseña guardada de forma segura.", + "deleteTitle": "¿Eliminar este catálogo?", + "disabled": "Desactivado", + "done": "Listo", + "downloadAndImport": "Descargar e importar", + "downloadFormat": "Descargar {{format}}", + "downloading": "Descargando…", + "downloadingProgress": "Descargando: {{percent}} %", + "downloadTitle": "Descargar {{title}}", + "editCatalog": "Editar {{name}}", + "editCredentials": "Editar credenciales", + "empty": "Aún no hay nada en esta colección", + "emptyHint": "Prueba otra colección o vuelve al nivel anterior.", + "enabled": "Activado", + "hiddenPresets": "Catálogos predefinidos ocultos", + "hideCatalog": "Ocultar {{name}}", + "imported": "Importado a tu biblioteca", + "importing": "Añadiendo a tu biblioteca…", + "loadFailed": "Catálogo no disponible", + "loading": "Cargando catálogo…", + "loadingCatalogs": "Abriendo catálogos…", + "loadingHint": "Leyendo esta colección y comprobando los formatos disponibles.", + "noCompatibleFormat": "No hay un formato de descarga compatible", + "next": "Siguiente", + "previous": "Anterior", + "publicationDetails": "Detalles de {{title}}", + "readerEyebrow": "Colecciones abiertas", + "readerIntro": "Recorre las colecciones de un catálogo, elige un formato y lleva el libro directamente a tu biblioteca.", + "refresh": "Actualizar", + "restore": "Restaurar", + "retry": "Reintentar", + "save": "Guardar", + "search": "Buscar", + "restoreCatalog": "Restaurar {{name}}", + "searchPlaceholder": "Buscar en este catálogo…", + "toggleCatalog": "Activar o desactivar {{name}}", + "unknownAuthor": "Autor desconocido", + "unsupportedExplanation": "Esta entrada no ofrece un formato de libro directo que ReadAny pueda importar.", + "form": { + "addTitle": "Añadir catálogo", + "anonymous": "Anónimo", + "authentication": "Autenticación", + "basic": "Acceso básico", + "credentialsInUrl": "No incluyas credenciales en la URL del catálogo.", + "editTitle": "Editar catálogo", + "enabled": "Catálogo activado", + "enabledHint": "Los catálogos desactivados permanecen guardados, pero no se pueden explorar.", + "invalidUrl": "Introduce una URL HTTPS o HTTP local válida para el catálogo.", + "localHttpTitle": "¿Usar un catálogo HTTP local?", + "localHttpWarning": "El tráfico y los datos de acceso pueden leerse en tu red local. Continúa solo si confías en ella.", + "name": "Nombre", + "namePlaceholder": "Mi catálogo", + "password": "Contraseña", + "passwordMissing": "No hay ninguna contraseña guardada", + "passwordSessionOnly": "Contraseña disponible solo durante esta sesión", + "passwordStoredSecurely": "Contraseña guardada en almacenamiento seguro", + "passwordUnchanged": "Déjalo en blanco para conservar la contraseña guardada", + "publicHttpBlocked": "Los catálogos públicos deben usar HTTPS.", + "saveFailed": "No se pudo guardar el catálogo.", + "subtitle": "Conecta un catálogo de libros sin poner la contraseña en la dirección.", + "url": "URL del catálogo", + "username": "Nombre de usuario" + }, + "errors": { + "asset-too-large": "Este libro es demasiado grande para descargarlo de forma segura.", + "cancelled": "La operación se canceló.", + "download-failed": "La descarga del libro falló.", + "download-in-progress": "Ya hay otra descarga de catálogo en curso.", + "import-failed": "El libro se descargó, pero no se pudo añadir a tu biblioteca.", + "insecure-url": "La dirección o redirección de este catálogo no es segura.", + "invalid-catalog": "Esta dirección no devolvió un catálogo OPDS válido.", + "too-large": "La respuesta del catálogo es demasiado grande para abrirla de forma segura.", + "unauthorized": "El catálogo rechazó las credenciales guardadas.", + "unreachable": "No se pudo acceder al catálogo.", + "unsupported-acquisition": "Este libro no ofrece un formato que ReadAny pueda importar.", + "unsupported-auth": "Este catálogo usa un método de autenticación que ReadAny aún no admite." + } + }, "sortRecent": "Abiertos recientemente", "sortAdded": "Fecha de agregado", "sortTitle": "Título", diff --git a/packages/core/src/i18n/locales/fr/library.json b/packages/core/src/i18n/locales/fr/library.json index fe3c20224..7ee6d4647 100644 --- a/packages/core/src/i18n/locales/fr/library.json +++ b/packages/core/src/i18n/locales/fr/library.json @@ -1,5 +1,106 @@ { "library": { + "opds": { + "alreadyImported": "Ce livre est déjà dans votre bibliothèque", + "authAnonymous": "Aucune connexion requise", + "authMissing": "Connexion Basic · Mot de passe requis", + "authSecure": "Connexion Basic · Stockage sécurisé", + "authSession": "Connexion Basic · Cette session uniquement", + "available": "Catalogues disponibles", + "back": "Retour", + "books": "Livres", + "browseCatalog": "Parcourir {{name}}", + "builtIn": "INTÉGRÉ", + "builtInLocked": "L’URL prédéfinie est en lecture seule", + "catalog": "Catalogue", + "catalogActionFailed": "Le catalogue n’a pas pu être mis à jour.", + "catalogsLoadFailed": "Les catalogues n’ont pas pu être chargés.", + "catalogsSubtitle": "Parcourez des collections publiques et vos propres serveurs OPDS.", + "catalogsTitle": "Catalogues en ligne", + "cancel": "Annuler", + "chooseFormat": "Choisir le format", + "collections": "Collections", + "continue": "Continuer", + "delete": "Supprimer", + "deleteCatalog": "Supprimer {{name}}", + "deleteDescription": "Le catalogue et son mot de passe stocké en toute sécurité seront supprimés.", + "deleteTitle": "Supprimer ce catalogue ?", + "disabled": "Désactivé", + "done": "Terminé", + "downloadAndImport": "Télécharger et importer", + "downloadFormat": "Télécharger {{format}}", + "downloading": "Téléchargement…", + "downloadingProgress": "Téléchargement : {{percent}} %", + "downloadTitle": "Télécharger {{title}}", + "editCatalog": "Modifier {{name}}", + "editCredentials": "Modifier les identifiants", + "empty": "Cette collection est encore vide", + "emptyHint": "Essayez une autre collection ou revenez au niveau précédent.", + "enabled": "Activé", + "hiddenPresets": "Catalogues prédéfinis masqués", + "hideCatalog": "Masquer {{name}}", + "imported": "Importé dans votre bibliothèque", + "importing": "Ajout à votre bibliothèque…", + "loadFailed": "Catalogue indisponible", + "loading": "Chargement du catalogue…", + "loadingCatalogs": "Ouverture des catalogues…", + "loadingHint": "Lecture de cette collection et vérification des formats disponibles.", + "noCompatibleFormat": "Aucun format de téléchargement compatible", + "next": "Suivant", + "previous": "Précédent", + "publicationDetails": "Détails de {{title}}", + "readerEyebrow": "Collections ouvertes", + "readerIntro": "Parcourez les collections d’un catalogue, choisissez un format et ajoutez le livre directement à votre bibliothèque.", + "refresh": "Actualiser", + "restore": "Restaurer", + "retry": "Réessayer", + "save": "Enregistrer", + "search": "Rechercher", + "restoreCatalog": "Restaurer {{name}}", + "searchPlaceholder": "Rechercher dans ce catalogue…", + "toggleCatalog": "Activer ou désactiver {{name}}", + "unknownAuthor": "Auteur inconnu", + "unsupportedExplanation": "Cette entrée ne propose aucun format de livre direct que ReadAny peut importer.", + "form": { + "addTitle": "Ajouter un catalogue", + "anonymous": "Anonyme", + "authentication": "Authentification", + "basic": "Connexion Basic", + "credentialsInUrl": "Ne placez pas d’identifiants dans l’URL du catalogue.", + "editTitle": "Modifier le catalogue", + "enabled": "Catalogue activé", + "enabledHint": "Les catalogues désactivés restent enregistrés mais ne peuvent pas être parcourus.", + "invalidUrl": "Saisissez une URL de catalogue HTTPS ou HTTP locale valide.", + "localHttpTitle": "Utiliser un catalogue HTTP local ?", + "localHttpWarning": "Le trafic et les identifiants peuvent être lus sur votre réseau local. Continuez uniquement si vous lui faites confiance.", + "name": "Nom", + "namePlaceholder": "Mon catalogue", + "password": "Mot de passe", + "passwordMissing": "Aucun mot de passe enregistré", + "passwordSessionOnly": "Mot de passe disponible pour cette session uniquement", + "passwordStoredSecurely": "Mot de passe enregistré dans le stockage sécurisé", + "passwordUnchanged": "Laissez vide pour conserver le mot de passe enregistré", + "publicHttpBlocked": "Les catalogues publics doivent utiliser HTTPS.", + "saveFailed": "Le catalogue n’a pas pu être enregistré.", + "subtitle": "Connectez un catalogue de livres sans inclure son mot de passe dans l’adresse.", + "url": "URL du catalogue", + "username": "Nom d’utilisateur" + }, + "errors": { + "asset-too-large": "Ce livre est trop volumineux pour être téléchargé en toute sécurité.", + "cancelled": "L’opération a été annulée.", + "download-failed": "Le téléchargement du livre a échoué.", + "download-in-progress": "Un autre téléchargement de catalogue est déjà en cours.", + "import-failed": "Le livre a été téléchargé, mais n’a pas pu être ajouté à votre bibliothèque.", + "insecure-url": "L’adresse ou la redirection de ce catalogue n’est pas sécurisée.", + "invalid-catalog": "Cette adresse n’a pas renvoyé de catalogue OPDS valide.", + "too-large": "La réponse du catalogue est trop volumineuse pour être ouverte en toute sécurité.", + "unauthorized": "Le catalogue a refusé les identifiants enregistrés.", + "unreachable": "Le catalogue est inaccessible.", + "unsupported-acquisition": "Ce livre ne propose aucun format que ReadAny peut importer.", + "unsupported-auth": "Ce catalogue utilise une méthode d’authentification que ReadAny ne prend pas encore en charge." + } + }, "sortRecent": "Ouverts récemment", "sortAdded": "Date d'ajout", "sortTitle": "Titre", diff --git a/packages/core/src/i18n/locales/ja/library.json b/packages/core/src/i18n/locales/ja/library.json index efd242e63..ab6e66bf1 100644 --- a/packages/core/src/i18n/locales/ja/library.json +++ b/packages/core/src/i18n/locales/ja/library.json @@ -1,5 +1,106 @@ { "library": { + "opds": { + "alreadyImported": "この本はすでにライブラリにあります", + "authAnonymous": "サインイン不要", + "authMissing": "Basic 認証 · パスワードが必要", + "authSecure": "Basic 認証 · 安全に保存済み", + "authSession": "Basic 認証 · このセッションのみ", + "available": "利用可能なカタログ", + "back": "戻る", + "books": "書籍", + "browseCatalog": "{{name}} を閲覧", + "builtIn": "標準", + "builtInLocked": "プリセット URL は変更できません", + "catalog": "カタログ", + "catalogActionFailed": "カタログを更新できませんでした。", + "catalogsLoadFailed": "カタログを読み込めませんでした。", + "catalogsSubtitle": "公開書棚や自分の OPDS サーバーを閲覧できます。", + "catalogsTitle": "オンラインカタログ", + "cancel": "キャンセル", + "chooseFormat": "形式を選択", + "collections": "コレクション", + "continue": "続行", + "delete": "削除", + "deleteCatalog": "{{name}} を削除", + "deleteDescription": "カタログと安全に保存されたパスワードを削除します。", + "deleteTitle": "このカタログを削除しますか?", + "disabled": "無効", + "done": "完了", + "downloadAndImport": "ダウンロードして取り込む", + "downloadFormat": "{{format}} をダウンロード", + "downloading": "ダウンロード中…", + "downloadingProgress": "ダウンロード中 {{percent}}%", + "downloadTitle": "{{title}} をダウンロード", + "editCatalog": "{{name}} を編集", + "editCredentials": "認証情報を編集", + "empty": "この書棚にはまだ何もありません", + "emptyHint": "別のコレクションを試すか、1つ前に戻ってください。", + "enabled": "有効", + "hiddenPresets": "非表示のプリセット", + "hideCatalog": "{{name}} を非表示", + "imported": "ライブラリに取り込みました", + "importing": "ライブラリに追加中…", + "loadFailed": "カタログを利用できません", + "loading": "カタログを読み込み中…", + "loadingCatalogs": "カタログを開いています…", + "loadingHint": "この書棚と利用可能な形式を確認しています。", + "noCompatibleFormat": "対応するダウンロード形式がありません", + "next": "次へ", + "previous": "前へ", + "publicationDetails": "{{title}} の詳細", + "readerEyebrow": "オープンな書棚", + "readerIntro": "カタログのコレクションをたどり、形式を選んで、本をライブラリへ直接追加できます。", + "refresh": "更新", + "restore": "復元", + "retry": "再試行", + "save": "保存", + "search": "検索", + "restoreCatalog": "{{name}} を復元", + "searchPlaceholder": "このカタログを検索…", + "toggleCatalog": "{{name}} の有効・無効を切り替え", + "unknownAuthor": "著者不明", + "unsupportedExplanation": "この項目には ReadAny が取り込める直接ダウンロード形式がありません。", + "form": { + "addTitle": "カタログを追加", + "anonymous": "匿名", + "authentication": "認証", + "basic": "Basic 認証", + "credentialsInUrl": "カタログ URL に認証情報を含めないでください。", + "editTitle": "カタログを編集", + "enabled": "カタログを有効にする", + "enabledHint": "無効なカタログは保存されたままですが、閲覧できません。", + "invalidUrl": "有効な HTTPS またはローカル HTTP のカタログ URL を入力してください。", + "localHttpTitle": "ローカル HTTP カタログを使用しますか?", + "localHttpWarning": "ローカルネットワーク上では通信内容や認証情報を読み取られる可能性があります。信頼できる場合のみ続行してください。", + "name": "名前", + "namePlaceholder": "マイカタログ", + "password": "パスワード", + "passwordMissing": "パスワードは保存されていません", + "passwordSessionOnly": "パスワードはこのセッションでのみ利用できます", + "passwordStoredSecurely": "パスワードは安全なストレージに保存されています", + "passwordUnchanged": "保存済みのパスワードを残す場合は空欄にします", + "publicHttpBlocked": "公開カタログには HTTPS が必要です。", + "saveFailed": "カタログを保存できませんでした。", + "subtitle": "パスワードをアドレスに含めずに書籍カタログへ接続します。", + "url": "カタログ URL", + "username": "ユーザー名" + }, + "errors": { + "asset-too-large": "この本は安全にダウンロードできるサイズを超えています。", + "cancelled": "操作をキャンセルしました。", + "download-failed": "本のダウンロードに失敗しました。", + "download-in-progress": "別のカタログダウンロードが進行中です。", + "import-failed": "本はダウンロードされましたが、ライブラリに追加できませんでした。", + "insecure-url": "このカタログのアドレスまたはリダイレクトは安全ではありません。", + "invalid-catalog": "このアドレスから有効な OPDS カタログが返されませんでした。", + "too-large": "カタログの応答が大きすぎるため、安全に開けません。", + "unauthorized": "保存済みの認証情報がカタログに拒否されました。", + "unreachable": "カタログに接続できませんでした。", + "unsupported-acquisition": "この本には ReadAny が取り込める形式がありません。", + "unsupported-auth": "このカタログの認証方式は ReadAny ではまだ対応していません。" + } + }, "sortRecent": "最近開いた順", "sortAdded": "追加日順", "sortTitle": "タイトル順", diff --git a/packages/core/src/i18n/locales/ko/library.json b/packages/core/src/i18n/locales/ko/library.json index 5c216bddb..cff275ee0 100644 --- a/packages/core/src/i18n/locales/ko/library.json +++ b/packages/core/src/i18n/locales/ko/library.json @@ -1,5 +1,106 @@ { "library": { + "opds": { + "alreadyImported": "이 책은 이미 라이브러리에 있습니다", + "authAnonymous": "로그인 필요 없음", + "authMissing": "기본 인증 · 비밀번호 필요", + "authSecure": "기본 인증 · 보안 저장소", + "authSession": "기본 인증 · 이번 세션만", + "available": "사용 가능한 카탈로그", + "back": "뒤로", + "books": "책", + "browseCatalog": "{{name}} 둘러보기", + "builtIn": "기본 제공", + "builtInLocked": "프리셋 URL은 변경할 수 없습니다", + "catalog": "카탈로그", + "catalogActionFailed": "카탈로그를 업데이트하지 못했습니다.", + "catalogsLoadFailed": "카탈로그를 불러오지 못했습니다.", + "catalogsSubtitle": "공개 서가와 나만의 OPDS 서버를 둘러보세요.", + "catalogsTitle": "온라인 카탈로그", + "cancel": "취소", + "chooseFormat": "형식 선택", + "collections": "컬렉션", + "continue": "계속", + "delete": "삭제", + "deleteCatalog": "{{name}} 삭제", + "deleteDescription": "카탈로그와 안전하게 저장된 비밀번호가 삭제됩니다.", + "deleteTitle": "이 카탈로그를 삭제할까요?", + "disabled": "사용 안 함", + "done": "완료", + "downloadAndImport": "다운로드 후 가져오기", + "downloadFormat": "{{format}} 다운로드", + "downloading": "다운로드 중…", + "downloadingProgress": "다운로드 중 {{percent}}%", + "downloadTitle": "{{title}} 다운로드", + "editCatalog": "{{name}} 편집", + "editCredentials": "로그인 정보 편집", + "empty": "이 서가에는 아직 항목이 없습니다", + "emptyHint": "다른 컬렉션을 열거나 이전 단계로 돌아가세요.", + "enabled": "사용", + "hiddenPresets": "숨긴 프리셋", + "hideCatalog": "{{name}} 숨기기", + "imported": "라이브러리에 가져왔습니다", + "importing": "라이브러리에 추가 중…", + "loadFailed": "카탈로그를 사용할 수 없음", + "loading": "카탈로그 불러오는 중…", + "loadingCatalogs": "카탈로그 여는 중…", + "loadingHint": "이 서가와 사용 가능한 형식을 확인하고 있습니다.", + "noCompatibleFormat": "호환되는 다운로드 형식 없음", + "next": "다음", + "previous": "이전", + "publicationDetails": "{{title}} 상세 정보", + "readerEyebrow": "열린 서가", + "readerIntro": "카탈로그의 컬렉션을 둘러보고 형식을 선택해 책을 라이브러리로 바로 가져오세요.", + "refresh": "새로 고침", + "restore": "복원", + "retry": "다시 시도", + "save": "저장", + "search": "검색", + "restoreCatalog": "{{name}} 복원", + "searchPlaceholder": "이 카탈로그 검색…", + "toggleCatalog": "{{name}} 사용 여부 전환", + "unknownAuthor": "알 수 없는 저자", + "unsupportedExplanation": "이 항목에는 ReadAny가 가져올 수 있는 직접 다운로드 책 형식이 없습니다.", + "form": { + "addTitle": "카탈로그 추가", + "anonymous": "익명", + "authentication": "인증", + "basic": "기본 인증", + "credentialsInUrl": "카탈로그 URL에 로그인 정보를 넣지 마세요.", + "editTitle": "카탈로그 편집", + "enabled": "카탈로그 사용", + "enabledHint": "사용하지 않는 카탈로그도 저장되지만 둘러볼 수는 없습니다.", + "invalidUrl": "올바른 HTTPS 또는 로컬 HTTP 카탈로그 URL을 입력하세요.", + "localHttpTitle": "로컬 HTTP 카탈로그를 사용할까요?", + "localHttpWarning": "로컬 네트워크에서 통신과 로그인 정보를 읽을 수 있습니다. 신뢰하는 경우에만 계속하세요.", + "name": "이름", + "namePlaceholder": "내 카탈로그", + "password": "비밀번호", + "passwordMissing": "저장된 비밀번호 없음", + "passwordSessionOnly": "이번 세션에서만 비밀번호 사용 가능", + "passwordStoredSecurely": "비밀번호가 보안 저장소에 저장됨", + "passwordUnchanged": "저장된 비밀번호를 유지하려면 비워 두세요", + "publicHttpBlocked": "공개 카탈로그는 HTTPS를 사용해야 합니다.", + "saveFailed": "카탈로그를 저장하지 못했습니다.", + "subtitle": "주소에 비밀번호를 넣지 않고 책 카탈로그에 연결합니다.", + "url": "카탈로그 URL", + "username": "사용자 이름" + }, + "errors": { + "asset-too-large": "이 책은 안전하게 다운로드하기에 너무 큽니다.", + "cancelled": "작업이 취소되었습니다.", + "download-failed": "책 다운로드에 실패했습니다.", + "download-in-progress": "다른 카탈로그 다운로드가 이미 진행 중입니다.", + "import-failed": "책을 다운로드했지만 라이브러리에 추가하지 못했습니다.", + "insecure-url": "이 카탈로그 주소 또는 리디렉션은 안전하지 않습니다.", + "invalid-catalog": "이 주소에서 올바른 OPDS 카탈로그를 받지 못했습니다.", + "too-large": "카탈로그 응답이 너무 커서 안전하게 열 수 없습니다.", + "unauthorized": "카탈로그가 저장된 로그인 정보를 거부했습니다.", + "unreachable": "카탈로그에 연결할 수 없습니다.", + "unsupported-acquisition": "이 책은 ReadAny가 가져올 수 있는 형식을 제공하지 않습니다.", + "unsupported-auth": "이 카탈로그의 인증 방식은 아직 ReadAny에서 지원하지 않습니다." + } + }, "sortRecent": "최근 열어본 순", "sortAdded": "추가된 날짜순", "sortTitle": "제목순", diff --git a/packages/core/src/i18n/locales/zh-TW/library.json b/packages/core/src/i18n/locales/zh-TW/library.json index 811eca7d6..f86f411ae 100644 --- a/packages/core/src/i18n/locales/zh-TW/library.json +++ b/packages/core/src/i18n/locales/zh-TW/library.json @@ -1,5 +1,106 @@ { "library": { + "opds": { + "alreadyImported": "這本書已在書庫中", + "authAnonymous": "無需登入", + "authMissing": "Basic 登入 · 需要密碼", + "authSecure": "Basic 登入 · 安全儲存", + "authSession": "Basic 登入 · 僅限本次工作階段", + "available": "可用目錄", + "back": "返回", + "books": "書籍", + "browseCatalog": "瀏覽 {{name}}", + "builtIn": "內建", + "builtInLocked": "預設網址為唯讀", + "catalog": "目錄", + "catalogActionFailed": "無法更新目錄。", + "catalogsLoadFailed": "無法載入目錄。", + "catalogsSubtitle": "瀏覽公共書架和你自己的 OPDS 伺服器。", + "catalogsTitle": "線上目錄", + "cancel": "取消", + "chooseFormat": "選擇格式", + "collections": "分類", + "continue": "繼續", + "delete": "刪除", + "deleteCatalog": "刪除 {{name}}", + "deleteDescription": "將刪除此目錄及其安全儲存的密碼。", + "deleteTitle": "刪除此目錄?", + "disabled": "已停用", + "done": "完成", + "downloadAndImport": "下載並匯入", + "downloadFormat": "下載 {{format}}", + "downloading": "正在下載…", + "downloadingProgress": "正在下載 {{percent}}%", + "downloadTitle": "下載 {{title}}", + "editCatalog": "編輯 {{name}}", + "editCredentials": "編輯登入資訊", + "empty": "這個書架還沒有內容", + "emptyHint": "試試其他分類,或返回上一層。", + "enabled": "已啟用", + "hiddenPresets": "已隱藏的預設項目", + "hideCatalog": "隱藏 {{name}}", + "imported": "已匯入書庫", + "importing": "正在加入書庫…", + "loadFailed": "目錄無法使用", + "loading": "正在載入目錄…", + "loadingCatalogs": "正在開啟目錄…", + "loadingHint": "正在讀取此書架並檢查可用格式。", + "noCompatibleFormat": "沒有相容的下載格式", + "next": "下一頁", + "previous": "上一頁", + "publicationDetails": "{{title}} 的詳細資料", + "readerEyebrow": "開放書架", + "readerIntro": "進入目錄中的分類,選擇格式,然後把書籍直接匯入書庫。", + "refresh": "重新整理", + "restore": "還原", + "retry": "重試", + "save": "儲存", + "search": "搜尋", + "restoreCatalog": "還原 {{name}}", + "searchPlaceholder": "搜尋此目錄…", + "toggleCatalog": "啟用或停用 {{name}}", + "unknownAuthor": "未知作者", + "unsupportedExplanation": "此項目未提供 ReadAny 可以匯入的書籍直接下載格式。", + "form": { + "addTitle": "新增目錄", + "anonymous": "匿名", + "authentication": "驗證", + "basic": "Basic 登入", + "credentialsInUrl": "請勿在目錄網址中填入登入資訊。", + "editTitle": "編輯目錄", + "enabled": "啟用目錄", + "enabledHint": "停用的目錄仍會保留,但無法瀏覽。", + "invalidUrl": "請輸入有效的 HTTPS 或本機 HTTP 目錄網址。", + "localHttpTitle": "使用本機 HTTP 目錄?", + "localHttpWarning": "本機網路上的其他人可能讀取流量和登入資訊。請僅在信任該網路時繼續。", + "name": "名稱", + "namePlaceholder": "我的目錄", + "password": "密碼", + "passwordMissing": "未儲存密碼", + "passwordSessionOnly": "密碼僅在本次工作階段中可用", + "passwordStoredSecurely": "密碼已儲存到安全儲存空間", + "passwordUnchanged": "留空即可保留已儲存的密碼", + "publicHttpBlocked": "公共目錄必須使用 HTTPS。", + "saveFailed": "無法儲存目錄。", + "subtitle": "連接書籍目錄,無需把密碼寫入網址。", + "url": "目錄網址", + "username": "使用者名稱" + }, + "errors": { + "asset-too-large": "這本書太大,無法安全下載。", + "cancelled": "操作已取消。", + "download-failed": "書籍下載失敗。", + "download-in-progress": "已有其他目錄下載正在進行。", + "import-failed": "書籍已下載,但無法加入書庫。", + "insecure-url": "此目錄網址或重新導向不安全。", + "invalid-catalog": "此網址未傳回有效的 OPDS 目錄。", + "too-large": "目錄回應太大,無法安全開啟。", + "unauthorized": "目錄拒絕了已儲存的登入資訊。", + "unreachable": "無法連線到目錄。", + "unsupported-acquisition": "這本書沒有 ReadAny 可以匯入的格式。", + "unsupported-auth": "此目錄使用的驗證方式目前不受 ReadAny 支援。" + } + }, "sortRecent": "最近開啟", "sortAdded": "新增時間", "sortTitle": "書名", diff --git a/packages/core/src/i18n/locales/zh/library.json b/packages/core/src/i18n/locales/zh/library.json index 440790e3c..53623095c 100644 --- a/packages/core/src/i18n/locales/zh/library.json +++ b/packages/core/src/i18n/locales/zh/library.json @@ -1,5 +1,106 @@ { "library": { + "opds": { + "alreadyImported": "这本书已在书库中", + "authAnonymous": "无需登录", + "authMissing": "Basic 登录 · 需要密码", + "authSecure": "Basic 登录 · 安全存储", + "authSession": "Basic 登录 · 仅本次会话", + "available": "可用目录", + "back": "返回", + "books": "图书", + "browseCatalog": "浏览 {{name}}", + "builtIn": "内置", + "builtInLocked": "预设网址只读", + "catalog": "目录", + "catalogActionFailed": "无法更新目录。", + "catalogsLoadFailed": "无法加载目录。", + "catalogsSubtitle": "浏览公共书架和你自己的 OPDS 服务器。", + "catalogsTitle": "在线目录", + "cancel": "取消", + "chooseFormat": "选择格式", + "collections": "分类", + "continue": "继续", + "delete": "删除", + "deleteCatalog": "删除 {{name}}", + "deleteDescription": "将删除此目录及其安全存储的密码。", + "deleteTitle": "删除此目录?", + "disabled": "已停用", + "done": "完成", + "downloadAndImport": "下载并导入", + "downloadFormat": "下载 {{format}}", + "downloading": "正在下载…", + "downloadingProgress": "正在下载 {{percent}}%", + "downloadTitle": "下载 {{title}}", + "editCatalog": "编辑 {{name}}", + "editCredentials": "编辑登录信息", + "empty": "这个书架还没有内容", + "emptyHint": "试试其他分类,或返回上一级。", + "enabled": "已启用", + "hiddenPresets": "已隐藏的预设", + "hideCatalog": "隐藏 {{name}}", + "imported": "已导入书库", + "importing": "正在添加到书库…", + "loadFailed": "目录不可用", + "loading": "正在加载目录…", + "loadingCatalogs": "正在打开目录…", + "loadingHint": "正在读取此书架并检查可用格式。", + "noCompatibleFormat": "没有兼容的下载格式", + "next": "下一页", + "previous": "上一页", + "publicationDetails": "{{title}} 的详情", + "readerEyebrow": "开放书架", + "readerIntro": "进入目录中的分类,选择格式,然后把图书直接导入书库。", + "refresh": "刷新", + "restore": "恢复", + "retry": "重试", + "save": "保存", + "search": "搜索", + "restoreCatalog": "恢复 {{name}}", + "searchPlaceholder": "搜索此目录…", + "toggleCatalog": "启用或停用 {{name}}", + "unknownAuthor": "未知作者", + "unsupportedExplanation": "此条目未提供 ReadAny 可以导入的图书直链格式。", + "form": { + "addTitle": "添加目录", + "anonymous": "匿名", + "authentication": "身份验证", + "basic": "Basic 登录", + "credentialsInUrl": "请勿在目录网址中填写登录信息。", + "editTitle": "编辑目录", + "enabled": "启用目录", + "enabledHint": "停用的目录仍会保留,但无法浏览。", + "invalidUrl": "请输入有效的 HTTPS 或本地 HTTP 目录网址。", + "localHttpTitle": "使用本地 HTTP 目录?", + "localHttpWarning": "本地网络上的其他人可能读取流量和登录信息。请仅在信任该网络时继续。", + "name": "名称", + "namePlaceholder": "我的目录", + "password": "密码", + "passwordMissing": "未保存密码", + "passwordSessionOnly": "密码仅在本次会话中可用", + "passwordStoredSecurely": "密码已保存到安全存储", + "passwordUnchanged": "留空可保留已保存的密码", + "publicHttpBlocked": "公共目录必须使用 HTTPS。", + "saveFailed": "无法保存目录。", + "subtitle": "连接图书目录,无需把密码写进网址。", + "url": "目录网址", + "username": "用户名" + }, + "errors": { + "asset-too-large": "这本书太大,无法安全下载。", + "cancelled": "操作已取消。", + "download-failed": "图书下载失败。", + "download-in-progress": "已有其他目录下载正在进行。", + "import-failed": "图书已下载,但无法添加到书库。", + "insecure-url": "此目录地址或重定向不安全。", + "invalid-catalog": "此地址未返回有效的 OPDS 目录。", + "too-large": "目录响应太大,无法安全打开。", + "unauthorized": "目录拒绝了已保存的登录信息。", + "unreachable": "无法连接到目录。", + "unsupported-acquisition": "这本书没有 ReadAny 可以导入的格式。", + "unsupported-auth": "此目录使用的身份验证方式暂不受 ReadAny 支持。" + } + }, "sortRecent": "最近打开", "sortAdded": "添加时间", "sortTitle": "书名", diff --git a/packages/core/src/i18n/opds-locales.test.ts b/packages/core/src/i18n/opds-locales.test.ts new file mode 100644 index 000000000..d1fc4f5b9 --- /dev/null +++ b/packages/core/src/i18n/opds-locales.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import en from "./locales/en/library.json"; +import es from "./locales/es/library.json"; +import fr from "./locales/fr/library.json"; +import ja from "./locales/ja/library.json"; +import ko from "./locales/ko/library.json"; +import zhTW from "./locales/zh-TW/library.json"; +import zh from "./locales/zh/library.json"; + +const REQUIRED_KEYS = [ + "alreadyImported", + "authAnonymous", + "authMissing", + "authSecure", + "authSession", + "available", + "back", + "books", + "browseCatalog", + "builtIn", + "builtInLocked", + "catalog", + "catalogActionFailed", + "catalogsLoadFailed", + "catalogsSubtitle", + "catalogsTitle", + "cancel", + "chooseFormat", + "collections", + "continue", + "delete", + "deleteCatalog", + "deleteDescription", + "deleteTitle", + "disabled", + "done", + "downloadAndImport", + "downloadFormat", + "downloading", + "downloadingProgress", + "downloadTitle", + "editCatalog", + "editCredentials", + "empty", + "emptyHint", + "enabled", + "hiddenPresets", + "hideCatalog", + "imported", + "importing", + "loadFailed", + "loading", + "loadingCatalogs", + "loadingHint", + "noCompatibleFormat", + "next", + "previous", + "publicationDetails", + "readerEyebrow", + "readerIntro", + "refresh", + "restore", + "restoreCatalog", + "searchPlaceholder", + "search", + "retry", + "save", + "toggleCatalog", + "unknownAuthor", + "unsupportedExplanation", + "form.addTitle", + "form.anonymous", + "form.authentication", + "form.basic", + "form.credentialsInUrl", + "form.editTitle", + "form.enabled", + "form.enabledHint", + "form.invalidUrl", + "form.localHttpTitle", + "form.localHttpWarning", + "form.name", + "form.namePlaceholder", + "form.password", + "form.passwordMissing", + "form.passwordSessionOnly", + "form.passwordStoredSecurely", + "form.passwordUnchanged", + "form.publicHttpBlocked", + "form.saveFailed", + "form.subtitle", + "form.url", + "form.username", + "errors.asset-too-large", + "errors.cancelled", + "errors.download-failed", + "errors.download-in-progress", + "errors.import-failed", + "errors.insecure-url", + "errors.invalid-catalog", + "errors.too-large", + "errors.unauthorized", + "errors.unreachable", + "errors.unsupported-acquisition", + "errors.unsupported-auth", +] as const; + +type JsonObject = Record; + +const resources = { en, es, fr, ja, ko, zh, "zh-TW": zhTW } as const; + +function flatten(value: unknown, prefix = ""): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const output: Record = {}; + for (const [key, child] of Object.entries(value as JsonObject)) { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof child === "string") output[path] = child; + else Object.assign(output, flatten(child, path)); + } + return output; +} + +function placeholders(value: string): string[] { + return [...value.matchAll(/{{\s*([^},\s]+)[^}]*}}/g)].map((match) => match[1]).sort(); +} + +describe("OPDS locale contract", () => { + const english = flatten((en.library as JsonObject).opds); + + it("defines every required user-facing key in English", () => { + expect(Object.keys(english).sort()).toEqual([...REQUIRED_KEYS].sort()); + }); + + for (const [locale, resource] of Object.entries(resources)) { + it(`${locale} has exact non-empty key and placeholder parity`, () => { + const actual = flatten((resource.library as JsonObject).opds); + expect(Object.keys(actual).sort()).toEqual(Object.keys(english).sort()); + for (const key of Object.keys(english)) { + expect(actual[key]?.trim(), `${locale}:${key}`).not.toBe(""); + expect(placeholders(actual[key]), `${locale}:${key}`).toEqual(placeholders(english[key])); + } + }); + } +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3a7dfd46e..067f235ff 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -87,6 +87,13 @@ export { } from "./opds/opds-acquisition"; export { parseOpdsDocument } from "./opds/opds-parser"; export { classifyOpdsUrl } from "./opds/opds-security"; +export { createOpdsRuntime } from "./opds/opds-runtime"; +export { + createOpdsCoverCache, + readOpdsCover, + type OpdsCoverLease, + type OpdsCoverValue, +} from "./opds/opds-cover-cache"; export { sanitizeOpdsDescription } from "./opds/opds-sanitize"; export type { OpdsAcquisition, diff --git a/packages/core/src/opds/opds-cover-cache.test.ts b/packages/core/src/opds/opds-cover-cache.test.ts new file mode 100644 index 000000000..b3ee0b6bb --- /dev/null +++ b/packages/core/src/opds/opds-cover-cache.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpdsAssetResponse } from "./opds-client"; +import { createOpdsCoverCache, readOpdsCover } from "./opds-cover-cache"; + +function imageResponse(bytes: number[], headers: Record = {}) { + const response = new Response(Uint8Array.from(bytes), { + headers: { "Content-Type": "image/png", ...headers }, + }); + return Object.assign(response, { cancel: vi.fn(async () => undefined) }) as OpdsAssetResponse; +} + +describe("shared OPDS cover cache", () => { + it("deduplicates in-flight authenticated image reads", async () => { + const load = vi.fn(async () => ({ uri: "data:image/png;base64,AQ==", byteLength: 1 })); + const cache = createOpdsCoverCache({ load, maxEntries: 2, maxBytes: 10 }); + + const [first, second] = await Promise.all([cache.acquire("cover"), cache.acquire("cover")]); + + expect(load).toHaveBeenCalledTimes(1); + first.release(); + second.release(); + }); + + it("evicts the least recently used released cover within entry and byte bounds", async () => { + const cache = createOpdsCoverCache({ + load: async (url) => ({ uri: url, byteLength: 4 }), + maxEntries: 1, + maxBytes: 4, + }); + (await cache.acquire("first")).release(); + (await cache.acquire("second")).release(); + + expect(cache.snapshot()).toEqual({ entries: 1, sourceBytes: 4, urls: ["second"] }); + }); + + it("rejects a streamed non-image or oversized cover and cancels transport", async () => { + const wrongType = imageResponse([1], { "Content-Type": "text/html" }); + await expect(readOpdsCover(wrongType, new AbortController().signal, 4)).rejects.toThrow( + "not-an-image", + ); + expect(wrongType.cancel).toHaveBeenCalledWith("not-an-image"); + + const tooLarge = imageResponse([1, 2, 3, 4, 5]); + await expect(readOpdsCover(tooLarge, new AbortController().signal, 4)).rejects.toThrow( + "cover-too-large", + ); + expect(tooLarge.cancel).toHaveBeenCalledWith("cover-too-large"); + }); +}); diff --git a/packages/core/src/opds/opds-cover-cache.ts b/packages/core/src/opds/opds-cover-cache.ts new file mode 100644 index 000000000..52ecf0af6 --- /dev/null +++ b/packages/core/src/opds/opds-cover-cache.ts @@ -0,0 +1,203 @@ +import type { OpdsAssetResponse } from "./opds-client"; + +export interface OpdsCoverValue { + readonly uri: string; + readonly byteLength: number; +} + +export interface OpdsCoverLease { + readonly uri: string; + release(): void; +} + +function bytesToBase64(bytes: Uint8Array): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let output = ""; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index] ?? 0; + const second = bytes[index + 1]; + const third = bytes[index + 2]; + const value = (first << 16) | ((second ?? 0) << 8) | (third ?? 0); + output += alphabet[(value >> 18) & 63]; + output += alphabet[(value >> 12) & 63]; + output += second === undefined ? "=" : alphabet[(value >> 6) & 63]; + output += third === undefined ? "=" : alphabet[value & 63]; + } + return output; +} + +export async function readOpdsCover( + response: OpdsAssetResponse, + signal: AbortSignal, + maxBytes: number, +): Promise { + let cancelled = false; + const cancelTransport = async (reason: string) => { + if (cancelled) return; + cancelled = true; + await response.cancel(reason); + }; + const contentType = response.headers.get("Content-Type")?.split(";", 1)[0]?.trim(); + const advertisedLength = Number(response.headers.get("Content-Length")); + if (!contentType?.startsWith("image/")) { + await cancelTransport("not-an-image"); + throw new Error("not-an-image"); + } + if (Number.isFinite(advertisedLength) && advertisedLength > maxBytes) { + await cancelTransport("cover-too-large"); + throw new Error("cover-too-large"); + } + if (signal.aborted) { + await cancelTransport("cancelled"); + throw new Error("cancelled"); + } + if (!response.body) { + await cancelTransport("missing-stream"); + throw new Error("missing-stream"); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let byteLength = 0; + const onAbort = () => void cancelTransport("cancelled"); + signal.addEventListener("abort", onAbort, { once: true }); + try { + for (;;) { + if (signal.aborted) { + await cancelTransport("cancelled"); + throw new Error("cancelled"); + } + const next = await reader.read(); + if (signal.aborted) { + await cancelTransport("cancelled"); + throw new Error("cancelled"); + } + if (next.done) break; + byteLength += next.value.byteLength; + if (byteLength > maxBytes) { + await cancelTransport("cover-too-large"); + throw new Error("cover-too-large"); + } + chunks.push(next.value); + } + } finally { + signal.removeEventListener("abort", onAbort); + reader.releaseLock(); + } + + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { uri: `data:${contentType};base64,${bytesToBase64(bytes)}`, byteLength }; +} + +interface CacheEntry extends OpdsCoverValue { + references: number; + lastUsed: number; +} + +interface InFlightEntry { + controller: AbortController; + promise: Promise; + waiters: number; +} + +export function createOpdsCoverCache({ + load, + maxEntries, + maxBytes, +}: { + load(url: string, signal: AbortSignal): Promise; + maxEntries: number; + maxBytes: number; +}) { + const entries = new Map(); + const inFlight = new Map(); + let sourceBytes = 0; + let clock = 0; + let generation = 0; + + const evict = () => { + while (entries.size > maxEntries || sourceBytes > maxBytes) { + const candidate = [...entries.entries()] + .filter(([, entry]) => entry.references === 0) + .sort(([, left], [, right]) => left.lastUsed - right.lastUsed)[0]; + if (!candidate) return; + entries.delete(candidate[0]); + sourceBytes -= candidate[1].byteLength; + } + }; + + const lease = (entry: CacheEntry): OpdsCoverLease => { + entry.references += 1; + entry.lastUsed = ++clock; + let released = false; + return { + uri: entry.uri, + release() { + if (released) return; + released = true; + entry.references = Math.max(0, entry.references - 1); + entry.lastUsed = ++clock; + evict(); + }, + }; + }; + + return { + async acquire(url: string, signal?: AbortSignal): Promise { + const acquisitionGeneration = generation; + if (signal?.aborted) throw new Error("cancelled"); + const cached = entries.get(url); + if (cached) return lease(cached); + + let pending = inFlight.get(url); + if (!pending) { + const controller = new AbortController(); + const promise = load(url, controller.signal).finally(() => { + if (inFlight.get(url)?.promise === promise) inFlight.delete(url); + }); + pending = { controller, promise, waiters: 0 }; + inFlight.set(url, pending); + } + pending.waiters += 1; + let settled = false; + let rejectCancelled: ((error: Error) => void) | undefined; + const cancelled = new Promise((_resolve, reject) => { + rejectCancelled = reject; + }); + const onAbort = () => rejectCancelled?.(new Error("cancelled")); + signal?.addEventListener("abort", onAbort, { once: true }); + try { + const value = await Promise.race([pending.promise, cancelled]); + settled = true; + if (acquisitionGeneration !== generation) throw new Error("cancelled"); + let entry = entries.get(url); + if (!entry && value.byteLength <= maxBytes && maxEntries > 0) { + entry = { ...value, references: 0, lastUsed: ++clock }; + entries.set(url, entry); + sourceBytes += value.byteLength; + evict(); + } + return entry ? lease(entry) : { uri: value.uri, release() {} }; + } finally { + signal?.removeEventListener("abort", onAbort); + pending.waiters = Math.max(0, pending.waiters - 1); + if (!settled && pending.waiters === 0) pending.controller.abort(); + } + }, + clear(): void { + generation += 1; + for (const pending of inFlight.values()) pending.controller.abort(); + inFlight.clear(); + entries.clear(); + sourceBytes = 0; + }, + snapshot() { + return { entries: entries.size, sourceBytes, urls: [...entries.keys()] }; + }, + }; +} diff --git a/packages/core/src/opds/opds-runtime.test.ts b/packages/core/src/opds/opds-runtime.test.ts new file mode 100644 index 000000000..92a9fdc59 --- /dev/null +++ b/packages/core/src/opds/opds-runtime.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from "vitest"; +import type { IPlatformService } from "../services/platform"; +import { createOpdsRuntime } from "./opds-runtime"; + +function platform() { + return { + kvGetItem: vi.fn(async () => null), + kvSetItem: vi.fn(async () => undefined), + } as unknown as IPlatformService; +} + +describe("shared OPDS runtime", () => { + it("keeps one loaded store and client for a platform while coalescing concurrent loads", async () => { + const active = platform(); + const runtime = createOpdsRuntime(() => active); + + expect(runtime.getCatalogStore()).toBe(runtime.getCatalogStore()); + expect(runtime.getClient()).toBe(runtime.getClient()); + + await Promise.all([runtime.ensureCatalogsLoaded(), runtime.ensureCatalogsLoaded()]); + + expect(active.kvGetItem).toHaveBeenCalledTimes(1); + }); + + it("replaces platform-bound owners when the platform changes", async () => { + let active = platform(); + const runtime = createOpdsRuntime(() => active); + const firstStore = runtime.getCatalogStore(); + const firstClient = runtime.getClient(); + + active = platform(); + + expect(runtime.getCatalogStore()).not.toBe(firstStore); + expect(runtime.getClient()).not.toBe(firstClient); + await runtime.ensureCatalogsLoaded(); + expect(active.kvGetItem).toHaveBeenCalledTimes(1); + }); + + it("allows a failed load to be retried", async () => { + const active = platform(); + vi.mocked(active.kvGetItem) + .mockRejectedValueOnce(new Error("storage unavailable")) + .mockResolvedValueOnce(null); + const runtime = createOpdsRuntime(() => active); + + await expect(runtime.ensureCatalogsLoaded()).rejects.toThrow("storage unavailable"); + await expect(runtime.ensureCatalogsLoaded()).resolves.toBeUndefined(); + expect(active.kvGetItem).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/core/src/opds/opds-runtime.ts b/packages/core/src/opds/opds-runtime.ts new file mode 100644 index 000000000..bfd3a5e64 --- /dev/null +++ b/packages/core/src/opds/opds-runtime.ts @@ -0,0 +1,42 @@ +import type { IPlatformService } from "../services/platform"; +import { OpdsCatalogStore } from "./opds-catalog-store"; +import { OpdsClient } from "./opds-client"; + +export function createOpdsRuntime(resolvePlatform: () => IPlatformService) { + let activePlatform: IPlatformService | undefined; + let catalogStore: OpdsCatalogStore | undefined; + let client: OpdsClient | undefined; + let loadPromise: Promise | undefined; + + const prepare = (): { catalogStore: OpdsCatalogStore; client: OpdsClient } => { + const platform = resolvePlatform(); + if (platform !== activePlatform || !catalogStore || !client) { + activePlatform = platform; + catalogStore = new OpdsCatalogStore(platform); + client = new OpdsClient(platform); + loadPromise = undefined; + } + return { catalogStore, client }; + }; + + return { + getCatalogStore(): OpdsCatalogStore { + return prepare().catalogStore; + }, + getClient(): OpdsClient { + return prepare().client; + }, + async ensureCatalogsLoaded(): Promise { + const currentStore = prepare().catalogStore; + if (loadPromise) return loadPromise; + const pending = currentStore.load(); + loadPromise = pending; + try { + await pending; + } catch (error) { + if (loadPromise === pending) loadPromise = undefined; + throw error; + } + }, + }; +} From 84516a8cae9dddb0bc9d005abd45dbad260252d1 Mon Sep 17 00:00:00 2001 From: Chai Date: Mon, 17 Aug 2026 04:14:40 -0400 Subject: [PATCH 32/38] fix(opds): harden desktop catalog interactions --- .github/workflows/release.yml | 2 +- .../screens/library/opds-back-controller.ts | 54 +-- .../src/screens/library/opds-view-state.ts | 366 +-------------- packages/app/package.json | 3 + .../src/components/home/OpdsBrowser.test.tsx | 237 ++++++++++ .../app/src/components/home/OpdsBrowser.tsx | 213 +++++---- .../home/OpdsCatalogFormDialog.test.tsx | 61 +++ .../components/home/OpdsCatalogFormDialog.tsx | 7 +- .../home/OpdsCatalogsDialog.test.tsx | 147 ++++++ .../components/home/OpdsCatalogsDialog.tsx | 70 ++- .../components/home/OpdsDescription.test.tsx | 43 ++ .../src/components/home/OpdsDescription.tsx | 42 ++ .../home/opds-component-test-setup.ts | 10 + .../home/opds-desktop-browser-state.test.ts | 132 ++++++ .../opds-desktop-download-controller.test.ts | 101 +++++ .../home/opds-desktop-download-controller.ts | 69 +++ .../home/opds-desktop-feed-window.test.ts | 53 +++ .../home/opds-desktop-feed-window.ts | 27 ++ .../ui/accessibility-controls.test.tsx | 39 ++ packages/app/src/components/ui/dialog.tsx | 5 +- .../app/src/components/ui/password-input.tsx | 13 +- .../src/ci/linux-release-dependencies.test.ts | 20 + .../core/src/i18n/locales/en/library.json | 4 + .../core/src/i18n/locales/es/library.json | 4 + .../core/src/i18n/locales/fr/library.json | 4 + .../core/src/i18n/locales/ja/library.json | 4 + .../core/src/i18n/locales/ko/library.json | 4 + .../core/src/i18n/locales/zh-TW/library.json | 4 + .../core/src/i18n/locales/zh/library.json | 4 + packages/core/src/i18n/opds-locales.test.ts | 4 + packages/core/src/index.ts | 2 + .../core/src/opds/opds-back-controller.ts | 53 +++ .../core/src/opds/opds-cover-cache.test.ts | 52 +++ packages/core/src/opds/opds-cover-cache.ts | 52 ++- packages/core/src/opds/opds-parser.test.ts | 2 +- packages/core/src/opds/opds-sanitize.test.ts | 4 +- packages/core/src/opds/opds-sanitize.ts | 6 +- packages/core/src/opds/opds-view-state.ts | 300 +++++++++++++ pnpm-lock.yaml | 422 +++++++++++++++++- 39 files changed, 2105 insertions(+), 534 deletions(-) create mode 100644 packages/app/src/components/home/OpdsBrowser.test.tsx create mode 100644 packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx create mode 100644 packages/app/src/components/home/OpdsCatalogsDialog.test.tsx create mode 100644 packages/app/src/components/home/OpdsDescription.test.tsx create mode 100644 packages/app/src/components/home/OpdsDescription.tsx create mode 100644 packages/app/src/components/home/opds-component-test-setup.ts create mode 100644 packages/app/src/components/home/opds-desktop-browser-state.test.ts create mode 100644 packages/app/src/components/home/opds-desktop-download-controller.test.ts create mode 100644 packages/app/src/components/home/opds-desktop-download-controller.ts create mode 100644 packages/app/src/components/home/opds-desktop-feed-window.test.ts create mode 100644 packages/app/src/components/home/opds-desktop-feed-window.ts create mode 100644 packages/app/src/components/ui/accessibility-controls.test.tsx create mode 100644 packages/core/src/ci/linux-release-dependencies.test.ts create mode 100644 packages/core/src/opds/opds-back-controller.ts create mode 100644 packages/core/src/opds/opds-view-state.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db9fa51df..7b9a8a0c0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,7 @@ jobs: if: matrix.platform == 'ubuntu-22.04' run: | sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev pkg-config libdbus-1-dev - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/packages/app-expo/src/screens/library/opds-back-controller.ts b/packages/app-expo/src/screens/library/opds-back-controller.ts index 4e21e52fd..f6555e948 100644 --- a/packages/app-expo/src/screens/library/opds-back-controller.ts +++ b/packages/app-expo/src/screens/library/opds-back-controller.ts @@ -1,53 +1 @@ -import type { OpdsViewAction, OpdsViewState } from "./opds-view-state"; - -interface OpdsBackDependencies { - getState(): OpdsViewState; - cancelRequest(): void; - dispatch(action: OpdsViewAction): void; - startBack(url: string): void; - exit(): void; -} - -export function createOpdsBackController(dependencies: OpdsBackDependencies) { - const consumeInternalBack = (): boolean => { - const { content } = dependencies.getState(); - if ( - content.status === "loading" && - content.previous && - (content.pending.mode === "push" || content.pending.mode === "back") - ) { - dependencies.cancelRequest(); - dependencies.dispatch({ type: "loadCancelled", requestId: content.requestId }); - return true; - } - if ( - content.status === "error" && - content.previous && - (content.failedRequest.mode === "push" || content.failedRequest.mode === "back") - ) { - dependencies.dispatch({ type: "loadCancelled", requestId: content.failedRequestId }); - return true; - } - const snapshot = - content.status === "ready" - ? content - : content.status === "loading" || content.status === "error" - ? content.previous - : undefined; - const target = snapshot?.history.at(-1); - if (!target) return false; - dependencies.cancelRequest(); - dependencies.startBack(target); - return true; - }; - - return { - handleHeaderBack(): void { - if (!consumeInternalBack()) dependencies.exit(); - }, - handleBeforeRemove(event: { preventDefault(): void }): void { - if (!consumeInternalBack()) return; - event.preventDefault(); - }, - }; -} +export { createOpdsBackController } from "@readany/core"; diff --git a/packages/app-expo/src/screens/library/opds-view-state.ts b/packages/app-expo/src/screens/library/opds-view-state.ts index fe21df4a6..f63853e0c 100644 --- a/packages/app-expo/src/screens/library/opds-view-state.ts +++ b/packages/app-expo/src/screens/library/opds-view-state.ts @@ -1,348 +1,18 @@ -import type { OpdsErrorCode, OpdsFeed } from "@readany/core"; - -export type OpdsLoadMode = "replace" | "refresh" | "push" | "back"; - -export interface OpdsPendingRequest { - readonly url: string; - readonly mode: OpdsLoadMode; -} - -interface OpdsReadySnapshot { - readonly feed: OpdsFeed; - readonly currentUrl: string; - readonly history: readonly string[]; -} - -export type OpdsContentState = - | { readonly status: "idle" } - | { - readonly status: "loading"; - readonly requestId: number; - readonly pending: OpdsPendingRequest; - readonly previous?: OpdsReadySnapshot; - } - | (OpdsReadySnapshot & { - readonly status: "ready"; - readonly refreshing: boolean; - readonly requestId?: number; - readonly pending?: OpdsPendingRequest; - }) - | { - readonly status: "error"; - readonly failedRequestId: number; - readonly error: OpdsErrorCode; - readonly failedRequest: OpdsPendingRequest; - readonly previous?: OpdsReadySnapshot; - }; - -export type OpdsDownloadState = - | { readonly status: "idle" } - | { - readonly status: "downloading"; - readonly requestId: number; - readonly publicationTitle: string; - readonly loaded: number; - readonly total: number; - } - | { - readonly status: "success"; - readonly requestId: number; - readonly publicationTitle: string; - readonly importedCount: number; - } - | { - readonly status: "importing"; - readonly requestId: number; - readonly publicationTitle: string; - } - | { - readonly status: "error"; - readonly requestId: number; - readonly publicationTitle: string; - readonly error: OpdsErrorCode; - }; - -export interface OpdsViewState { - readonly content: OpdsContentState; - readonly download: OpdsDownloadState; -} - -export type OpdsViewAction = - | { - readonly type: "loadStarted"; - readonly requestId: number; - readonly url: string; - readonly mode: OpdsLoadMode; - } - | { - readonly type: "loadSucceeded"; - readonly requestId: number; - readonly feed: OpdsFeed; - } - | { - readonly type: "loadFailed"; - readonly requestId: number; - readonly error: OpdsErrorCode; - } - | { readonly type: "retryStarted"; readonly requestId: number } - | { readonly type: "loadCancelled"; readonly requestId: number } - | { - readonly type: "downloadStarted"; - readonly requestId: number; - readonly publicationTitle: string; - } - | { - readonly type: "downloadProgress"; - readonly requestId: number; - readonly loaded: number; - readonly total: number; - } - | { - readonly type: "downloadSucceeded"; - readonly requestId: number; - readonly importedCount: number; - } - | { readonly type: "downloadImporting"; readonly requestId: number } - | { - readonly type: "downloadFailed"; - readonly requestId: number; - readonly error: OpdsErrorCode; - } - | { readonly type: "downloadCancelled"; readonly requestId: number } - | { readonly type: "downloadReset" }; - -export interface OpdsBrowserRouteParams { - readonly catalogId: string; -} - -export function createInitialOpdsViewState(): OpdsViewState { - return { content: { status: "idle" }, download: { status: "idle" } }; -} - -export function createOpdsBrowserRouteParams(catalogId: string): OpdsBrowserRouteParams { - return { catalogId }; -} - -function readySnapshot(content: OpdsContentState): OpdsReadySnapshot | undefined { - if (content.status === "ready") { - return { - feed: content.feed, - currentUrl: content.currentUrl, - history: content.history, - }; - } - if (content.status === "loading" || content.status === "error") return content.previous; - return undefined; -} - -function activeRequestId(content: OpdsContentState): number | undefined { - if (content.status === "loading") return content.requestId; - if (content.status === "ready" && content.refreshing) return content.requestId; - return undefined; -} - -function startLoad( - content: OpdsContentState, - requestId: number, - pending: OpdsPendingRequest, -): OpdsContentState { - const previous = readySnapshot(content); - if (pending.mode === "refresh" && previous) { - return { - status: "ready", - ...previous, - refreshing: true, - requestId, - pending, - }; - } - return { - status: "loading", - requestId, - pending, - ...(previous ? { previous } : {}), - }; -} - -function finishLoad(content: OpdsContentState, feed: OpdsFeed): OpdsContentState { - if (content.status !== "loading" && content.status !== "ready") return content; - const pending = content.pending; - if (!pending) return content; - const previous = readySnapshot(content); - let history: readonly string[] = []; - if (previous) { - if (pending.mode === "push") history = [...previous.history, previous.currentUrl]; - else if (pending.mode === "back") history = previous.history.slice(0, -1); - else if (pending.mode === "refresh") history = previous.history; - } - return { - status: "ready", - feed, - currentUrl: pending.url, - history, - refreshing: false, - }; -} - -export function opdsViewReducer(state: OpdsViewState, action: OpdsViewAction): OpdsViewState { - switch (action.type) { - case "loadStarted": - return { - ...state, - content: startLoad(state.content, action.requestId, { - url: action.url, - mode: action.mode, - }), - }; - case "retryStarted": { - if (state.content.status !== "error") return state; - return { - ...state, - content: startLoad(state.content, action.requestId, state.content.failedRequest), - }; - } - case "loadSucceeded": - if (activeRequestId(state.content) !== action.requestId) return state; - return { ...state, content: finishLoad(state.content, action.feed) }; - case "loadFailed": { - if (activeRequestId(state.content) !== action.requestId) return state; - const pending = - state.content.status === "loading" || state.content.status === "ready" - ? state.content.pending - : undefined; - if (!pending) return state; - const previous = readySnapshot(state.content); - return { - ...state, - content: { - status: "error", - failedRequestId: action.requestId, - error: action.error, - failedRequest: pending, - ...(previous ? { previous } : {}), - }, - }; - } - case "loadCancelled": { - const requestMatches = - activeRequestId(state.content) === action.requestId || - (state.content.status === "error" && state.content.failedRequestId === action.requestId); - if (!requestMatches) return state; - const previous = readySnapshot(state.content); - return previous - ? { ...state, content: { status: "ready", ...previous, refreshing: false } } - : { ...state, content: { status: "idle" } }; - } - case "downloadStarted": - if (state.download.status === "downloading") return state; - return { - ...state, - download: { - status: "downloading", - requestId: action.requestId, - publicationTitle: action.publicationTitle, - loaded: 0, - total: 0, - }, - }; - case "downloadProgress": - if ( - state.download.status !== "downloading" || - state.download.requestId !== action.requestId - ) { - return state; - } - return { - ...state, - download: { - ...state.download, - loaded: Math.max(0, action.loaded), - total: Math.max(0, action.total), - }, - }; - case "downloadImporting": - if ( - state.download.status !== "downloading" || - state.download.requestId !== action.requestId - ) { - return state; - } - return { - ...state, - download: { - status: "importing", - requestId: action.requestId, - publicationTitle: state.download.publicationTitle, - }, - }; - case "downloadSucceeded": - if ( - (state.download.status !== "downloading" && state.download.status !== "importing") || - state.download.requestId !== action.requestId - ) { - return state; - } - return { - ...state, - download: { - status: "success", - requestId: action.requestId, - publicationTitle: state.download.publicationTitle, - importedCount: action.importedCount, - }, - }; - case "downloadFailed": - if ( - (state.download.status !== "downloading" && state.download.status !== "importing") || - state.download.requestId !== action.requestId - ) { - return state; - } - return { - ...state, - download: { - status: "error", - requestId: action.requestId, - publicationTitle: state.download.publicationTitle, - error: action.error, - }, - }; - case "downloadCancelled": - if ( - state.download.status !== "downloading" || - state.download.requestId !== action.requestId - ) { - return state; - } - return { ...state, download: { status: "idle" } }; - case "downloadReset": - return state.download.status === "idle" ? state : { ...state, download: { status: "idle" } }; - } -} - -export function selectOpdsFeed(state: OpdsViewState): OpdsFeed | undefined { - if (state.content.status === "ready") return state.content.feed; - if (state.content.status === "loading" || state.content.status === "error") { - return state.content.previous?.feed; - } - return undefined; -} - -export function canSearchOpds(state: OpdsViewState): boolean { - return selectOpdsFeed(state)?.search !== undefined; -} - -export function getOpdsPagination(state: OpdsViewState): { - previousUrl?: string; - nextUrl?: string; -} { - const current = selectOpdsFeed(state); - return { - ...(current?.previousUrl ? { previousUrl: current.previousUrl } : {}), - ...(current?.nextUrl ? { nextUrl: current.nextUrl } : {}), - }; -} - -export function shouldEditOpdsCredentials(state: OpdsViewState): boolean { - return state.content.status === "error" && state.content.error === "unauthorized"; -} +export { + canSearchOpds, + createInitialOpdsViewState, + createOpdsBrowserRouteParams, + getOpdsPagination, + opdsViewReducer, + selectOpdsFeed, + shouldEditOpdsCredentials, +} from "@readany/core"; +export type { + OpdsBrowserRouteParams, + OpdsContentState, + OpdsDownloadState, + OpdsLoadMode, + OpdsPendingRequest, + OpdsViewAction, + OpdsViewState, +} from "@readany/core"; diff --git a/packages/app/package.json b/packages/app/package.json index 65791aa10..bbb66c381 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -77,6 +77,8 @@ "devDependencies": { "@tailwindcss/vite": "^4.0.0", "@tauri-apps/cli": "^2.10.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.4", "@types/d3": "^7.4.3", "@types/d3-scale": "^4.0.9", "@types/d3-shape": "^3.1.8", @@ -84,6 +86,7 @@ "@types/react-dom": "^19.1.6", "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^30.0.1", "tailwindcss": "^4.0.0", "typescript": "~5.8.3", "vite": "^7.0.4" diff --git a/packages/app/src/components/home/OpdsBrowser.test.tsx b/packages/app/src/components/home/OpdsBrowser.test.tsx new file mode 100644 index 000000000..d6e9595be --- /dev/null +++ b/packages/app/src/components/home/OpdsBrowser.test.tsx @@ -0,0 +1,237 @@ +// @vitest-environment jsdom + +import type { OpdsPublication } from "@readany/core"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "./opds-component-test-setup"; + +const harness = vi.hoisted(() => { + let resolveDownload!: (value: unknown) => void; + const download = vi.fn( + () => + new Promise((resolve) => { + resolveDownload = resolve; + }), + ); + const cancel = vi.fn(); + const translate = (key: string, values?: Record) => { + if (values?.title) return `${key}:${values.title}`; + if (values?.format) return `${key}:${values.format}`; + if (values?.percent !== undefined) return `${key}:${values.percent}`; + return key; + }; + return { download, cancel, translate, resolve: (value: unknown) => resolveDownload(value) }; +}); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: harness.translate }), +})); + +vi.mock("./useOpdsDownload", () => ({ + useOpdsDownload: () => ({ + download: harness.download, + cancel: harness.cancel, + progress: { loaded: 25, total: 100 }, + isDownloading: true, + }), +})); + +import { OpdsBrowser } from "./OpdsBrowser"; + +const epub = { + rel: ["http://opds-spec.org/acquisition"], + url: "https://catalog.test/book.epub", + type: "application/epub+zip", + format: "epub" as const, +}; +const pdf = { + rel: ["http://opds-spec.org/acquisition"], + url: "https://catalog.test/book.pdf", + type: "application/pdf", + format: "pdf" as const, +}; + +function publication(index: number): OpdsPublication { + return { + id: `book-${index}`, + title: `Book ${index}`, + authors: ["Reader"], + subjects: [], + description: index === 0 ? '

Author

' : undefined, + images: [], + acquisitions: index === 0 ? [epub, pdf] : [], + readingOrder: [], + }; +} + +describe("OpdsBrowser", () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + it("windows a dense feed and wires nested format progress and cancellation UI", async () => { + const store = { getCredentials: vi.fn(async () => undefined) }; + const client = { + open: vi.fn(async () => ({ + title: "Dense Shelf", + navigation: [], + publications: Array.from({ length: 40 }, (_, index) => publication(index)), + groups: [], + facets: [], + })), + }; + const { container, unmount } = render( + , + ); + + await screen.findByRole("heading", { name: "Dense Shelf" }); + expect(container.querySelectorAll("article")).toHaveLength(18); + await userEvent.click( + screen.getByRole("button", { name: "library.opds.publicationDetails:Book 0" }), + ); + await userEvent.click(screen.getByRole("button", { name: "library.opds.chooseFormat" })); + expect(screen.getByRole("dialog", { name: "library.opds.chooseFormat" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "library.opds.close" })).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "library.opds.downloadFormat:EPUB" })); + + expect(await screen.findByRole("status")).toBeTruthy(); + expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe("25"); + await userEvent.click(screen.getByRole("button", { name: "library.opds.cancel" })); + expect(harness.cancel).toHaveBeenCalledOnce(); + expect(screen.queryByRole("status")).toBeNull(); + + harness.resolve({ importResult: { imported: [], skippedDuplicates: [], failures: [] } }); + await waitFor(() => expect(harness.download).toHaveBeenCalledOnce()); + unmount(); + }); + + it("cancels during deferred credentials before the download transport starts", async () => { + let resolveCredentials!: () => void; + const credentials = new Promise((resolve) => { + resolveCredentials = resolve; + }); + const store = { + getCredentials: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockImplementationOnce(() => credentials), + }; + const singleFormat = { ...publication(0), acquisitions: [epub] }; + const client = { + open: vi.fn(async () => ({ + title: "Single Shelf", + navigation: [], + publications: [singleFormat], + groups: [], + facets: [], + })), + }; + render( + , + ); + await screen.findByRole("heading", { name: "Single Shelf" }); + await userEvent.click( + screen.getByRole("button", { name: "library.opds.publicationDetails:Book 0" }), + ); + await userEvent.click(screen.getByRole("button", { name: "library.opds.downloadAndImport" })); + await userEvent.click(screen.getByRole("button", { name: "library.opds.cancel" })); + resolveCredentials(); + await Promise.resolve(); + + expect(harness.download).not.toHaveBeenCalled(); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("dismisses a failed push and suppresses a later cancelled push", async () => { + let resolveChild!: (value: unknown) => void; + const child = new Promise((resolve) => { + resolveChild = resolve; + }); + const rootFeed = { + title: "Root Shelf", + navigation: [{ rel: ["subsection"], title: "Child", url: "https://catalog.test/child" }], + publications: [], + groups: [], + facets: [], + }; + let opens = 0; + const client = { + open: vi.fn(() => { + opens += 1; + if (opens === 1) return Promise.resolve(rootFeed); + if (opens === 2) return Promise.reject(new Error("offline")); + return child; + }), + }; + const onBack = vi.fn(); + render( + undefined) } as never} + client={client as never} + onBack={onBack} + onEditCredentials={vi.fn()} + registerBackHandler={vi.fn()} + />, + ); + await screen.findByRole("heading", { name: "Root Shelf" }); + await userEvent.click(screen.getByRole("button", { name: "Child" })); + await screen.findByRole("alert"); + await userEvent.click(screen.getByRole("button", { name: "library.opds.back" })); + expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.getByRole("heading", { name: "Root Shelf" })).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: "Child" })); + await userEvent.click(screen.getByRole("button", { name: "library.opds.back" })); + resolveChild({ ...rootFeed, title: "Stale Child" }); + await Promise.resolve(); + + expect(screen.getByRole("heading", { name: "Root Shelf" })).toBeTruthy(); + expect(screen.queryByRole("heading", { name: "Stale Child" })).toBeNull(); + expect(onBack).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/home/OpdsBrowser.tsx b/packages/app/src/components/home/OpdsBrowser.tsx index 396cc503f..960eaf91a 100644 --- a/packages/app/src/components/home/OpdsBrowser.tsx +++ b/packages/app/src/components/home/OpdsBrowser.tsx @@ -17,10 +17,14 @@ import { type OpdsErrorCode, type OpdsFeed, type OpdsPublication, + createInitialOpdsViewState, + createOpdsBackController, createOpdsCoverCache, + getOpdsReadySnapshot, listSupportedAcquisitions, + opdsViewReducer, readOpdsCover, - sanitizeOpdsDescription, + selectOpdsFeed, } from "@readany/core"; import { ArrowLeft, @@ -41,10 +45,14 @@ import { useCallback, useEffect, useMemo, + useReducer, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; +import { OpdsDescription } from "./OpdsDescription"; +import { createOpdsDesktopDownloadController } from "./opds-desktop-download-controller"; +import { windowOpdsFeedPublications } from "./opds-desktop-feed-window"; import { createOpdsDesktopRequestController } from "./opds-desktop-request-controller"; import { useOpdsDownload } from "./useOpdsDownload"; @@ -59,18 +67,6 @@ interface OpdsBrowserProps { type LoadMode = "replace" | "push" | "back" | "refresh"; -interface ReadySnapshot { - feed: OpdsFeed; - currentUrl: string; - history: string[]; -} - -type ContentState = - | { status: "idle" } - | ({ status: "loading"; refreshing: boolean } & Partial) - | ({ status: "ready"; refreshing: boolean } & ReadySnapshot) - | ({ status: "error"; error: OpdsErrorCode } & Partial); - type DownloadState = | { status: "idle" } | { status: "downloading"; title: string } @@ -91,6 +87,7 @@ type Operation = ( const MAX_COVER_BYTES = 4 * 1024 * 1024; const MAX_COVER_CACHE_BYTES = 8 * 1024 * 1024; const MAX_COVER_CACHE_ENTRIES = 12; +const INITIAL_PUBLICATION_WINDOW = 18; function errorCode(error: unknown, fallback: OpdsErrorCode = "unreachable"): OpdsErrorCode { return error instanceof OpdsError ? error.code : fallback; @@ -149,22 +146,26 @@ export function OpdsBrowser({ registerBackHandler, }: OpdsBrowserProps) { const { t } = useTranslation(); - const [content, setContent] = useState({ status: "idle" }); + const [viewState, dispatch] = useReducer(opdsViewReducer, undefined, createInitialOpdsViewState); + const content = viewState.content; const [query, setQuery] = useState(""); const [expanded, setExpanded] = useState(); const [formatChoice, setFormatChoice] = useState(); const [downloadState, setDownloadState] = useState({ status: "idle" }); + const [publicationLimit, setPublicationLimit] = useState(INITIAL_PUBLICATION_WINDOW); const [lastDownload, setLastDownload] = useState<{ publication: OpdsPublication; acquisition: OpdsAcquisition; }>(); const mounted = useRef(true); - const snapshot = useRef(undefined); + const viewStateRef = useRef(viewState); + viewStateRef.current = viewState; + const headingRef = useRef(null); + const requestSequence = useRef(0); const operations = useRef(new Map()); const lastOperation = useRef<{ key: string; mode: LoadMode; execute: Operation } | undefined>( undefined, ); - const cancelledDownload = useRef(false); const { download, cancel, progress } = useOpdsDownload(); const catalogOrigin = new URL(catalog.url).origin; const requestController = useMemo( @@ -174,6 +175,13 @@ export function OpdsBrowser({ }), [catalog.id, store], ); + const downloadController = useMemo( + () => + createOpdsDesktopDownloadController({ + prepare: () => store.getCredentials(catalog.id), + }), + [catalog.id, store], + ); const coverCache = useMemo( () => @@ -194,27 +202,16 @@ export function OpdsBrowser({ async (key: string, mode: LoadMode, execute: Operation) => { operations.current.set(key, execute); lastOperation.current = { key, mode, execute }; - const previous = snapshot.current; - setContent( - mode === "refresh" && previous - ? { ...previous, status: "loading", refreshing: true } - : { ...(previous ?? {}), status: "loading", refreshing: false }, - ); + const requestId = ++requestSequence.current; + dispatch({ type: "loadStarted", requestId, url: key, mode }); try { const feed = await requestController.run(execute); if (!feed || !mounted.current) return; - let history: string[] = []; - if (previous) { - if (mode === "push") history = [...previous.history, previous.currentUrl]; - else if (mode === "back") history = previous.history.slice(0, -1); - else history = previous.history; - } - const next = { feed, currentUrl: key, history }; - snapshot.current = next; - setContent({ ...next, status: "ready", refreshing: false }); + if (mode !== "refresh") setPublicationLimit(INITIAL_PUBLICATION_WINDOW); + dispatch({ type: "loadSucceeded", requestId, feed }); } catch (error) { if (!mounted.current) return; - setContent({ ...(previous ?? {}), status: "error", error: errorCode(error) }); + dispatch({ type: "loadFailed", requestId, error: errorCode(error) }); } }, [requestController], @@ -231,39 +228,45 @@ export function OpdsBrowser({ useEffect(() => { mounted.current = true; + headingRef.current?.focus(); openUrl(catalog.url, "replace"); return () => { mounted.current = false; - requestController.cancel(); + requestController.dispose(); coverCache.clear(); - cancel(); + if (downloadController.dispose()) cancel(); }; - }, [cancel, catalog.url, coverCache, openUrl, requestController]); + }, [cancel, catalog.url, coverCache, downloadController, openUrl, requestController]); const handleBack = useCallback((): boolean => { - const current = snapshot.current; - if (content.status === "loading" && current) { - requestController.cancel(); - setContent({ ...current, status: "ready", refreshing: false }); - return true; - } - const target = current?.history[current.history.length - 1]; - if (!target) { - onBack(); - return false; - } - const operation = operations.current.get(target); - if (operation) void startOperation(target, "back", operation); - else openUrl(target, "back"); - return true; - }, [content.status, onBack, openUrl, requestController, startOperation]); + let exited = false; + createOpdsBackController({ + getState: () => viewStateRef.current, + cancelRequest: requestController.cancel, + dispatch, + startBack: (target) => { + const operation = operations.current.get(target); + if (operation) void startOperation(target, "back", operation); + else openUrl(target, "back"); + }, + exit: () => { + exited = true; + onBack(); + }, + }).handleHeaderBack(); + return !exited; + }, [onBack, openUrl, requestController.cancel, startOperation]); useEffect(() => { registerBackHandler(handleBack); return () => registerBackHandler(undefined); }, [handleBack, registerBackHandler]); - const feed = "feed" in content ? content.feed : undefined; + const feed = selectOpdsFeed(viewState); + const windowedFeed = useMemo( + () => (feed ? windowOpdsFeedPublications(feed, publicationLimit) : undefined), + [feed, publicationLimit], + ); const runSearch = (event: FormEvent) => { event.preventDefault(); @@ -277,7 +280,7 @@ export function OpdsBrowser({ }; const refresh = () => { - const current = snapshot.current; + const current = getOpdsReadySnapshot(content); if (!current) return; const operation = operations.current.get(current.currentUrl); if (operation) void startOperation(current.currentUrl, "refresh", operation); @@ -285,27 +288,41 @@ export function OpdsBrowser({ const retry = () => { const operation = lastOperation.current; - if (operation) void startOperation(operation.key, operation.mode, operation.execute); + if (!operation || content.status !== "error") return; + const requestId = ++requestSequence.current; + dispatch({ type: "retryStarted", requestId }); + void requestController + .run(operation.execute) + .then((feed) => { + if (feed && mounted.current) { + if (operation.mode !== "refresh") setPublicationLimit(INITIAL_PUBLICATION_WINDOW); + dispatch({ type: "loadSucceeded", requestId, feed }); + } + }) + .catch((error) => { + if (mounted.current) dispatch({ type: "loadFailed", requestId, error: errorCode(error) }); + }); }; const runDownload = useCallback( async (publication: OpdsPublication, acquisition: OpdsAcquisition) => { setLastDownload({ publication, acquisition }); - cancelledDownload.current = false; setDownloadState({ status: "downloading", title: publication.title }); try { - const credentials = await store.getCredentials(catalog.id); - const result = await download({ - publication, - acquisition, - catalogOrigin, - credentials, - onImportStart: () => { - if (mounted.current) - setDownloadState({ status: "importing", title: publication.title }); - }, - }); - if (mounted.current) { + const result = await downloadController.run(async (credentials, ownership) => + download({ + publication, + acquisition, + catalogOrigin, + credentials, + onImportStart: () => { + ownership.markImportStarted(); + if (mounted.current) + setDownloadState({ status: "importing", title: publication.title }); + }, + }), + ); + if (result && mounted.current) { setDownloadState({ status: "success", title: publication.title, @@ -313,7 +330,7 @@ export function OpdsBrowser({ }); } } catch (error) { - if (!mounted.current || cancelledDownload.current) return; + if (!mounted.current || errorCode(error) === "download-in-progress") return; setDownloadState({ status: "error", title: publication.title, @@ -321,7 +338,7 @@ export function OpdsBrowser({ }); } }, - [catalog.id, catalogOrigin, download, store], + [catalogOrigin, download, downloadController], ); const chooseDownload = (publication: OpdsPublication) => { @@ -332,7 +349,7 @@ export function OpdsBrowser({ const cancelDownload = () => { if (downloadState.status !== "downloading") return; - cancelledDownload.current = true; + if (!downloadController.cancel()) return; cancel(); setDownloadState({ status: "idle" }); }; @@ -341,9 +358,7 @@ export function OpdsBrowser({ const key = `${prefix}:${publication.id ?? publication.title}`; const isExpanded = expanded === key; const formats = listSupportedAcquisitions(publication); - const description = publication.description - ? sanitizeOpdsDescription(publication.description) - : undefined; + const description = publication.description; return (
@@ -513,8 +535,14 @@ export function OpdsBrowser({ {feed ? (
{feed.subtitle ? (

{feed.subtitle}

@@ -558,18 +586,18 @@ export function OpdsBrowser({ ))} - {feed.publications.length ? ( + {windowedFeed?.publications.length ? (
{t("library.opds.books")}
- {feed.publications.map((publication) => + {windowedFeed.publications.map((publication) => renderPublication(publication, "publication"), )}
) : null} - {feed.groups.map((group, groupIndex) => ( + {windowedFeed?.groups.map((group, groupIndex) => (
{group.title} {group.navigation.length ? ( @@ -594,6 +622,19 @@ export function OpdsBrowser({
))} + {windowedFeed?.hasMore ? ( +
+ +
+ ) : null} + {!feed.navigation.length && !feed.publications.length && !feed.groups.length ? (
@@ -711,7 +752,7 @@ export function OpdsBrowser({ ) : null} !open && setFormatChoice(undefined)}> - + {t("library.opds.chooseFormat")} {formatChoice?.publication.title} diff --git a/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx b/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx new file mode 100644 index 000000000..fa9b3b047 --- /dev/null +++ b/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "./opds-component-test-setup"; +import { OpdsCatalogFormDialog } from "./OpdsCatalogFormDialog"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe("OpdsCatalogFormDialog", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("exposes a localized, keyboard-complete Basic-auth add flow", async () => { + const addCatalog = vi.fn(async (_input: unknown) => ({ id: "added" })); + const onOpenChange = vi.fn(); + const onSaved = vi.fn(); + render( + , + ); + + expect(screen.getByRole("dialog", { name: "library.opds.form.addTitle" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "library.opds.close" })).toBeTruthy(); + const anonymous = screen.getByRole("radio", { name: "library.opds.form.anonymous" }); + anonymous.focus(); + await userEvent.keyboard("{ArrowRight}"); + expect( + (screen.getByRole("radio", { name: "library.opds.form.basic" }) as HTMLInputElement).checked, + ).toBe(true); + + await userEvent.type(screen.getByLabelText("library.opds.form.name"), "Private shelf"); + await userEvent.type( + screen.getByLabelText("library.opds.form.url"), + "https://catalog.test/opds", + ); + await userEvent.type(screen.getByLabelText("library.opds.form.username"), "reader"); + await userEvent.type(screen.getByLabelText("library.opds.form.password"), "secret"); + expect(screen.getByRole("button", { name: "library.opds.showPassword" })).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "library.opds.save" })); + + await waitFor(() => expect(addCatalog).toHaveBeenCalledOnce()); + expect(addCatalog.mock.calls[0]?.[0]).toMatchObject({ + name: "Private shelf", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "secret", + }); + expect(onSaved).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app/src/components/home/OpdsCatalogFormDialog.tsx b/packages/app/src/components/home/OpdsCatalogFormDialog.tsx index 7d12fc617..d413b4b4b 100644 --- a/packages/app/src/components/home/OpdsCatalogFormDialog.tsx +++ b/packages/app/src/components/home/OpdsCatalogFormDialog.tsx @@ -117,7 +117,10 @@ export function OpdsCatalogFormDialog({ return ( - + {catalog ? t("library.opds.form.editTitle") : t("library.opds.form.addTitle")} @@ -206,6 +209,8 @@ export function OpdsCatalogFormDialog({ autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} + showPasswordLabel={t("library.opds.showPassword")} + hidePasswordLabel={t("library.opds.hidePassword")} placeholder={ catalog && hasPassword ? t("library.opds.form.passwordUnchanged") : undefined } diff --git a/packages/app/src/components/home/OpdsCatalogsDialog.test.tsx b/packages/app/src/components/home/OpdsCatalogsDialog.test.tsx new file mode 100644 index 000000000..6c6af33e8 --- /dev/null +++ b/packages/app/src/components/home/OpdsCatalogsDialog.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment jsdom + +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "./opds-component-test-setup"; + +const harness = vi.hoisted(() => { + const catalog = { + id: "custom", + name: "Test Catalog", + url: "https://catalog.test/opds", + auth: "anonymous" as const, + enabled: true, + builtIn: false, + hidden: false, + passwordStorage: "none" as const, + }; + const store = { + listCatalogs: vi.fn(() => [catalog]), + getCredentials: vi.fn(async () => undefined), + removeCatalog: vi.fn(async () => undefined), + setCatalogEnabled: vi.fn(async () => undefined), + hideBuiltIn: vi.fn(async () => undefined), + restoreBuiltIn: vi.fn(async () => undefined), + }; + const client = { + open: vi.fn(async () => ({ + title: "Test Shelf", + navigation: [], + publications: [], + groups: [], + facets: [], + })), + }; + const ensureCatalogsLoaded = vi.fn(async () => undefined); + const translate = (key: string, values?: Record) => + values?.name ? `${key}:${values.name}` : key; + const download = vi.fn(); + const cancelDownload = vi.fn(); + return { + catalog, + store, + client, + ensureCatalogsLoaded, + translate, + download, + cancelDownload, + }; +}); + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: harness.translate }), +})); + +vi.mock("./opds-desktop-runtime", () => ({ + opdsDesktopRuntime: { + ensureCatalogsLoaded: harness.ensureCatalogsLoaded, + getCatalogStore: () => harness.store, + getClient: () => harness.client, + }, +})); + +vi.mock("./useOpdsDownload", () => ({ + useOpdsDownload: () => ({ + download: harness.download, + cancel: harness.cancelDownload, + progress: null, + isDownloading: false, + }), +})); + +import { OpdsCatalogsDialog } from "./OpdsCatalogsDialog"; + +describe("OpdsCatalogsDialog", () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + document.body.removeAttribute("style"); + document.body.removeAttribute("data-scroll-locked"); + }); + + it("keeps the dialog named and moves focus into and back out of browser mode", async () => { + render(); + await waitFor(() => expect(harness.ensureCatalogsLoaded).toHaveBeenCalledOnce()); + await waitFor(() => expect(harness.store.listCatalogs).toHaveBeenCalled()); + const browse = await screen.findByRole("button", { + name: "library.opds.browseCatalog:Test Catalog", + }); + expect(screen.getByRole("dialog", { name: "library.opds.catalogsTitle" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "library.opds.close" })).toBeTruthy(); + + await userEvent.click(browse); + + const browserHeading = await screen.findByRole("heading", { name: "Test Shelf" }); + expect(screen.getByRole("dialog", { name: "Test Catalog" })).toBeTruthy(); + await waitFor(() => expect(document.activeElement).toBe(browserHeading)); + await userEvent.keyboard("{Escape}"); + + const restored = await screen.findByRole("button", { + name: "library.opds.browseCatalog:Test Catalog", + }); + await waitFor(() => expect(document.activeElement).toBe(restored)); + }); + + it("wires the localized nested delete dialog to custom catalog deletion", async () => { + render(); + await waitFor(() => expect(harness.store.listCatalogs).toHaveBeenCalled()); + const trigger = await screen.findByRole("button", { + name: "library.opds.deleteCatalog:Test Catalog", + }); + await userEvent.click(trigger); + + expect(screen.getByRole("dialog", { name: "library.opds.deleteTitle" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "library.opds.close" })).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "library.opds.delete" })); + + await waitFor(() => expect(harness.store.removeCatalog).toHaveBeenCalledWith("custom")); + }); + + it("returns focus to the originating control when the dialog closes", async () => { + function Host() { + const [open, setOpen] = useState(false); + return ( + <> + + + + ); + } + render(); + const origin = screen.getByRole("button", { name: "Open catalogs" }); + await userEvent.click(origin); + const dialog = await screen.findByRole("dialog", { name: "library.opds.catalogsTitle" }); + for (let index = 0; index < 8; index += 1) { + await userEvent.tab(); + expect(dialog.contains(document.activeElement)).toBe(true); + } + await userEvent.click(screen.getByRole("button", { name: "library.opds.close" })); + + await waitFor(() => expect(document.activeElement).toBe(origin)); + }); +}); diff --git a/packages/app/src/components/home/OpdsCatalogsDialog.tsx b/packages/app/src/components/home/OpdsCatalogsDialog.tsx index 8548bdf5a..ad12a8723 100644 --- a/packages/app/src/components/home/OpdsCatalogsDialog.tsx +++ b/packages/app/src/components/home/OpdsCatalogsDialog.tsx @@ -44,6 +44,9 @@ export function OpdsCatalogsDialog({ open, onOpenChange }: OpdsCatalogsDialogPro const [busyId, setBusyId] = useState(); const [error, setError] = useState(); const backHandler = useRef<(() => boolean) | undefined>(undefined); + const dialogOrigin = useRef(undefined); + const returnFocusCatalogId = useRef(undefined); + const returnFocusElement = useRef(undefined); const syncCatalogs = useCallback(() => { const next = store.listCatalogs({ includeHidden: true }); @@ -80,6 +83,12 @@ export function OpdsCatalogsDialog({ open, onOpenChange }: OpdsCatalogsDialogPro }; }, [open, syncCatalogs, t]); + useEffect(() => { + if (open && !selected && returnFocusCatalogId.current) { + requestAnimationFrame(() => returnFocusElement.current?.focus()); + } + }, [open, selected]); + const mutate = async (catalogId: string, operation: () => Promise) => { setBusyId(catalogId); setError(undefined); @@ -107,6 +116,18 @@ export function OpdsCatalogsDialog({ open, onOpenChange }: OpdsCatalogsDialogPro <> { + if (document.activeElement instanceof HTMLElement) { + dialogOrigin.current = document.activeElement; + } + }} + onCloseAutoFocus={(event) => { + const origin = dialogOrigin.current; + if (!origin?.isConnected) return; + event.preventDefault(); + origin.focus(); + }} className="flex h-[min(88vh,860px)] max-h-[calc(100vh-24px)] w-[min(1080px,calc(100vw-24px))] max-w-none flex-col gap-0 overflow-hidden p-0" onEscapeKeyDown={(event) => { if (!selected) return; @@ -115,21 +136,27 @@ export function OpdsCatalogsDialog({ open, onOpenChange }: OpdsCatalogsDialogPro }} > {selected ? ( - setSelected(undefined)} - onEditCredentials={() => { - if (selected.builtIn) return; - setEditing(selected); - setFormOpen(true); - }} - registerBackHandler={(handler) => { - backHandler.current = handler; - }} - /> + <> + {selected.name} + + {t("library.opds.catalogsSubtitle")} + + setSelected(undefined)} + onEditCredentials={() => { + if (selected.builtIn) return; + setEditing(selected); + setFormOpen(true); + }} + registerBackHandler={(handler) => { + backHandler.current = handler; + }} + /> + ) : ( <> @@ -200,10 +227,19 @@ export function OpdsCatalogsDialog({ open, onOpenChange }: OpdsCatalogsDialogPro className={`overflow-hidden rounded-2xl border bg-card shadow-sm ${!catalog.enabled ? "opacity-60" : ""}`} > diff --git a/packages/core/src/ci/linux-release-dependencies.test.ts b/packages/core/src/ci/linux-release-dependencies.test.ts new file mode 100644 index 000000000..97dd9ae7b --- /dev/null +++ b/packages/core/src/ci/linux-release-dependencies.test.ts @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const releaseWorkflow = readFileSync( + new URL("../../../../.github/workflows/release.yml", import.meta.url), + "utf8", +); + +describe("Linux Tauri release dependencies", () => { + it.each(["pkg-config", "libdbus-1-dev"])("installs %s before the Ubuntu build", (dependency) => { + const ubuntuInstall = releaseWorkflow.match( + /- name: Install dependencies \(Ubuntu only\)[\s\S]*?sudo apt-get install -y ([^\n]+)/, + ); + + expect(ubuntuInstall?.[1]?.split(/\s+/)).toContain(dependency); + expect(releaseWorkflow.indexOf(dependency)).toBeLessThan( + releaseWorkflow.indexOf("uses: tauri-apps/tauri-action@v0"), + ); + }); +}); diff --git a/packages/core/src/i18n/locales/en/library.json b/packages/core/src/i18n/locales/en/library.json index 16d0381a0..d21b0ff9d 100644 --- a/packages/core/src/i18n/locales/en/library.json +++ b/packages/core/src/i18n/locales/en/library.json @@ -19,6 +19,7 @@ "catalogsTitle": "Online catalogs", "cancel": "Cancel", "chooseFormat": "Choose format", + "close": "Close", "collections": "Collections", "continue": "Continue", "delete": "Delete", @@ -38,6 +39,7 @@ "emptyHint": "Try another collection or go back one level.", "enabled": "Enabled", "hiddenPresets": "Hidden presets", + "hidePassword": "Hide password", "hideCatalog": "Hide {{name}}", "imported": "Imported to your library", "importing": "Adding to your library…", @@ -56,6 +58,8 @@ "retry": "Retry", "save": "Save", "search": "Search", + "showMore": "Show more", + "showPassword": "Show password", "restoreCatalog": "Restore {{name}}", "searchPlaceholder": "Search this catalog…", "toggleCatalog": "Enable or disable {{name}}", diff --git a/packages/core/src/i18n/locales/es/library.json b/packages/core/src/i18n/locales/es/library.json index 3a0bbac11..9de72ceab 100644 --- a/packages/core/src/i18n/locales/es/library.json +++ b/packages/core/src/i18n/locales/es/library.json @@ -19,6 +19,7 @@ "catalogsTitle": "Catálogos en línea", "cancel": "Cancelar", "chooseFormat": "Elegir formato", + "close": "Cerrar", "collections": "Colecciones", "continue": "Continuar", "delete": "Eliminar", @@ -38,6 +39,7 @@ "emptyHint": "Prueba otra colección o vuelve al nivel anterior.", "enabled": "Activado", "hiddenPresets": "Catálogos predefinidos ocultos", + "hidePassword": "Ocultar contraseña", "hideCatalog": "Ocultar {{name}}", "imported": "Importado a tu biblioteca", "importing": "Añadiendo a tu biblioteca…", @@ -56,6 +58,8 @@ "retry": "Reintentar", "save": "Guardar", "search": "Buscar", + "showMore": "Mostrar más", + "showPassword": "Mostrar contraseña", "restoreCatalog": "Restaurar {{name}}", "searchPlaceholder": "Buscar en este catálogo…", "toggleCatalog": "Activar o desactivar {{name}}", diff --git a/packages/core/src/i18n/locales/fr/library.json b/packages/core/src/i18n/locales/fr/library.json index 7ee6d4647..51427c124 100644 --- a/packages/core/src/i18n/locales/fr/library.json +++ b/packages/core/src/i18n/locales/fr/library.json @@ -19,6 +19,7 @@ "catalogsTitle": "Catalogues en ligne", "cancel": "Annuler", "chooseFormat": "Choisir le format", + "close": "Fermer", "collections": "Collections", "continue": "Continuer", "delete": "Supprimer", @@ -38,6 +39,7 @@ "emptyHint": "Essayez une autre collection ou revenez au niveau précédent.", "enabled": "Activé", "hiddenPresets": "Catalogues prédéfinis masqués", + "hidePassword": "Masquer le mot de passe", "hideCatalog": "Masquer {{name}}", "imported": "Importé dans votre bibliothèque", "importing": "Ajout à votre bibliothèque…", @@ -56,6 +58,8 @@ "retry": "Réessayer", "save": "Enregistrer", "search": "Rechercher", + "showMore": "Afficher plus", + "showPassword": "Afficher le mot de passe", "restoreCatalog": "Restaurer {{name}}", "searchPlaceholder": "Rechercher dans ce catalogue…", "toggleCatalog": "Activer ou désactiver {{name}}", diff --git a/packages/core/src/i18n/locales/ja/library.json b/packages/core/src/i18n/locales/ja/library.json index ab6e66bf1..6ed210a2f 100644 --- a/packages/core/src/i18n/locales/ja/library.json +++ b/packages/core/src/i18n/locales/ja/library.json @@ -19,6 +19,7 @@ "catalogsTitle": "オンラインカタログ", "cancel": "キャンセル", "chooseFormat": "形式を選択", + "close": "閉じる", "collections": "コレクション", "continue": "続行", "delete": "削除", @@ -38,6 +39,7 @@ "emptyHint": "別のコレクションを試すか、1つ前に戻ってください。", "enabled": "有効", "hiddenPresets": "非表示のプリセット", + "hidePassword": "パスワードを非表示", "hideCatalog": "{{name}} を非表示", "imported": "ライブラリに取り込みました", "importing": "ライブラリに追加中…", @@ -56,6 +58,8 @@ "retry": "再試行", "save": "保存", "search": "検索", + "showMore": "さらに表示", + "showPassword": "パスワードを表示", "restoreCatalog": "{{name}} を復元", "searchPlaceholder": "このカタログを検索…", "toggleCatalog": "{{name}} の有効・無効を切り替え", diff --git a/packages/core/src/i18n/locales/ko/library.json b/packages/core/src/i18n/locales/ko/library.json index cff275ee0..1e22c2209 100644 --- a/packages/core/src/i18n/locales/ko/library.json +++ b/packages/core/src/i18n/locales/ko/library.json @@ -19,6 +19,7 @@ "catalogsTitle": "온라인 카탈로그", "cancel": "취소", "chooseFormat": "형식 선택", + "close": "닫기", "collections": "컬렉션", "continue": "계속", "delete": "삭제", @@ -38,6 +39,7 @@ "emptyHint": "다른 컬렉션을 열거나 이전 단계로 돌아가세요.", "enabled": "사용", "hiddenPresets": "숨긴 프리셋", + "hidePassword": "비밀번호 숨기기", "hideCatalog": "{{name}} 숨기기", "imported": "라이브러리에 가져왔습니다", "importing": "라이브러리에 추가 중…", @@ -56,6 +58,8 @@ "retry": "다시 시도", "save": "저장", "search": "검색", + "showMore": "더 보기", + "showPassword": "비밀번호 표시", "restoreCatalog": "{{name}} 복원", "searchPlaceholder": "이 카탈로그 검색…", "toggleCatalog": "{{name}} 사용 여부 전환", diff --git a/packages/core/src/i18n/locales/zh-TW/library.json b/packages/core/src/i18n/locales/zh-TW/library.json index f86f411ae..02603a041 100644 --- a/packages/core/src/i18n/locales/zh-TW/library.json +++ b/packages/core/src/i18n/locales/zh-TW/library.json @@ -19,6 +19,7 @@ "catalogsTitle": "線上目錄", "cancel": "取消", "chooseFormat": "選擇格式", + "close": "關閉", "collections": "分類", "continue": "繼續", "delete": "刪除", @@ -38,6 +39,7 @@ "emptyHint": "試試其他分類,或返回上一層。", "enabled": "已啟用", "hiddenPresets": "已隱藏的預設項目", + "hidePassword": "隱藏密碼", "hideCatalog": "隱藏 {{name}}", "imported": "已匯入書庫", "importing": "正在加入書庫…", @@ -56,6 +58,8 @@ "retry": "重試", "save": "儲存", "search": "搜尋", + "showMore": "顯示更多", + "showPassword": "顯示密碼", "restoreCatalog": "還原 {{name}}", "searchPlaceholder": "搜尋此目錄…", "toggleCatalog": "啟用或停用 {{name}}", diff --git a/packages/core/src/i18n/locales/zh/library.json b/packages/core/src/i18n/locales/zh/library.json index 53623095c..84694e902 100644 --- a/packages/core/src/i18n/locales/zh/library.json +++ b/packages/core/src/i18n/locales/zh/library.json @@ -19,6 +19,7 @@ "catalogsTitle": "在线目录", "cancel": "取消", "chooseFormat": "选择格式", + "close": "关闭", "collections": "分类", "continue": "继续", "delete": "删除", @@ -38,6 +39,7 @@ "emptyHint": "试试其他分类,或返回上一级。", "enabled": "已启用", "hiddenPresets": "已隐藏的预设", + "hidePassword": "隐藏密码", "hideCatalog": "隐藏 {{name}}", "imported": "已导入书库", "importing": "正在添加到书库…", @@ -56,6 +58,8 @@ "retry": "重试", "save": "保存", "search": "搜索", + "showMore": "显示更多", + "showPassword": "显示密码", "restoreCatalog": "恢复 {{name}}", "searchPlaceholder": "搜索此目录…", "toggleCatalog": "启用或停用 {{name}}", diff --git a/packages/core/src/i18n/opds-locales.test.ts b/packages/core/src/i18n/opds-locales.test.ts index d1fc4f5b9..9c3e411df 100644 --- a/packages/core/src/i18n/opds-locales.test.ts +++ b/packages/core/src/i18n/opds-locales.test.ts @@ -26,6 +26,7 @@ const REQUIRED_KEYS = [ "catalogsTitle", "cancel", "chooseFormat", + "close", "collections", "continue", "delete", @@ -46,6 +47,7 @@ const REQUIRED_KEYS = [ "enabled", "hiddenPresets", "hideCatalog", + "hidePassword", "imported", "importing", "loadFailed", @@ -65,6 +67,8 @@ const REQUIRED_KEYS = [ "search", "retry", "save", + "showMore", + "showPassword", "toggleCatalog", "unknownAuthor", "unsupportedExplanation", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 067f235ff..f6e337030 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -88,6 +88,8 @@ export { export { parseOpdsDocument } from "./opds/opds-parser"; export { classifyOpdsUrl } from "./opds/opds-security"; export { createOpdsRuntime } from "./opds/opds-runtime"; +export * from "./opds/opds-view-state"; +export { createOpdsBackController } from "./opds/opds-back-controller"; export { createOpdsCoverCache, readOpdsCover, diff --git a/packages/core/src/opds/opds-back-controller.ts b/packages/core/src/opds/opds-back-controller.ts new file mode 100644 index 000000000..3ef752473 --- /dev/null +++ b/packages/core/src/opds/opds-back-controller.ts @@ -0,0 +1,53 @@ +import type { OpdsViewAction, OpdsViewState } from "./opds-view-state"; + +interface OpdsBackDependencies { + getState(): OpdsViewState; + cancelRequest(): void; + dispatch(action: OpdsViewAction): void; + startBack(url: string): void; + exit(): void; +} + +export function createOpdsBackController(dependencies: OpdsBackDependencies) { + const consumeInternalBack = (): boolean => { + const { content } = dependencies.getState(); + if ( + content.status === "loading" && + content.previous && + (content.pending.mode === "push" || content.pending.mode === "back") + ) { + dependencies.cancelRequest(); + dependencies.dispatch({ type: "loadCancelled", requestId: content.requestId }); + return true; + } + if ( + content.status === "error" && + content.previous && + (content.failedRequest.mode === "push" || content.failedRequest.mode === "back") + ) { + dependencies.dispatch({ type: "loadCancelled", requestId: content.failedRequestId }); + return true; + } + const snapshot = + content.status === "ready" + ? content + : content.status === "loading" || content.status === "error" + ? content.previous + : undefined; + const target = snapshot?.history[snapshot.history.length - 1]; + if (!target) return false; + dependencies.cancelRequest(); + dependencies.startBack(target); + return true; + }; + + return { + handleHeaderBack(): void { + if (!consumeInternalBack()) dependencies.exit(); + }, + handleBeforeRemove(event: { preventDefault(): void }): void { + if (!consumeInternalBack()) return; + event.preventDefault(); + }, + }; +} diff --git a/packages/core/src/opds/opds-cover-cache.test.ts b/packages/core/src/opds/opds-cover-cache.test.ts index b3ee0b6bb..ca37df274 100644 --- a/packages/core/src/opds/opds-cover-cache.test.ts +++ b/packages/core/src/opds/opds-cover-cache.test.ts @@ -9,6 +9,14 @@ function imageResponse(bytes: number[], headers: Record = {}) { return Object.assign(response, { cancel: vi.fn(async () => undefined) }) as OpdsAssetResponse; } +function deferred() { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + describe("shared OPDS cover cache", () => { it("deduplicates in-flight authenticated image reads", async () => { const load = vi.fn(async () => ({ uri: "data:image/png;base64,AQ==", byteLength: 1 })); @@ -33,6 +41,50 @@ describe("shared OPDS cover cache", () => { expect(cache.snapshot()).toEqual({ entries: 1, sourceBytes: 4, urls: ["second"] }); }); + it("never admits beyond entry or byte bounds while every cached cover is leased", async () => { + const cache = createOpdsCoverCache({ + load: async (url) => ({ uri: url, byteLength: 2 }), + maxEntries: 12, + maxBytes: 24, + }); + const leases = []; + for (let index = 0; index < 20; index += 1) { + leases.push(await cache.acquire(`cover-${index}`)); + const snapshot = cache.snapshot(); + expect(snapshot.entries).toBeLessThanOrEqual(12); + expect(snapshot.sourceBytes).toBeLessThanOrEqual(24); + } + + expect(cache.snapshot()).toMatchObject({ entries: 12, sourceBytes: 24 }); + for (const lease of leases) lease.release(); + }); + + it("caps concurrent distinct loads", async () => { + const gate = deferred(); + let active = 0; + let maximumActive = 0; + const cache = createOpdsCoverCache({ + load: async (url) => { + active += 1; + maximumActive = Math.max(maximumActive, active); + await gate.promise; + active -= 1; + return { uri: url, byteLength: 1 }; + }, + maxEntries: 12, + maxBytes: 24, + maxConcurrentLoads: 3, + }); + + const pending = Array.from({ length: 20 }, (_, index) => cache.acquire(`cover-${index}`)); + await Promise.resolve(); + expect(maximumActive).toBe(3); + gate.resolve(); + const leases = await Promise.all(pending); + expect(maximumActive).toBe(3); + for (const lease of leases) lease.release(); + }); + it("rejects a streamed non-image or oversized cover and cancels transport", async () => { const wrongType = imageResponse([1], { "Content-Type": "text/html" }); await expect(readOpdsCover(wrongType, new AbortController().signal, 4)).rejects.toThrow( diff --git a/packages/core/src/opds/opds-cover-cache.ts b/packages/core/src/opds/opds-cover-cache.ts index 52ecf0af6..b3ce56d13 100644 --- a/packages/core/src/opds/opds-cover-cache.ts +++ b/packages/core/src/opds/opds-cover-cache.ts @@ -109,16 +109,49 @@ export function createOpdsCoverCache({ load, maxEntries, maxBytes, + maxConcurrentLoads = 4, }: { load(url: string, signal: AbortSignal): Promise; maxEntries: number; maxBytes: number; + maxConcurrentLoads?: number; }) { const entries = new Map(); const inFlight = new Map(); let sourceBytes = 0; let clock = 0; let generation = 0; + let activeLoads = 0; + const queuedLoads: Array<() => void> = []; + + const runQueuedLoads = () => { + const limit = Math.max(1, maxConcurrentLoads); + while (activeLoads < limit) { + const start = queuedLoads.shift(); + if (!start) return; + activeLoads += 1; + start(); + } + }; + + const scheduleLoad = (url: string, signal: AbortSignal): Promise => + new Promise((resolve, reject) => { + queuedLoads.push(() => { + if (signal.aborted) { + activeLoads -= 1; + reject(new Error("cancelled")); + runQueuedLoads(); + return; + } + void load(url, signal) + .then(resolve, reject) + .finally(() => { + activeLoads -= 1; + runQueuedLoads(); + }); + }); + runQueuedLoads(); + }); const evict = () => { while (entries.size > maxEntries || sourceBytes > maxBytes) { @@ -157,7 +190,7 @@ export function createOpdsCoverCache({ let pending = inFlight.get(url); if (!pending) { const controller = new AbortController(); - const promise = load(url, controller.signal).finally(() => { + const promise = scheduleLoad(url, controller.signal).finally(() => { if (inFlight.get(url)?.promise === promise) inFlight.delete(url); }); pending = { controller, promise, waiters: 0 }; @@ -177,10 +210,19 @@ export function createOpdsCoverCache({ if (acquisitionGeneration !== generation) throw new Error("cancelled"); let entry = entries.get(url); if (!entry && value.byteLength <= maxBytes && maxEntries > 0) { - entry = { ...value, references: 0, lastUsed: ++clock }; - entries.set(url, entry); - sourceBytes += value.byteLength; - evict(); + while (entries.size >= maxEntries || sourceBytes + value.byteLength > maxBytes) { + const candidate = [...entries.entries()] + .filter(([, cached]) => cached.references === 0) + .sort(([, left], [, right]) => left.lastUsed - right.lastUsed)[0]; + if (!candidate) break; + entries.delete(candidate[0]); + sourceBytes -= candidate[1].byteLength; + } + if (entries.size < maxEntries && sourceBytes + value.byteLength <= maxBytes) { + entry = { ...value, references: 0, lastUsed: ++clock }; + entries.set(url, entry); + sourceBytes += value.byteLength; + } } return entry ? lease(entry) : { uri: value.uri, release() {} }; } finally { diff --git a/packages/core/src/opds/opds-parser.test.ts b/packages/core/src/opds/opds-parser.test.ts index 587411975..cd2951f1f 100644 --- a/packages/core/src/opds/opds-parser.test.ts +++ b/packages/core/src/opds/opds-parser.test.ts @@ -172,7 +172,7 @@ describe("parseOpdsDocument", () => { published: "2025-01-02", subjects: ["Mystery", "Adventure"], description: - '

Read this. Chapter About Details Bad

', + '

Read this. Chapter About Details Bad

', images: [expect.objectContaining({ url: "https://catalog.test/root/images/cover.png" })], acquisitions: [ expect.objectContaining({ diff --git a/packages/core/src/opds/opds-sanitize.test.ts b/packages/core/src/opds/opds-sanitize.test.ts index 3352db0eb..5a7768e60 100644 --- a/packages/core/src/opds/opds-sanitize.test.ts +++ b/packages/core/src/opds/opds-sanitize.test.ts @@ -45,7 +45,7 @@ describe("sanitizeOpdsDescription", () => { `Book`, "https://catalog.test/root/feed", ), - ).toBe(`Book`); + ).toBe(`Book`); }); it.each([ @@ -81,7 +81,7 @@ describe("sanitizeOpdsDescription", () => { expect(ALLOWED_ELEMENTS.has(element.localName)).toBe(true); for (const attribute of Array.from(element.attributes)) { expect(element.localName).toBe("a"); - expect(attribute.name).toBe("href"); + expect(["href", "target", "rel"]).toContain(attribute.name); } const href = element.getAttribute("href"); if (href) expect(["http:", "https:"]).toContain(new URL(href).protocol); diff --git a/packages/core/src/opds/opds-sanitize.ts b/packages/core/src/opds/opds-sanitize.ts index 02cc7bca2..758f6dd79 100644 --- a/packages/core/src/opds/opds-sanitize.ts +++ b/packages/core/src/opds/opds-sanitize.ts @@ -114,7 +114,11 @@ export function sanitizeOpdsDescription(input: string, documentUrl?: string): st if (name === "a") { const href = getSafeHref(token.slice(token.indexOf(name) + name.length), documentUrl); - output.push(href ? `` : ""); + output.push( + href + ? `` + : "", + ); } else { output.push(`<${name}>`); } diff --git a/packages/core/src/opds/opds-view-state.ts b/packages/core/src/opds/opds-view-state.ts new file mode 100644 index 000000000..e3505440a --- /dev/null +++ b/packages/core/src/opds/opds-view-state.ts @@ -0,0 +1,300 @@ +import type { OpdsErrorCode } from "./opds-client"; +import type { OpdsFeed } from "./opds-types"; + +export type OpdsLoadMode = "replace" | "refresh" | "push" | "back"; + +export interface OpdsPendingRequest { + readonly url: string; + readonly mode: OpdsLoadMode; +} + +export interface OpdsReadySnapshot { + readonly feed: OpdsFeed; + readonly currentUrl: string; + readonly history: readonly string[]; +} + +export type OpdsContentState = + | { readonly status: "idle" } + | { + readonly status: "loading"; + readonly requestId: number; + readonly pending: OpdsPendingRequest; + readonly previous?: OpdsReadySnapshot; + } + | (OpdsReadySnapshot & { + readonly status: "ready"; + readonly refreshing: boolean; + readonly requestId?: number; + readonly pending?: OpdsPendingRequest; + }) + | { + readonly status: "error"; + readonly failedRequestId: number; + readonly error: OpdsErrorCode; + readonly failedRequest: OpdsPendingRequest; + readonly previous?: OpdsReadySnapshot; + }; + +export type OpdsDownloadState = + | { readonly status: "idle" } + | { + readonly status: "downloading"; + readonly requestId: number; + readonly publicationTitle: string; + readonly loaded: number; + readonly total: number; + } + | { + readonly status: "success"; + readonly requestId: number; + readonly publicationTitle: string; + readonly importedCount: number; + } + | { + readonly status: "importing"; + readonly requestId: number; + readonly publicationTitle: string; + } + | { + readonly status: "error"; + readonly requestId: number; + readonly publicationTitle: string; + readonly error: OpdsErrorCode; + }; + +export interface OpdsViewState { + readonly content: OpdsContentState; + readonly download: OpdsDownloadState; +} + +export type OpdsViewAction = + | { + readonly type: "loadStarted"; + readonly requestId: number; + readonly url: string; + readonly mode: OpdsLoadMode; + } + | { readonly type: "loadSucceeded"; readonly requestId: number; readonly feed: OpdsFeed } + | { readonly type: "loadFailed"; readonly requestId: number; readonly error: OpdsErrorCode } + | { readonly type: "retryStarted"; readonly requestId: number } + | { readonly type: "loadCancelled"; readonly requestId: number } + | { + readonly type: "downloadStarted"; + readonly requestId: number; + readonly publicationTitle: string; + } + | { + readonly type: "downloadProgress"; + readonly requestId: number; + readonly loaded: number; + readonly total: number; + } + | { + readonly type: "downloadSucceeded"; + readonly requestId: number; + readonly importedCount: number; + } + | { readonly type: "downloadImporting"; readonly requestId: number } + | { readonly type: "downloadFailed"; readonly requestId: number; readonly error: OpdsErrorCode } + | { readonly type: "downloadCancelled"; readonly requestId: number } + | { readonly type: "downloadReset" }; + +export interface OpdsBrowserRouteParams { + readonly catalogId: string; +} + +export function createInitialOpdsViewState(): OpdsViewState { + return { content: { status: "idle" }, download: { status: "idle" } }; +} + +export function createOpdsBrowserRouteParams(catalogId: string): OpdsBrowserRouteParams { + return { catalogId }; +} + +export function getOpdsReadySnapshot(content: OpdsContentState): OpdsReadySnapshot | undefined { + if (content.status === "ready") { + return { feed: content.feed, currentUrl: content.currentUrl, history: content.history }; + } + if (content.status === "loading" || content.status === "error") return content.previous; + return undefined; +} + +function activeRequestId(content: OpdsContentState): number | undefined { + if (content.status === "loading") return content.requestId; + if (content.status === "ready" && content.refreshing) return content.requestId; + return undefined; +} + +function startLoad( + content: OpdsContentState, + requestId: number, + pending: OpdsPendingRequest, +): OpdsContentState { + const previous = getOpdsReadySnapshot(content); + if (pending.mode === "refresh" && previous) { + return { status: "ready", ...previous, refreshing: true, requestId, pending }; + } + return { status: "loading", requestId, pending, ...(previous ? { previous } : {}) }; +} + +function finishLoad(content: OpdsContentState, feed: OpdsFeed): OpdsContentState { + if (content.status !== "loading" && content.status !== "ready") return content; + const pending = content.pending; + if (!pending) return content; + const previous = getOpdsReadySnapshot(content); + let history: readonly string[] = []; + if (previous) { + if (pending.mode === "push") history = [...previous.history, previous.currentUrl]; + else if (pending.mode === "back") history = previous.history.slice(0, -1); + else if (pending.mode === "refresh") history = previous.history; + } + return { status: "ready", feed, currentUrl: pending.url, history, refreshing: false }; +} + +export function opdsViewReducer(state: OpdsViewState, action: OpdsViewAction): OpdsViewState { + switch (action.type) { + case "loadStarted": + return { + ...state, + content: startLoad(state.content, action.requestId, { url: action.url, mode: action.mode }), + }; + case "retryStarted": + return state.content.status === "error" + ? { + ...state, + content: startLoad(state.content, action.requestId, state.content.failedRequest), + } + : state; + case "loadSucceeded": + return activeRequestId(state.content) === action.requestId + ? { ...state, content: finishLoad(state.content, action.feed) } + : state; + case "loadFailed": { + if (activeRequestId(state.content) !== action.requestId) return state; + const pending = + state.content.status === "loading" || state.content.status === "ready" + ? state.content.pending + : undefined; + if (!pending) return state; + const previous = getOpdsReadySnapshot(state.content); + return { + ...state, + content: { + status: "error", + failedRequestId: action.requestId, + error: action.error, + failedRequest: pending, + ...(previous ? { previous } : {}), + }, + }; + } + case "loadCancelled": { + const matches = + activeRequestId(state.content) === action.requestId || + (state.content.status === "error" && state.content.failedRequestId === action.requestId); + if (!matches) return state; + const previous = getOpdsReadySnapshot(state.content); + return previous + ? { ...state, content: { status: "ready", ...previous, refreshing: false } } + : { ...state, content: { status: "idle" } }; + } + case "downloadStarted": + return state.download.status === "downloading" + ? state + : { + ...state, + download: { + status: "downloading", + requestId: action.requestId, + publicationTitle: action.publicationTitle, + loaded: 0, + total: 0, + }, + }; + case "downloadProgress": + return state.download.status === "downloading" && + state.download.requestId === action.requestId + ? { + ...state, + download: { + ...state.download, + loaded: Math.max(0, action.loaded), + total: Math.max(0, action.total), + }, + } + : state; + case "downloadImporting": + return state.download.status === "downloading" && + state.download.requestId === action.requestId + ? { + ...state, + download: { + status: "importing", + requestId: action.requestId, + publicationTitle: state.download.publicationTitle, + }, + } + : state; + case "downloadSucceeded": + return (state.download.status === "downloading" || state.download.status === "importing") && + state.download.requestId === action.requestId + ? { + ...state, + download: { + status: "success", + requestId: action.requestId, + publicationTitle: state.download.publicationTitle, + importedCount: action.importedCount, + }, + } + : state; + case "downloadFailed": + return (state.download.status === "downloading" || state.download.status === "importing") && + state.download.requestId === action.requestId + ? { + ...state, + download: { + status: "error", + requestId: action.requestId, + publicationTitle: state.download.publicationTitle, + error: action.error, + }, + } + : state; + case "downloadCancelled": + return state.download.status === "downloading" && + state.download.requestId === action.requestId + ? { ...state, download: { status: "idle" } } + : state; + case "downloadReset": + return state.download.status === "idle" ? state : { ...state, download: { status: "idle" } }; + } +} + +export function selectOpdsFeed(state: OpdsViewState): OpdsFeed | undefined { + if (state.content.status === "ready") return state.content.feed; + if (state.content.status === "loading" || state.content.status === "error") { + return state.content.previous?.feed; + } + return undefined; +} + +export function canSearchOpds(state: OpdsViewState): boolean { + return selectOpdsFeed(state)?.search !== undefined; +} + +export function getOpdsPagination(state: OpdsViewState): { + previousUrl?: string; + nextUrl?: string; +} { + const current = selectOpdsFeed(state); + return { + ...(current?.previousUrl ? { previousUrl: current.previousUrl } : {}), + ...(current?.nextUrl ? { nextUrl: current.nextUrl } : {}), + }; +} + +export function shouldEditOpdsCredentials(state: OpdsViewState): boolean { + return state.content.status === "error" && state.content.error === "unauthorized"; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 238ced77c..6027ef878 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,12 @@ importers: '@tauri-apps/cli': specifier: ^2.10.1 version: 2.10.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@testing-library/user-event': + specifier: ^14.6.4 + version: 14.6.4(@testing-library/dom@10.4.1) '@types/d3': specifier: ^7.4.3 version: 7.4.3 @@ -244,6 +250,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.6.0 version: 4.7.0(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.8.2)) + jsdom: + specifier: ^30.0.1 + version: 30.0.1 tailwindcss: specifier: ^4.0.0 version: 4.2.1 @@ -481,7 +490,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.2 - version: 4.1.2(@types/node@25.5.2)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.8.2)) + version: 4.1.2(@types/node@25.5.2)(jsdom@30.0.1)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.8.2)) packages/cli: dependencies: @@ -513,7 +522,7 @@ importers: version: 5.8.3 vitest: specifier: ^4.1.2 - version: 4.1.2(@types/node@25.5.2)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.8.2)) + version: 4.1.2(@types/node@25.5.2)(jsdom@30.0.1)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.8.2)) packages/core: dependencies: @@ -583,7 +592,7 @@ importers: version: 19.1.17 vitest: specifier: ^4.1.2 - version: 4.1.2(@types/node@25.5.2)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.8.2)) + version: 4.1.2(@types/node@25.5.2)(jsdom@30.0.1)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.8.2)) packages/feedback-worker: {} @@ -669,6 +678,14 @@ packages: zod: optional: true + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@astrojs/compiler@2.13.1': resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} @@ -1428,6 +1445,10 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@capsizecss/unpack@4.0.0': resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==} engines: {node: '>=18'} @@ -1439,6 +1460,42 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.0': + resolution: {integrity: sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.8': + resolution: {integrity: sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@ctrl/tinycolor@4.2.0': resolution: {integrity: sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==} engines: {node: '>=14'} @@ -1938,6 +1995,15 @@ packages: resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@expo/apple-utils@2.1.19': resolution: {integrity: sha512-f1iMteL+tTOSF1sovVB35ncobdiZvhjWvwEOWGIuAutyeIpcxNJ/2tUZSE748X/VEQLn1cL2Tozkdp2MLXStvA==} hasBin: true @@ -3905,6 +3971,31 @@ packages: '@tauri-apps/plugin-window-state@2.4.1': resolution: {integrity: sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': 19.1.17 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: 19.1.0 + react-dom: 19.1.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.4': + resolution: {integrity: sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.20.1': resolution: {integrity: sha512-SwkPEWIfaDEZjC8SEIi4kZjqIYUbRgLUHUuQezo5GbphUNC8kM1pi3C3EtoOPtxXrEbY6e4pWEzW54Pcrd+rVA==} peerDependencies: @@ -4077,6 +4168,9 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4487,6 +4581,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -4676,6 +4773,9 @@ packages: resolution: {integrity: sha512-HfFtzCqnSfwB3+HroF6PSKzyh+7RfNMGPCzHFUZXRlvrPCb4P3cvxKZNN43Sr7IrkofqQZM+gIvffGpA8VvqgA==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -5196,6 +5296,10 @@ packages: resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} engines: {node: '>=12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} @@ -5228,6 +5332,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -5333,6 +5440,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -5443,6 +5553,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-editor@0.4.2: resolution: {integrity: sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==} engines: {node: '>=8'} @@ -6197,6 +6311,10 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -6385,6 +6503,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-retry-allowed@1.2.0: resolution: {integrity: sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==} engines: {node: '>=0.10.0'} @@ -6496,6 +6617,15 @@ packages: jsc-safe-url@0.2.4: resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + jsep@1.4.0: resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} engines: {node: '>= 10.16.0'} @@ -6779,8 +6909,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -6802,6 +6932,10 @@ packages: peerDependencies: react: 19.1.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -7479,18 +7613,22 @@ packages: onnxruntime-common@1.21.0: resolution: {integrity: sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==} - onnxruntime-common@1.24.3: - resolution: {integrity: sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==} - onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: resolution: {integrity: sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==} + onnxruntime-common@1.24.3: + resolution: {integrity: sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==} + onnxruntime-node@1.21.0: resolution: {integrity: sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==} os: [win32, darwin, linux] onnxruntime-react-native@1.24.3: resolution: {integrity: sha512-vMxFcnO1YDtT6auv719Zk0Zj/EXujqeEzSjTe5W7A915qaRrM4pRfXlI4ye/ExOwcTan7NYVZ/wpLX+YBU2aWQ==} + engines: {node: '>=18'} + peerDependencies: + react: 19.1.0 + react-native: '*' onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==} @@ -7626,6 +7764,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -7762,6 +7903,10 @@ packages: resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} engines: {node: '>=6'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -7949,6 +8094,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -8361,6 +8509,10 @@ packages: resolution: {integrity: sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==} engines: {node: '>=11.0.0'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -8668,6 +8820,9 @@ packages: engines: {node: '>=16'} hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tailwind-merge@3.5.0: resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} @@ -8752,6 +8907,13 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.8: + resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} + + tldts@7.4.8: + resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} + hasBin: true + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -8763,9 +8925,17 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -8799,6 +8969,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -8881,6 +9052,10 @@ packages: resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} engines: {node: '>=18.17'} + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -9245,6 +9420,10 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -9264,6 +9443,10 @@ packages: resolution: {integrity: sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==} engines: {node: '>=8'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} @@ -9276,10 +9459,22 @@ packages: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + whatwg-url-without-unicode@8.0.0-3: resolution: {integrity: sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==} engines: {node: '>=10'} + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -9379,6 +9574,10 @@ packages: resolution: {integrity: sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==} engines: {node: '>=10.0.0'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml2js@0.6.0: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} @@ -9395,6 +9594,9 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} @@ -9511,6 +9713,21 @@ snapshots: optionalDependencies: zod: 4.3.6 + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@astrojs/compiler@2.13.1': {} '@astrojs/internal-helpers@0.7.6': {} @@ -10733,6 +10950,10 @@ snapshots: '@biomejs/cli-win32-x64@1.9.4': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@capsizecss/unpack@4.0.0': dependencies: fontkitten: 1.0.3 @@ -10743,6 +10964,30 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.8(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@ctrl/tinycolor@4.2.0': {} '@dr.pogodin/js-utils@0.1.6': @@ -11008,6 +11253,8 @@ snapshots: '@eslint/js@9.39.4': {} + '@exodus/bytes@1.15.1': {} + '@expo/apple-utils@2.1.19': {} '@expo/bunyan@4.0.1': @@ -13535,6 +13782,31 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.1.17))(@types/react@19.1.17)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.17 + '@types/react-dom': 19.2.3(@types/react@19.1.17) + + '@testing-library/user-event@14.6.4(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@tiptap/core@3.20.1(@tiptap/pm@3.20.1)': dependencies: '@tiptap/pm': 3.20.1 @@ -13730,6 +14002,8 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -14194,6 +14468,10 @@ snapshots: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-iterate@2.0.1: {} @@ -14513,6 +14791,10 @@ snapshots: prebuild-install: 7.1.3 optional: true + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + big-integer@1.6.52: {} bindings@1.5.0: @@ -15077,6 +15359,13 @@ snapshots: d3-transition: 3.0.1(d3-selection@3.0.0) d3-zoom: 3.0.0 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dateformat@4.6.3: {} debug@2.6.9: @@ -15095,6 +15384,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -15176,6 +15467,8 @@ snapshots: dlv@1.1.3: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -15367,6 +15660,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-editor@0.4.2: {} env-paths@2.2.0: {} @@ -16359,6 +16654,12 @@ snapshots: dependencies: lru-cache: 10.4.3 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@3.0.3: {} html-parse-stringify@3.0.1: @@ -16529,6 +16830,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-retry-allowed@1.2.0: {} is-stream@2.0.1: {} @@ -16686,6 +16989,32 @@ snapshots: jsc-safe-url@0.2.4: {} + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.8(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsep@1.4.0: {} jsesc@3.1.0: {} @@ -16903,7 +17232,7 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.6: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: dependencies: @@ -16923,6 +17252,8 @@ snapshots: dependencies: react: 19.1.0 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -18085,10 +18416,10 @@ snapshots: onnxruntime-common@1.21.0: {} - onnxruntime-common@1.24.3: {} - onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: {} + onnxruntime-common@1.24.3: {} + onnxruntime-node@1.21.0: dependencies: global-agent: 3.0.0 @@ -18266,6 +18597,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} password-prompt@1.1.3: @@ -18294,7 +18629,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.2.6 + lru-cache: 11.5.2 minipass: 7.1.3 path-type@4.0.0: {} @@ -18386,6 +18721,12 @@ snapshots: pretty-bytes@5.6.0: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -18642,6 +18983,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-is@19.2.4: {} @@ -19218,6 +19561,10 @@ snapshots: sax@1.5.0: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.26.0: {} semver-compare@1.0.0: {} @@ -19563,6 +19910,8 @@ snapshots: picocolors: 1.1.1 sax: 1.5.0 + symbol-tree@3.2.4: {} + tailwind-merge@3.5.0: {} tailwindcss@4.2.1: {} @@ -19666,6 +20015,12 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.8: {} + + tldts@7.4.8: + dependencies: + tldts-core: 7.4.8 + tmpl@1.0.5: {} to-regex-range@5.0.1: @@ -19674,8 +20029,16 @@ snapshots: toidentifier@1.0.1: {} + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.8 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -19758,6 +20121,8 @@ snapshots: undici@6.23.0: {} + undici@8.10.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -19847,7 +20212,7 @@ snapshots: chokidar: 5.0.0 destr: 2.0.5 h3: 1.15.6 - lru-cache: 11.2.6 + lru-cache: 11.5.2 node-fetch-native: 1.6.7 ofetch: 1.5.1 ufo: 1.6.3 @@ -19966,7 +20331,7 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.8.2) - vitest@4.1.2(@types/node@25.5.2)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.8.2)): + vitest@4.1.2(@types/node@25.5.2)(jsdom@30.0.1)(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.2 '@vitest/mocker': 4.1.2(vite@7.3.1(@types/node@25.5.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.8.2)) @@ -19990,6 +20355,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.2 + jsdom: 30.0.1 transitivePeerDependencies: - msw @@ -19999,6 +20365,10 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walker@1.0.8: dependencies: makeerror: 1.0.12 @@ -20015,6 +20385,8 @@ snapshots: webidl-conversions@5.0.0: {} + webidl-conversions@8.0.1: {} + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -20023,12 +20395,30 @@ snapshots: whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} + whatwg-url-without-unicode@8.0.0-3: dependencies: buffer: 5.7.1 punycode: 2.3.1 webidl-conversions: 5.0.0 + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -20109,6 +20499,8 @@ snapshots: simple-plist: 1.3.1 uuid: 7.0.3 + xml-name-validator@5.0.0: {} + xml2js@0.6.0: dependencies: sax: 1.5.0 @@ -20120,6 +20512,8 @@ snapshots: xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + xxhash-wasm@1.1.0: {} y18n@4.0.3: {} From c893f7def64b35fec3db6667635867b14eac66bf Mon Sep 17 00:00:00 2001 From: Chai Date: Mon, 17 Aug 2026 04:32:05 -0400 Subject: [PATCH 33/38] fix(opds): serialize catalog saves --- .../home/OpdsCatalogFormDialog.test.tsx | 142 +++++++++++++++++- .../components/home/OpdsCatalogFormDialog.tsx | 61 +++++++- .../home/OpdsCatalogsDialog.test.tsx | 55 ++++++- .../components/home/OpdsCatalogsDialog.tsx | 1 + packages/app/src/components/ui/dialog.tsx | 11 +- 5 files changed, 260 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx b/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx index fa9b3b047..add4990ae 100644 --- a/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx +++ b/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom -import { render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import "./opds-component-test-setup"; import { OpdsCatalogFormDialog } from "./OpdsCatalogFormDialog"; @@ -11,6 +12,53 @@ vi.mock("react-i18next", async (importOriginal) => ({ useTranslation: () => ({ t: (key: string) => key }), })); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function renderControlledForm({ + store, + catalog, +}: { + store: unknown; + catalog?: Parameters[0]["catalog"]; +}) { + let setOpen!: (open: boolean) => void; + const onSaved = vi.fn(); + const onBackgroundSaved = vi.fn(); + + function Host() { + const [open, setOpenState] = useState(true); + setOpen = setOpenState; + return ( + { + onSaved(); + setOpenState(false); + }} + onBackgroundSaved={onBackgroundSaved} + /> + ); + } + + render(); + return { + onSaved, + onBackgroundSaved, + forceOpen(open: boolean) { + act(() => setOpen(open)); + }, + }; +} + describe("OpdsCatalogFormDialog", () => { beforeEach(() => { document.body.innerHTML = ""; @@ -58,4 +106,96 @@ describe("OpdsCatalogFormDialog", () => { }); expect(onSaved).toHaveBeenCalledOnce(); }); + + it("blocks every user dismissal and concurrent add while a save is pending", async () => { + const firstSave = deferred<{ id: string }>(); + const addCatalog = vi.fn(() => firstSave.promise); + renderControlledForm({ store: { addCatalog } }); + const user = userEvent.setup(); + + await user.type(screen.getByLabelText("library.opds.form.name"), "Pending shelf"); + await user.type(screen.getByLabelText("library.opds.form.url"), "https://catalog.test/pending"); + await user.click(screen.getByRole("button", { name: "library.opds.save" })); + + const dialog = screen.getByRole("dialog", { name: "library.opds.form.addTitle" }); + const save = screen.getByRole("button", { name: "library.opds.save" }); + expect((save as HTMLButtonElement).disabled).toBe(true); + expect(dialog.getAttribute("aria-busy")).toBe("true"); + fireEvent.submit(dialog.querySelector("form") as HTMLFormElement); + expect(addCatalog).toHaveBeenCalledOnce(); + + await user.keyboard("{Escape}"); + expect(screen.getByRole("dialog", { name: "library.opds.form.addTitle" })).toBeTruthy(); + const overlay = dialog.previousElementSibling as HTMLElement; + fireEvent.pointerDown(overlay, { button: 0, pointerType: "mouse" }); + fireEvent.click(overlay); + expect(screen.getByRole("dialog", { name: "library.opds.form.addTitle" })).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "library.opds.close" })); + expect(screen.getByRole("dialog", { name: "library.opds.form.addTitle" })).toBeTruthy(); + + firstSave.resolve({ id: "added" }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + }); + + it("serializes deferred updates across forced close/reopen without touching the new form", async () => { + const firstSave = deferred(); + const secondSave = deferred(); + const updateCatalog = vi + .fn() + .mockImplementationOnce(() => firstSave.promise) + .mockImplementationOnce(() => secondSave.promise); + const harness = renderControlledForm({ + store: { updateCatalog }, + catalog: { + id: "custom", + name: "Original shelf", + url: "https://catalog.test/original", + auth: "basic", + username: "reader", + enabled: true, + builtIn: false, + hidden: false, + passwordStorage: "persistent", + }, + }); + const user = userEvent.setup(); + + const name = screen.getByLabelText("library.opds.form.name"); + await user.clear(name); + await user.type(name, "First update"); + await user.type(screen.getByLabelText("library.opds.form.password"), "first-secret"); + await user.click(screen.getByRole("button", { name: "library.opds.save" })); + expect(updateCatalog).toHaveBeenCalledOnce(); + + harness.forceOpen(false); + expect(screen.queryByRole("dialog")).toBeNull(); + harness.forceOpen(true); + await screen.findByRole("dialog", { name: "library.opds.form.editTitle" }); + const reopenedName = screen.getByLabelText("library.opds.form.name"); + const reopenedPassword = screen.getByLabelText("library.opds.form.password"); + await user.clear(reopenedName); + await user.type(reopenedName, "Second update"); + await user.type(reopenedPassword, "second-secret"); + + const reopenedSave = screen.getByRole("button", { name: "library.opds.save" }); + expect((reopenedSave as HTMLButtonElement).disabled).toBe(true); + fireEvent.submit(reopenedSave.closest("form") as HTMLFormElement); + expect(updateCatalog).toHaveBeenCalledOnce(); + + firstSave.resolve(); + await waitFor(() => expect((reopenedSave as HTMLButtonElement).disabled).toBe(false)); + expect((reopenedName as HTMLInputElement).value).toBe("Second update"); + expect((reopenedPassword as HTMLInputElement).value).toBe("second-secret"); + expect(harness.onSaved).not.toHaveBeenCalled(); + expect(harness.onBackgroundSaved).toHaveBeenCalledOnce(); + + await user.click(reopenedSave); + expect(updateCatalog).toHaveBeenCalledTimes(2); + expect(updateCatalog.mock.calls[1]?.[1]).toMatchObject({ + name: "Second update", + password: "second-secret", + }); + secondSave.resolve(); + await waitFor(() => expect(harness.onSaved).toHaveBeenCalledOnce()); + }); }); diff --git a/packages/app/src/components/home/OpdsCatalogFormDialog.tsx b/packages/app/src/components/home/OpdsCatalogFormDialog.tsx index d413b4b4b..610a0be29 100644 --- a/packages/app/src/components/home/OpdsCatalogFormDialog.tsx +++ b/packages/app/src/components/home/OpdsCatalogFormDialog.tsx @@ -17,7 +17,7 @@ import { classifyOpdsUrl, } from "@readany/core"; import { Loader2, ShieldAlert } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; interface OpdsCatalogFormDialogProps { @@ -26,6 +26,7 @@ interface OpdsCatalogFormDialogProps { store: OpdsCatalogStore; onOpenChange(open: boolean): void; onSaved(): void; + onBackgroundSaved?(): void; } export function OpdsCatalogFormDialog({ @@ -34,6 +35,7 @@ export function OpdsCatalogFormDialog({ store, onOpenChange, onSaved, + onBackgroundSaved, }: OpdsCatalogFormDialogProps) { const { t } = useTranslation(); const [name, setName] = useState(""); @@ -45,19 +47,30 @@ export function OpdsCatalogFormDialog({ const [submitting, setSubmitting] = useState(false); const [confirmingLocalHttp, setConfirmingLocalHttp] = useState(false); const [error, setError] = useState(); + const openGeneration = useRef(0); + const saveGeneration = useRef(0); + const wasOpen = useRef(false); + const openRef = useRef(open); + const activeSave = useRef<{ saveGeneration: number; openGeneration: number } | undefined>( + undefined, + ); + openRef.current = open; useEffect(() => { + const opening = open && !wasOpen.current; + wasOpen.current = open; if (!open) { setPassword(""); return; } + if (!opening) return; + openGeneration.current += 1; setName(catalog?.name ?? ""); setUrl(catalog?.url ?? ""); setAuth(catalog?.auth ?? "anonymous"); setUsername(catalog?.username ?? ""); setPassword(""); setEnabled(catalog?.enabled ?? true); - setSubmitting(false); setConfirmingLocalHttp(false); setError(undefined); }, [catalog, open]); @@ -71,7 +84,17 @@ export function OpdsCatalogFormDialog({ !submitting; const persist = async () => { - if (!canSubmit) return; + if (!canSubmit || activeSave.current) return; + const saveId = ++saveGeneration.current; + const saveOpenGeneration = openGeneration.current; + activeSave.current = { + saveGeneration: saveId, + openGeneration: saveOpenGeneration, + }; + const isCurrentOpenAttempt = () => + activeSave.current?.saveGeneration === saveId && + activeSave.current.openGeneration === openGeneration.current && + openRef.current; setSubmitting(true); setError(undefined); try { @@ -86,12 +109,21 @@ export function OpdsCatalogFormDialog({ }; if (catalog) await store.updateCatalog(catalog.id, input); else await store.addCatalog(input); + if (!isCurrentOpenAttempt()) { + onBackgroundSaved?.(); + return; + } setPassword(""); onSaved(); } catch { - setError(t("library.opds.form.saveFailed")); + if (isCurrentOpenAttempt()) { + setError(t("library.opds.form.saveFailed")); + } } finally { - setSubmitting(false); + if (activeSave.current?.saveGeneration === saveId) { + activeSave.current = undefined; + setSubmitting(false); + } } }; @@ -116,9 +148,26 @@ export function OpdsCatalogFormDialog({ }; return ( - + { + if (!nextOpen && submitting) return; + onOpenChange(nextOpen); + }} + > { + if (submitting) event.preventDefault(); + }} + onPointerDownOutside={(event) => { + if (submitting) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (submitting) event.preventDefault(); + }} className="max-h-[calc(100vh-32px)] w-[min(92vw,620px)] max-w-none overflow-y-auto" > diff --git a/packages/app/src/components/home/OpdsCatalogsDialog.test.tsx b/packages/app/src/components/home/OpdsCatalogsDialog.test.tsx index 6c6af33e8..062f61399 100644 --- a/packages/app/src/components/home/OpdsCatalogsDialog.test.tsx +++ b/packages/app/src/components/home/OpdsCatalogsDialog.test.tsx @@ -17,10 +17,24 @@ const harness = vi.hoisted(() => { hidden: false, passwordStorage: "none" as const, }; + const visibleBuiltIn = { + ...catalog, + id: "built-in-visible", + name: "Built-in Catalog", + builtIn: true, + }; + const hiddenBuiltIn = { + ...catalog, + id: "built-in-hidden", + name: "Hidden Catalog", + builtIn: true, + hidden: true, + }; const store = { - listCatalogs: vi.fn(() => [catalog]), + listCatalogs: vi.fn(() => [catalog, visibleBuiltIn, hiddenBuiltIn]), getCredentials: vi.fn(async () => undefined), removeCatalog: vi.fn(async () => undefined), + updateCatalog: vi.fn(async () => undefined), setCatalogEnabled: vi.fn(async () => undefined), hideBuiltIn: vi.fn(async () => undefined), restoreBuiltIn: vi.fn(async () => undefined), @@ -120,6 +134,45 @@ describe("OpdsCatalogsDialog", () => { await waitFor(() => expect(harness.store.removeCatalog).toHaveBeenCalledWith("custom")); }); + it("wires rendered update, enable, hide, and restore catalog actions", async () => { + render(); + await waitFor(() => expect(harness.store.listCatalogs).toHaveBeenCalled()); + + await userEvent.click( + screen.getByRole("switch", { name: "library.opds.toggleCatalog:Test Catalog" }), + ); + expect(harness.store.setCatalogEnabled).toHaveBeenCalledWith("custom", false); + + await userEvent.click( + screen.getByRole("button", { + name: "library.opds.hideCatalog:Built-in Catalog", + }), + ); + expect(harness.store.hideBuiltIn).toHaveBeenCalledWith("built-in-visible"); + + await userEvent.click( + screen.getByRole("button", { + name: "library.opds.restoreCatalog:Hidden Catalog", + }), + ); + expect(harness.store.restoreBuiltIn).toHaveBeenCalledWith("built-in-hidden"); + + await userEvent.click( + screen.getByRole("button", { name: "library.opds.editCatalog:Test Catalog" }), + ); + const name = await screen.findByLabelText("library.opds.form.name"); + await userEvent.clear(name); + await userEvent.type(name, "Updated Catalog"); + await userEvent.click(screen.getByRole("button", { name: "library.opds.save" })); + + await waitFor(() => + expect(harness.store.updateCatalog).toHaveBeenCalledWith( + "custom", + expect.objectContaining({ name: "Updated Catalog", enabled: true }), + ), + ); + }); + it("returns focus to the originating control when the dialog closes", async () => { function Host() { const [open, setOpen] = useState(false); diff --git a/packages/app/src/components/home/OpdsCatalogsDialog.tsx b/packages/app/src/components/home/OpdsCatalogsDialog.tsx index ad12a8723..9d56440d4 100644 --- a/packages/app/src/components/home/OpdsCatalogsDialog.tsx +++ b/packages/app/src/components/home/OpdsCatalogsDialog.tsx @@ -395,6 +395,7 @@ export function OpdsCatalogsDialog({ open, onOpenChange }: OpdsCatalogsDialogPro setEditing(undefined); syncCatalogs(); }} + onBackgroundSaved={syncCatalogs} /> !next && setDeleting(undefined)}> diff --git a/packages/app/src/components/ui/dialog.tsx b/packages/app/src/components/ui/dialog.tsx index a6a1d3f71..5037f9948 100644 --- a/packages/app/src/components/ui/dialog.tsx +++ b/packages/app/src/components/ui/dialog.tsx @@ -27,8 +27,12 @@ function DialogContent({ className, children, closeLabel = "Close", + closeDisabled = false, ...props -}: ComponentPropsWithoutRef & { closeLabel?: string }) { +}: ComponentPropsWithoutRef & { + closeLabel?: string; + closeDisabled?: boolean; +}) { return ( @@ -40,7 +44,10 @@ function DialogContent({ {...props} > {children} - + {closeLabel} From e330f65977e8dd7d7fe17b63789a7d3fd14c4a1e Mon Sep 17 00:00:00 2001 From: Chai Date: Mon, 17 Aug 2026 04:39:20 -0400 Subject: [PATCH 34/38] fix(opds): lock pending catalog form --- .../home/OpdsCatalogFormDialog.test.tsx | 76 +++++++++++++++++-- .../components/home/OpdsCatalogFormDialog.tsx | 31 +++++--- .../app/src/components/ui/password-input.tsx | 5 +- 3 files changed, 96 insertions(+), 16 deletions(-) diff --git a/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx b/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx index add4990ae..342d4b5db 100644 --- a/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx +++ b/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx @@ -107,22 +107,81 @@ describe("OpdsCatalogFormDialog", () => { expect(onSaved).toHaveBeenCalledOnce(); }); - it("blocks every user dismissal and concurrent add while a save is pending", async () => { + it("locks every mutable control and dismissal path during a current-generation save", async () => { const firstSave = deferred<{ id: string }>(); const addCatalog = vi.fn(() => firstSave.promise); renderControlledForm({ store: { addCatalog } }); const user = userEvent.setup(); - await user.type(screen.getByLabelText("library.opds.form.name"), "Pending shelf"); - await user.type(screen.getByLabelText("library.opds.form.url"), "https://catalog.test/pending"); + const name = screen.getByLabelText("library.opds.form.name") as HTMLInputElement; + const url = screen.getByLabelText("library.opds.form.url") as HTMLInputElement; + await user.type(name, "Pending shelf"); + await user.type(url, "http://localhost:8080/opds"); + await user.click(screen.getByRole("radio", { name: "library.opds.form.basic" })); + const username = screen.getByLabelText("library.opds.form.username") as HTMLInputElement; + const password = screen.getByLabelText("library.opds.form.password") as HTMLInputElement; + const reveal = screen.getByRole("button", { name: "library.opds.showPassword" }); + const enabled = screen.getByRole("switch", { + name: "library.opds.form.enabled", + }) as HTMLButtonElement; + await user.type(username, "reader"); + await user.type(password, "secret"); await user.click(screen.getByRole("button", { name: "library.opds.save" })); + const warning = screen.getByRole("alert"); + const warningCancel = screen.getAllByRole("button", { name: "library.opds.cancel" })[0]; + const continueSave = screen.getByRole("button", { name: "library.opds.continue" }); + await user.click(continueSave); const dialog = screen.getByRole("dialog", { name: "library.opds.form.addTitle" }); const save = screen.getByRole("button", { name: "library.opds.save" }); - expect((save as HTMLButtonElement).disabled).toBe(true); + const footerCancel = screen.getAllByRole("button", { name: "library.opds.cancel" })[1]; + const close = screen.getByRole("button", { name: "library.opds.close" }); + const anonymous = screen.getByRole("radio", { + name: "library.opds.form.anonymous", + }) as HTMLInputElement; + const basic = screen.getByRole("radio", { + name: "library.opds.form.basic", + }) as HTMLInputElement; + for (const control of [ + name, + url, + anonymous, + basic, + username, + password, + reveal, + enabled, + warningCancel, + continueSave, + footerCancel, + save, + close, + ]) { + expect((control as HTMLButtonElement | HTMLInputElement).disabled).toBe(true); + } expect(dialog.getAttribute("aria-busy")).toBe("true"); + + await user.type(name, " changed"); + await user.type(url, "/changed"); + await user.click(anonymous); + await user.type(username, "-changed"); + await user.type(password, "-changed"); + await user.click(reveal); + await user.click(enabled); + await user.click(warningCancel); + await user.click(continueSave); + await user.click(footerCancel); fireEvent.submit(dialog.querySelector("form") as HTMLFormElement); expect(addCatalog).toHaveBeenCalledOnce(); + expect(name.value).toBe("Pending shelf"); + expect(url.value).toBe("http://localhost:8080/opds"); + expect(anonymous.checked).toBe(false); + expect(basic.checked).toBe(true); + expect(username.value).toBe("reader"); + expect(password.value).toBe("secret"); + expect(password.type).toBe("password"); + expect(enabled.getAttribute("aria-checked")).toBe("true"); + expect(screen.getByRole("alert")).toBe(warning); await user.keyboard("{Escape}"); expect(screen.getByRole("dialog", { name: "library.opds.form.addTitle" })).toBeTruthy(); @@ -130,7 +189,7 @@ describe("OpdsCatalogFormDialog", () => { fireEvent.pointerDown(overlay, { button: 0, pointerType: "mouse" }); fireEvent.click(overlay); expect(screen.getByRole("dialog", { name: "library.opds.form.addTitle" })).toBeTruthy(); - await user.click(screen.getByRole("button", { name: "library.opds.close" })); + await user.click(close); expect(screen.getByRole("dialog", { name: "library.opds.form.addTitle" })).toBeTruthy(); firstSave.resolve({ id: "added" }); @@ -170,7 +229,9 @@ describe("OpdsCatalogFormDialog", () => { harness.forceOpen(false); expect(screen.queryByRole("dialog")).toBeNull(); harness.forceOpen(true); - await screen.findByRole("dialog", { name: "library.opds.form.editTitle" }); + const reopenedDialog = await screen.findByRole("dialog", { + name: "library.opds.form.editTitle", + }); const reopenedName = screen.getByLabelText("library.opds.form.name"); const reopenedPassword = screen.getByLabelText("library.opds.form.password"); await user.clear(reopenedName); @@ -178,6 +239,9 @@ describe("OpdsCatalogFormDialog", () => { await user.type(reopenedPassword, "second-secret"); const reopenedSave = screen.getByRole("button", { name: "library.opds.save" }); + expect((reopenedName as HTMLInputElement).disabled).toBe(false); + expect((reopenedPassword as HTMLInputElement).disabled).toBe(false); + expect(reopenedDialog.getAttribute("aria-busy")).toBe("false"); expect((reopenedSave as HTMLButtonElement).disabled).toBe(true); fireEvent.submit(reopenedSave.closest("form") as HTMLFormElement); expect(updateCatalog).toHaveBeenCalledOnce(); diff --git a/packages/app/src/components/home/OpdsCatalogFormDialog.tsx b/packages/app/src/components/home/OpdsCatalogFormDialog.tsx index 610a0be29..8bc9d7b36 100644 --- a/packages/app/src/components/home/OpdsCatalogFormDialog.tsx +++ b/packages/app/src/components/home/OpdsCatalogFormDialog.tsx @@ -45,6 +45,7 @@ export function OpdsCatalogFormDialog({ const [password, setPassword] = useState(""); const [enabled, setEnabled] = useState(true); const [submitting, setSubmitting] = useState(false); + const [renderedOpenGeneration, setRenderedOpenGeneration] = useState(0); const [confirmingLocalHttp, setConfirmingLocalHttp] = useState(false); const [error, setError] = useState(); const openGeneration = useRef(0); @@ -65,6 +66,7 @@ export function OpdsCatalogFormDialog({ } if (!opening) return; openGeneration.current += 1; + setRenderedOpenGeneration(openGeneration.current); setName(catalog?.name ?? ""); setUrl(catalog?.url ?? ""); setAuth(catalog?.auth ?? "anonymous"); @@ -82,6 +84,8 @@ export function OpdsCatalogFormDialog({ (auth === "anonymous" || (username.trim().length > 0 && (password.length > 0 || hasPassword))) && !submitting; + const savingCurrentOpen = + submitting && activeSave.current?.openGeneration === renderedOpenGeneration; const persist = async () => { if (!canSubmit || activeSave.current) return; @@ -151,22 +155,22 @@ export function OpdsCatalogFormDialog({ { - if (!nextOpen && submitting) return; + if (!nextOpen && savingCurrentOpen) return; onOpenChange(nextOpen); }} > { - if (submitting) event.preventDefault(); + if (savingCurrentOpen) event.preventDefault(); }} onPointerDownOutside={(event) => { - if (submitting) event.preventDefault(); + if (savingCurrentOpen) event.preventDefault(); }} onInteractOutside={(event) => { - if (submitting) event.preventDefault(); + if (savingCurrentOpen) event.preventDefault(); }} className="max-h-[calc(100vh-32px)] w-[min(92vw,620px)] max-w-none overflow-y-auto" > @@ -190,6 +194,7 @@ export function OpdsCatalogFormDialog({ setName(event.target.value)} placeholder={t("library.opds.form.namePlaceholder")} @@ -206,6 +211,7 @@ export function OpdsCatalogFormDialog({ inputMode="url" autoCapitalize="none" autoCorrect="off" + disabled={savingCurrentOpen} value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://catalog.example.com/opds" @@ -219,7 +225,9 @@ export function OpdsCatalogFormDialog({ {(["anonymous", "basic"] as const).map((mode) => (
@@ -305,6 +317,7 @@ export function OpdsCatalogFormDialog({ type="button" size="sm" variant="outline" + disabled={savingCurrentOpen} onClick={() => setConfirmingLocalHttp(false)} > {t("library.opds.cancel")} @@ -313,7 +326,7 @@ export function OpdsCatalogFormDialog({ type="button" size="sm" onClick={() => void persist()} - disabled={submitting} + disabled={savingCurrentOpen} > {t("library.opds.continue")} @@ -337,7 +350,7 @@ export function OpdsCatalogFormDialog({ type="button" variant="outline" onClick={() => onOpenChange(false)} - disabled={submitting} + disabled={savingCurrentOpen} > {t("library.opds.cancel")} diff --git a/packages/app/src/components/ui/password-input.tsx b/packages/app/src/components/ui/password-input.tsx index 316320c19..d64cbe1f4 100644 --- a/packages/app/src/components/ui/password-input.tsx +++ b/packages/app/src/components/ui/password-input.tsx @@ -11,6 +11,7 @@ export function PasswordInput({ className, showPasswordLabel = "Show password", hidePasswordLabel = "Hide password", + disabled, ...props }: PasswordInputProps) { const [visible, setVisible] = useState(false); @@ -19,6 +20,7 @@ export function PasswordInput({
From a243d3da103d35191d37ed6326b206f8ce518de8 Mon Sep 17 00:00:00 2001 From: Chai Date: Mon, 17 Aug 2026 05:12:06 -0400 Subject: [PATCH 35/38] fix(opds): address final review findings --- .../src/screens/library/OpdsBrowserScreen.tsx | 9 +- .../screens/library/OpdsCatalogFormSheet.tsx | 86 ++++++++++-- .../screens/library/OpdsCatalogsScreen.tsx | 1 + .../screens/library/opds-cover-cache.test.ts | 4 +- .../library/opds-form-save-owner.test.ts | 33 +++++ .../screens/library/opds-form-save-owner.ts | 38 +++++ .../src/components/home/OpdsBrowser.test.tsx | 14 ++ .../app/src/components/home/OpdsBrowser.tsx | 7 +- .../home/OpdsCatalogFormDialog.test.tsx | 59 ++++++++ .../components/home/OpdsCatalogFormDialog.tsx | 19 ++- .../core/src/i18n/locales/en/library.json | 1 + .../core/src/i18n/locales/es/library.json | 1 + .../core/src/i18n/locales/fr/library.json | 1 + .../core/src/i18n/locales/ja/library.json | 1 + .../core/src/i18n/locales/ko/library.json | 1 + .../core/src/i18n/locales/zh-TW/library.json | 1 + .../core/src/i18n/locales/zh/library.json | 1 + packages/core/src/i18n/opds-locales.test.ts | 1 + packages/core/src/index.ts | 6 + packages/core/src/opds/opds-acquisition.ts | 7 +- .../core/src/opds/opds-catalog-store.test.ts | 132 ++++++++++++++---- packages/core/src/opds/opds-catalog-store.ts | 35 ++++- packages/core/src/opds/opds-client.test.ts | 112 +++++++++++++++ packages/core/src/opds/opds-client.ts | 73 ++++++++-- .../core/src/opds/opds-cover-cache.test.ts | 61 ++++++-- packages/core/src/opds/opds-cover-cache.ts | 93 ++++++++---- packages/core/src/opds/opds-parser.test.ts | 57 ++++++++ packages/core/src/opds/opds-parser.ts | 31 ++-- packages/core/src/opds/opds-relations.ts | 41 ++++++ packages/core/src/opds/opds-types.ts | 3 + 30 files changed, 813 insertions(+), 116 deletions(-) create mode 100644 packages/app-expo/src/screens/library/opds-form-save-owner.test.ts create mode 100644 packages/app-expo/src/screens/library/opds-form-save-owner.ts create mode 100644 packages/core/src/opds/opds-relations.ts diff --git a/packages/app-expo/src/screens/library/OpdsBrowserScreen.tsx b/packages/app-expo/src/screens/library/OpdsBrowserScreen.tsx index 2b59fd4ad..caa61896f 100644 --- a/packages/app-expo/src/screens/library/OpdsBrowserScreen.tsx +++ b/packages/app-expo/src/screens/library/OpdsBrowserScreen.tsx @@ -153,6 +153,7 @@ export function OpdsBrowserScreen({ navigation, route }: Props) { const [formatChoice, setFormatChoice] = useState(); const formatHeadingRef = useRef(null); const requestSequence = useRef(0); + const catalogOrigin = useRef(undefined); const mounted = useRef(true); const lifecycleGeneration = useRef(0); const requestController = useRef(undefined); @@ -208,7 +209,8 @@ export function OpdsBrowserScreen({ navigation, route }: Props) { startOperation({ key: url, mode, - execute: (credentials, signal) => client.open(url, credentials, signal), + execute: (credentials, signal) => + client.open(url, credentials, signal, catalogOrigin.current), }); }, [client, startOperation], @@ -221,6 +223,7 @@ export function OpdsBrowserScreen({ navigation, route }: Props) { if (!mounted.current || lifecycleGeneration.current !== generation) return; const catalog = store.getCatalog(catalogId); if (!catalog || !catalog.enabled) throw new Error("catalog-unavailable"); + catalogOrigin.current = new URL(catalog.url).origin; setCatalogName(catalog.name); setCatalogUrl(catalog.url); openUrl(catalog.url, "replace"); @@ -251,6 +254,7 @@ export function OpdsBrowserScreen({ navigation, route }: Props) { return createOpdsCoverCache({ maxEntries: MAX_COVER_CACHE_ENTRIES, maxBytes: MAX_COVER_CACHE_BYTES, + maxLoadBytes: MAX_COVER_BYTES, load: async (url, signal) => { // Capturing the feed scope makes navigation create a fresh cache and release the old feed. void cacheScope; @@ -337,7 +341,8 @@ export function OpdsBrowserScreen({ navigation, route }: Props) { startOperation({ key, mode: "push", - execute: (credentials, signal) => client.search(descriptor, trimmed, credentials, signal), + execute: (credentials, signal) => + client.search(descriptor, trimmed, credentials, signal, catalogOrigin.current), }); }; diff --git a/packages/app-expo/src/screens/library/OpdsCatalogFormSheet.tsx b/packages/app-expo/src/screens/library/OpdsCatalogFormSheet.tsx index 79ffde9b0..583903c12 100644 --- a/packages/app-expo/src/screens/library/OpdsCatalogFormSheet.tsx +++ b/packages/app-expo/src/screens/library/OpdsCatalogFormSheet.tsx @@ -4,9 +4,10 @@ import { type OpdsCatalog, type OpdsCatalogAuth, type OpdsCatalogStore, + canPreserveOpdsCatalogPassword, classifyOpdsUrl, } from "@readany/core"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, @@ -24,6 +25,7 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { createOpdsFormSaveOwner } from "./opds-form-save-owner"; interface OpdsCatalogFormSheetProps { visible: boolean; @@ -31,6 +33,7 @@ interface OpdsCatalogFormSheetProps { store: OpdsCatalogStore; onClose: () => void; onSaved: () => void; + onBackgroundSaved?: () => void; } export function OpdsCatalogFormSheet({ @@ -39,6 +42,7 @@ export function OpdsCatalogFormSheet({ store, onClose, onSaved, + onBackgroundSaved, }: OpdsCatalogFormSheetProps) { const { t } = useTranslation(); const colors = useColors(); @@ -51,30 +55,47 @@ export function OpdsCatalogFormSheet({ const [password, setPassword] = useState(""); const [enabled, setEnabled] = useState(true); const [submitting, setSubmitting] = useState(false); + const [renderedOpenGeneration, setRenderedOpenGeneration] = useState(0); const [error, setError] = useState(); + const saveOwner = useRef(createOpdsFormSaveOwner()); + const wasVisible = useRef(false); useEffect(() => { + const opening = visible && !wasVisible.current; + wasVisible.current = visible; if (!visible) { + saveOwner.current.close(); setPassword(""); return; } + if (!opening) return; + const generation = saveOwner.current.open(); + setRenderedOpenGeneration(generation); setName(catalog?.name ?? ""); setUrl(catalog?.url ?? ""); setAuth(catalog?.auth ?? "anonymous"); setUsername(catalog?.username ?? ""); setPassword(""); setEnabled(catalog?.enabled ?? true); - setSubmitting(false); setError(undefined); }, [catalog, visible]); + const hasPassword = (catalog?.passwordStorage ?? "none") !== "none"; + const preservesPassword = Boolean( + catalog && + canPreserveOpdsCatalogPassword(catalog, { + url: url.trim(), + auth, + username: username.trim(), + }), + ); const canSubmit = name.trim().length > 0 && url.trim().length > 0 && (auth === "anonymous" || - (username.trim().length > 0 && - (password.length > 0 || (catalog?.passwordStorage ?? "none") !== "none"))) && + (username.trim().length > 0 && (password.length > 0 || preservesPassword))) && !submitting; + const savingCurrentOpen = saveOwner.current.isSavingCurrent(renderedOpenGeneration); const s = useMemo( () => @@ -208,8 +229,11 @@ export function OpdsCatalogFormSheet({ const persist = async () => { if (!canSubmit) return; + const token = saveOwner.current.start(renderedOpenGeneration); + if (!token) return; setSubmitting(true); setError(undefined); + let succeeded = false; try { const input = { name: name.trim(), @@ -222,12 +246,20 @@ export function OpdsCatalogFormSheet({ }; if (catalog) await store.updateCatalog(catalog.id, input); else await store.addCatalog(input); - setPassword(""); - onSaved(); + succeeded = true; } catch { - setError(t("library.opds.form.saveFailed")); + // Ownership is resolved in finally so stale failures cannot touch a reopened form. } finally { - setSubmitting(false); + const outcome = saveOwner.current.finish(token); + setSubmitting(saveOwner.current.hasActiveSave()); + if (succeeded && outcome === "current") { + setPassword(""); + onSaved(); + } else if (succeeded && outcome === "stale") { + onBackgroundSaved?.(); + } else if (!succeeded && outcome === "current") { + setError(t("library.opds.form.saveFailed")); + } } }; @@ -258,8 +290,21 @@ export function OpdsCatalogFormSheet({ }; return ( - - + { + if (!savingCurrentOpen) onClose(); + }} + > + { + if (!savingCurrentOpen) onClose(); + }} + > @@ -302,6 +348,7 @@ export function OpdsCatalogFormSheet({ placeholder="https://catalog.example.com/opds" placeholderTextColor={colors.mutedForeground} accessibilityLabel={t("library.opds.form.url")} + editable={!savingCurrentOpen} /> @@ -314,6 +361,7 @@ export function OpdsCatalogFormSheet({ onPress={() => setAuth(mode)} accessibilityRole="radio" accessibilityState={{ checked: auth === mode }} + disabled={savingCurrentOpen} > {mode === "anonymous" @@ -335,6 +383,7 @@ export function OpdsCatalogFormSheet({ autoCapitalize="none" autoCorrect={false} accessibilityLabel={t("library.opds.form.username")} + editable={!savingCurrentOpen} /> @@ -347,12 +396,17 @@ export function OpdsCatalogFormSheet({ autoCapitalize="none" autoCorrect={false} placeholder={ - catalog?.passwordStorage !== "none" - ? t("library.opds.form.passwordUnchanged") + hasPassword + ? t( + preservesPassword + ? "library.opds.form.passwordUnchanged" + : "library.opds.form.passwordRequiredForIdentityChange", + ) : undefined } placeholderTextColor={colors.mutedForeground} accessibilityLabel={t("library.opds.form.password")} + editable={!savingCurrentOpen} /> {catalog ? ( @@ -375,6 +429,7 @@ export function OpdsCatalogFormSheet({ value={enabled} onValueChange={setEnabled} accessibilityLabel={t("library.opds.form.enabled")} + disabled={savingCurrentOpen} /> {error ? ( @@ -384,7 +439,12 @@ export function OpdsCatalogFormSheet({ ) : null} - + {t("library.opds.cancel")} ); diff --git a/packages/app-expo/src/screens/library/opds-cover-cache.test.ts b/packages/app-expo/src/screens/library/opds-cover-cache.test.ts index becbd675c..9e5b0b317 100644 --- a/packages/app-expo/src/screens/library/opds-cover-cache.test.ts +++ b/packages/app-expo/src/screens/library/opds-cover-cache.test.ts @@ -127,7 +127,7 @@ describe("OPDS cover streaming and cache", () => { cache.clear(); expect(loaderSignal?.aborted).toBe(true); - expect(cache.snapshot()).toEqual({ entries: 0, sourceBytes: 0, urls: [] }); + expect(cache.snapshot()).toMatchObject({ entries: 0, sourceBytes: 0, urls: [] }); }); it("does not repopulate a cleared feed when a stale loader resolves late", async () => { @@ -146,6 +146,6 @@ describe("OPDS cover streaming and cache", () => { resolveLoad({ uri: "data:image/jpeg;base64,AQ==", byteLength: 1 }); await expect(stale).rejects.toThrow("cancelled"); - expect(cache.snapshot()).toEqual({ entries: 0, sourceBytes: 0, urls: [] }); + expect(cache.snapshot()).toMatchObject({ entries: 0, sourceBytes: 0, urls: [] }); }); }); diff --git a/packages/app-expo/src/screens/library/opds-form-save-owner.test.ts b/packages/app-expo/src/screens/library/opds-form-save-owner.test.ts new file mode 100644 index 000000000..ab67d5cfb --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-form-save-owner.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { createOpdsFormSaveOwner } from "./opds-form-save-owner"; + +describe("mobile OPDS form save ownership", () => { + it("serializes deferred add and update saves in one open generation", () => { + const owner = createOpdsFormSaveOwner(); + const generation = owner.open(); + const first = owner.start(generation); + + expect(first).toBeDefined(); + expect(owner.isSavingCurrent(generation)).toBe(true); + expect(owner.start(generation)).toBeUndefined(); + expect(owner.finish(first as never)).toBe("current"); + + const second = owner.start(generation); + expect(second).toBeDefined(); + expect(owner.finish(second as never)).toBe("current"); + }); + + it("treats completion after forced close and reopen as background-only", () => { + const owner = createOpdsFormSaveOwner(); + const firstGeneration = owner.open(); + const first = owner.start(firstGeneration); + owner.close(); + const secondGeneration = owner.open(); + + expect(owner.isSavingCurrent(secondGeneration)).toBe(false); + expect(owner.hasActiveSave()).toBe(true); + expect(owner.finish(first as never)).toBe("stale"); + expect(owner.hasActiveSave()).toBe(false); + expect(owner.start(secondGeneration)).toBeDefined(); + }); +}); diff --git a/packages/app-expo/src/screens/library/opds-form-save-owner.ts b/packages/app-expo/src/screens/library/opds-form-save-owner.ts new file mode 100644 index 000000000..c2745d82c --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-form-save-owner.ts @@ -0,0 +1,38 @@ +export interface OpdsFormSaveToken { + readonly saveId: number; + readonly openGeneration: number; +} + +export function createOpdsFormSaveOwner() { + let openGeneration = 0; + let saveId = 0; + let open = false; + let active: OpdsFormSaveToken | undefined; + + return { + open(): number { + open = true; + openGeneration += 1; + return openGeneration; + }, + close(): void { + open = false; + }, + start(generation: number): OpdsFormSaveToken | undefined { + if (active || !open || generation !== openGeneration) return undefined; + active = { saveId: ++saveId, openGeneration: generation }; + return active; + }, + finish(token: OpdsFormSaveToken): "current" | "stale" | "ignored" { + if (active?.saveId !== token.saveId) return "ignored"; + active = undefined; + return open && token.openGeneration === openGeneration ? "current" : "stale"; + }, + isSavingCurrent(generation: number): boolean { + return active?.openGeneration === generation && open && generation === openGeneration; + }, + hasActiveSave(): boolean { + return active !== undefined; + }, + }; +} diff --git a/packages/app/src/components/home/OpdsBrowser.test.tsx b/packages/app/src/components/home/OpdsBrowser.test.tsx index d6e9595be..4569b9aa1 100644 --- a/packages/app/src/components/home/OpdsBrowser.test.tsx +++ b/packages/app/src/components/home/OpdsBrowser.test.tsx @@ -219,7 +219,21 @@ describe("OpdsBrowser", () => { />, ); await screen.findByRole("heading", { name: "Root Shelf" }); + expect(client.open).toHaveBeenLastCalledWith( + "https://catalog.test/opds", + undefined, + expect.any(AbortSignal), + "https://catalog.test", + ); await userEvent.click(screen.getByRole("button", { name: "Child" })); + await waitFor(() => + expect(client.open).toHaveBeenLastCalledWith( + "https://catalog.test/child", + undefined, + expect.any(AbortSignal), + "https://catalog.test", + ), + ); await screen.findByRole("alert"); await userEvent.click(screen.getByRole("button", { name: "library.opds.back" })); expect(screen.queryByRole("alert")).toBeNull(); diff --git a/packages/app/src/components/home/OpdsBrowser.tsx b/packages/app/src/components/home/OpdsBrowser.tsx index 960eaf91a..6f0b1d284 100644 --- a/packages/app/src/components/home/OpdsBrowser.tsx +++ b/packages/app/src/components/home/OpdsBrowser.tsx @@ -188,6 +188,7 @@ export function OpdsBrowser({ createOpdsCoverCache({ maxEntries: MAX_COVER_CACHE_ENTRIES, maxBytes: MAX_COVER_CACHE_BYTES, + maxLoadBytes: MAX_COVER_BYTES, load: async (url, signal) => { const credentials = await store.getCredentials(catalog.id); if (signal.aborted) throw new Error("cancelled"); @@ -220,10 +221,10 @@ export function OpdsBrowser({ const openUrl = useCallback( (url: string, mode: LoadMode) => { void startOperation(url, mode, (credentials, signal) => - client.open(url, credentials, signal), + client.open(url, credentials, signal, catalogOrigin), ); }, - [client, startOperation], + [catalogOrigin, client, startOperation], ); useEffect(() => { @@ -275,7 +276,7 @@ export function OpdsBrowser({ if (!descriptor || !trimmed) return; const key = `opds-search:${encodeURIComponent(trimmed)}`; void startOperation(key, "push", (credentials, signal) => - client.search(descriptor, trimmed, credentials, signal), + client.search(descriptor, trimmed, credentials, signal, catalogOrigin), ); }; diff --git a/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx b/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx index 342d4b5db..fd5cf9b58 100644 --- a/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx +++ b/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx @@ -107,6 +107,65 @@ describe("OpdsCatalogFormDialog", () => { expect(onSaved).toHaveBeenCalledOnce(); }); + it("preserves a stored password for a same-origin path edit", async () => { + const updateCatalog = vi.fn(async (_id: string, _update: unknown) => undefined); + renderControlledForm({ + store: { updateCatalog }, + catalog: { + id: "custom", + name: "Private", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + enabled: true, + builtIn: false, + hidden: false, + passwordStorage: "persistent", + }, + }); + + const url = screen.getByLabelText("library.opds.form.url"); + await userEvent.clear(url); + await userEvent.type(url, "https://catalog.test/opds/v2"); + expect(screen.getByPlaceholderText("library.opds.form.passwordUnchanged")).toBeTruthy(); + await userEvent.click(screen.getByRole("button", { name: "library.opds.save" })); + + await waitFor(() => expect(updateCatalog).toHaveBeenCalledOnce()); + expect(updateCatalog.mock.calls[0]?.[1]).not.toHaveProperty("password"); + }); + + it("requires a new password before saving a changed credential identity", async () => { + const updateCatalog = vi.fn(async (_id: string, _update: unknown) => undefined); + renderControlledForm({ + store: { updateCatalog }, + catalog: { + id: "custom", + name: "Private", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + enabled: true, + builtIn: false, + hidden: false, + passwordStorage: "persistent", + }, + }); + + const url = screen.getByLabelText("library.opds.form.url"); + await userEvent.clear(url); + await userEvent.type(url, "https://other.test/opds"); + expect( + screen.getByPlaceholderText("library.opds.form.passwordRequiredForIdentityChange"), + ).toBeTruthy(); + expect( + (screen.getByRole("button", { name: "library.opds.save" }) as HTMLButtonElement).disabled, + ).toBe(true); + await userEvent.type(screen.getByLabelText("library.opds.form.password"), "new-secret"); + expect( + (screen.getByRole("button", { name: "library.opds.save" }) as HTMLButtonElement).disabled, + ).toBe(false); + }); + it("locks every mutable control and dismissal path during a current-generation save", async () => { const firstSave = deferred<{ id: string }>(); const addCatalog = vi.fn(() => firstSave.promise); diff --git a/packages/app/src/components/home/OpdsCatalogFormDialog.tsx b/packages/app/src/components/home/OpdsCatalogFormDialog.tsx index 8bc9d7b36..24d4f814a 100644 --- a/packages/app/src/components/home/OpdsCatalogFormDialog.tsx +++ b/packages/app/src/components/home/OpdsCatalogFormDialog.tsx @@ -14,6 +14,7 @@ import { type OpdsCatalog, type OpdsCatalogAuth, type OpdsCatalogStore, + canPreserveOpdsCatalogPassword, classifyOpdsUrl, } from "@readany/core"; import { Loader2, ShieldAlert } from "lucide-react"; @@ -78,11 +79,19 @@ export function OpdsCatalogFormDialog({ }, [catalog, open]); const hasPassword = (catalog?.passwordStorage ?? "none") !== "none"; + const preservesPassword = Boolean( + catalog && + canPreserveOpdsCatalogPassword(catalog, { + url: url.trim(), + auth, + username: username.trim(), + }), + ); const canSubmit = name.trim().length > 0 && url.trim().length > 0 && (auth === "anonymous" || - (username.trim().length > 0 && (password.length > 0 || hasPassword))) && + (username.trim().length > 0 && (password.length > 0 || preservesPassword))) && !submitting; const savingCurrentOpen = submitting && activeSave.current?.openGeneration === renderedOpenGeneration; @@ -272,7 +281,13 @@ export function OpdsCatalogFormDialog({ showPasswordLabel={t("library.opds.showPassword")} hidePasswordLabel={t("library.opds.hidePassword")} placeholder={ - catalog && hasPassword ? t("library.opds.form.passwordUnchanged") : undefined + catalog && hasPassword + ? t( + preservesPassword + ? "library.opds.form.passwordUnchanged" + : "library.opds.form.passwordRequiredForIdentityChange", + ) + : undefined } /> diff --git a/packages/core/src/i18n/locales/en/library.json b/packages/core/src/i18n/locales/en/library.json index d21b0ff9d..72d53cb69 100644 --- a/packages/core/src/i18n/locales/en/library.json +++ b/packages/core/src/i18n/locales/en/library.json @@ -84,6 +84,7 @@ "passwordSessionOnly": "Password available for this session only", "passwordStoredSecurely": "Password saved in secure storage", "passwordUnchanged": "Leave blank to keep saved password", + "passwordRequiredForIdentityChange": "Re-enter the password for these changes", "publicHttpBlocked": "Public catalogs must use HTTPS.", "saveFailed": "The catalog could not be saved.", "subtitle": "Connect a book catalog without putting its password in the address.", diff --git a/packages/core/src/i18n/locales/es/library.json b/packages/core/src/i18n/locales/es/library.json index 9de72ceab..347f4bbf6 100644 --- a/packages/core/src/i18n/locales/es/library.json +++ b/packages/core/src/i18n/locales/es/library.json @@ -84,6 +84,7 @@ "passwordSessionOnly": "Contraseña disponible solo durante esta sesión", "passwordStoredSecurely": "Contraseña guardada en almacenamiento seguro", "passwordUnchanged": "Déjalo en blanco para conservar la contraseña guardada", + "passwordRequiredForIdentityChange": "Vuelve a introducir la contraseña para estos cambios", "publicHttpBlocked": "Los catálogos públicos deben usar HTTPS.", "saveFailed": "No se pudo guardar el catálogo.", "subtitle": "Conecta un catálogo de libros sin poner la contraseña en la dirección.", diff --git a/packages/core/src/i18n/locales/fr/library.json b/packages/core/src/i18n/locales/fr/library.json index 51427c124..019ae10ae 100644 --- a/packages/core/src/i18n/locales/fr/library.json +++ b/packages/core/src/i18n/locales/fr/library.json @@ -84,6 +84,7 @@ "passwordSessionOnly": "Mot de passe disponible pour cette session uniquement", "passwordStoredSecurely": "Mot de passe enregistré dans le stockage sécurisé", "passwordUnchanged": "Laissez vide pour conserver le mot de passe enregistré", + "passwordRequiredForIdentityChange": "Saisissez à nouveau le mot de passe pour ces modifications", "publicHttpBlocked": "Les catalogues publics doivent utiliser HTTPS.", "saveFailed": "Le catalogue n’a pas pu être enregistré.", "subtitle": "Connectez un catalogue de livres sans inclure son mot de passe dans l’adresse.", diff --git a/packages/core/src/i18n/locales/ja/library.json b/packages/core/src/i18n/locales/ja/library.json index 6ed210a2f..d5e6ad6ed 100644 --- a/packages/core/src/i18n/locales/ja/library.json +++ b/packages/core/src/i18n/locales/ja/library.json @@ -84,6 +84,7 @@ "passwordSessionOnly": "パスワードはこのセッションでのみ利用できます", "passwordStoredSecurely": "パスワードは安全なストレージに保存されています", "passwordUnchanged": "保存済みのパスワードを残す場合は空欄にします", + "passwordRequiredForIdentityChange": "この変更にはパスワードを再入力してください", "publicHttpBlocked": "公開カタログには HTTPS が必要です。", "saveFailed": "カタログを保存できませんでした。", "subtitle": "パスワードをアドレスに含めずに書籍カタログへ接続します。", diff --git a/packages/core/src/i18n/locales/ko/library.json b/packages/core/src/i18n/locales/ko/library.json index 1e22c2209..9ff862ef8 100644 --- a/packages/core/src/i18n/locales/ko/library.json +++ b/packages/core/src/i18n/locales/ko/library.json @@ -84,6 +84,7 @@ "passwordSessionOnly": "이번 세션에서만 비밀번호 사용 가능", "passwordStoredSecurely": "비밀번호가 보안 저장소에 저장됨", "passwordUnchanged": "저장된 비밀번호를 유지하려면 비워 두세요", + "passwordRequiredForIdentityChange": "이 변경을 위해 비밀번호를 다시 입력하세요", "publicHttpBlocked": "공개 카탈로그는 HTTPS를 사용해야 합니다.", "saveFailed": "카탈로그를 저장하지 못했습니다.", "subtitle": "주소에 비밀번호를 넣지 않고 책 카탈로그에 연결합니다.", diff --git a/packages/core/src/i18n/locales/zh-TW/library.json b/packages/core/src/i18n/locales/zh-TW/library.json index 02603a041..2c318f2b6 100644 --- a/packages/core/src/i18n/locales/zh-TW/library.json +++ b/packages/core/src/i18n/locales/zh-TW/library.json @@ -84,6 +84,7 @@ "passwordSessionOnly": "密碼僅在本次工作階段中可用", "passwordStoredSecurely": "密碼已儲存到安全儲存空間", "passwordUnchanged": "留空即可保留已儲存的密碼", + "passwordRequiredForIdentityChange": "請為這些變更重新輸入密碼", "publicHttpBlocked": "公共目錄必須使用 HTTPS。", "saveFailed": "無法儲存目錄。", "subtitle": "連接書籍目錄,無需把密碼寫入網址。", diff --git a/packages/core/src/i18n/locales/zh/library.json b/packages/core/src/i18n/locales/zh/library.json index 84694e902..fe24dfe09 100644 --- a/packages/core/src/i18n/locales/zh/library.json +++ b/packages/core/src/i18n/locales/zh/library.json @@ -84,6 +84,7 @@ "passwordSessionOnly": "密码仅在本次会话中可用", "passwordStoredSecurely": "密码已保存到安全存储", "passwordUnchanged": "留空可保留已保存的密码", + "passwordRequiredForIdentityChange": "请为这些更改重新输入密码", "publicHttpBlocked": "公共目录必须使用 HTTPS。", "saveFailed": "无法保存目录。", "subtitle": "连接图书目录,无需把密码写进网址。", diff --git a/packages/core/src/i18n/opds-locales.test.ts b/packages/core/src/i18n/opds-locales.test.ts index 9c3e411df..9edaa3d0e 100644 --- a/packages/core/src/i18n/opds-locales.test.ts +++ b/packages/core/src/i18n/opds-locales.test.ts @@ -87,6 +87,7 @@ const REQUIRED_KEYS = [ "form.namePlaceholder", "form.password", "form.passwordMissing", + "form.passwordRequiredForIdentityChange", "form.passwordSessionOnly", "form.passwordStoredSecurely", "form.passwordUnchanged", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f6e337030..8b804fad5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -64,6 +64,7 @@ export { OPDS_BUILT_IN_CATALOGS, OPDS_CATALOG_STORAGE_KEY, OpdsCatalogStore, + canPreserveOpdsCatalogPassword, opdsCatalogSecretKey, type OpdsCatalog, type OpdsCatalogAuth, @@ -86,6 +87,11 @@ export { type SupportedOpdsAcquisition, } from "./opds/opds-acquisition"; export { parseOpdsDocument } from "./opds/opds-parser"; +export { + classifyOpdsAcquisitionRelation, + type OpdsAcquisitionRelation, + type OpdsAcquisitionRelationKind, +} from "./opds/opds-relations"; export { classifyOpdsUrl } from "./opds/opds-security"; export { createOpdsRuntime } from "./opds/opds-runtime"; export * from "./opds/opds-view-state"; diff --git a/packages/core/src/opds/opds-acquisition.ts b/packages/core/src/opds/opds-acquisition.ts index 6c3728801..bce1c97b4 100644 --- a/packages/core/src/opds/opds-acquisition.ts +++ b/packages/core/src/opds/opds-acquisition.ts @@ -2,6 +2,7 @@ import type { IPlatformService } from "../services/platform"; import type { BookFormat, BookMeta } from "../types/book"; import { normalizeIsbn } from "../utils/book-metadata"; import { type OpdsAssetResponse, type OpdsClient, OpdsError } from "./opds-client"; +import { classifyOpdsAcquisitionRelation } from "./opds-relations"; import type { OpdsAcquisition, OpdsCredentials, OpdsPublication } from "./opds-types"; const FORMAT_BY_MEDIA_TYPE: Readonly> = { @@ -33,7 +34,6 @@ const SUPPORTED_FORMATS = new Set([ "umd", ]); -const DIRECT_ACQUISITION_REL = "http://opds-spec.org/acquisition"; /** Hard safety ceiling for the whole-file platform write fallback. */ export const OPDS_MAX_ACQUISITION_BYTES = 256 * 1024 * 1024; @@ -135,8 +135,9 @@ function getSupportedFormat(acquisition: OpdsAcquisition): BookFormat | undefine } function isDirectAcquisition(acquisition: OpdsAcquisition): boolean { - return acquisition.rel.some( - (rel) => rel === DIRECT_ACQUISITION_REL || rel.startsWith(`${DIRECT_ACQUISITION_REL}/`), + return ( + (acquisition.relation ?? classifyOpdsAcquisitionRelation(acquisition.rel))?.downloadable === + true ); } diff --git a/packages/core/src/opds/opds-catalog-store.test.ts b/packages/core/src/opds/opds-catalog-store.test.ts index b7295bbd6..5a866e471 100644 --- a/packages/core/src/opds/opds-catalog-store.test.ts +++ b/packages/core/src/opds/opds-catalog-store.test.ts @@ -49,6 +49,72 @@ function createStorage(initial: string | null = null) { } describe("OpdsCatalogStore", () => { + it.each(["persistent", "session-only"] as const)( + "preserves a %s Basic password across same-origin path and display edits", + async (mode) => { + const { storage: fullStorage } = createStorage(); + const storage = + mode === "persistent" + ? fullStorage + : { kvGetItem: fullStorage.kvGetItem, kvSetItem: fullStorage.kvSetItem }; + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Private", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "old-password", + }); + + const updated = await store.updateCatalog(CUSTOM_ID, { + name: "Renamed", + url: "https://catalog.test/opds/v2", + }); + + expect(updated.passwordStorage).toBe(mode); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toMatchObject({ + username: "reader", + password: "old-password", + catalogOrigin: "https://catalog.test", + }); + }, + ); + + it.each([ + ["origin", { url: "https://other.test/opds" }], + ["username", { username: "other-reader" }], + ["anonymous to Basic auth", { auth: "basic", username: "reader" }], + ] as const)( + "rejects a blank password before mutating a changed %s identity", + async (_name, update) => { + const { storage, persisted } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + if (_name === "anonymous to Basic auth") { + await store.addCatalog({ + name: "Catalog", + url: "https://catalog.test/opds", + auth: "anonymous", + }); + } else { + await store.addCatalog({ + name: "Private", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "old-password", + }); + } + const before = persisted(); + + await expect(store.updateCatalog(CUSTOM_ID, update)).rejects.toThrow( + "A password is required when changing catalog credentials", + ); + expect(persisted()).toBe(before); + }, + ); + it("provides the two stable Gutenberg catalogs with immutable URLs", async () => { const { storage } = createStorage(); const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); @@ -168,7 +234,7 @@ describe("OpdsCatalogStore", () => { vi.mocked(storage.secretSetItem).mockClear(); await expect( - store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }), + store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds", password: "new-password" }), ).rejects.toThrow("remove failed"); expect(storage.kvSetItem).toHaveBeenCalledTimes(2); @@ -200,7 +266,7 @@ describe("OpdsCatalogStore", () => { vi.mocked(storage.secretGetItem).mockRejectedValueOnce(new Error("secret read failed")); await expect( - store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }), + store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds", password: "new-password" }), ).rejects.toThrow("secret read failed"); expect(storage.kvSetItem).not.toHaveBeenCalled(); expect(persisted()).toBe(before); @@ -227,7 +293,7 @@ describe("OpdsCatalogStore", () => { vi.mocked(storage.secretSetItem).mockRejectedValueOnce(new Error("restore failed")); await expect( - store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }), + store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds", password: "new-password" }), ).rejects.toThrow("Catalog update failed and secret compensation failed"); expect(persisted()).toBe(before); @@ -260,17 +326,20 @@ describe("OpdsCatalogStore", () => { vi.mocked(storage.secretRemoveItem).mockRejectedValueOnce(new Error("remove failed")); await expect( - store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }), + store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds", password: "new-password" }), ).rejects.toThrow("Catalog update failed and rollback failed"); expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ url: "https://other.test/opds", - passwordStorage: "none", + passwordStorage: "session-only", }); expect(JSON.parse(persisted() ?? "{}").customCatalogs[0].url).toBe("https://other.test/opds"); expect(storage.secretRemoveItem).toHaveBeenCalledTimes(2); expect(secrets.has(opdsCatalogSecretKey(CUSTOM_ID))).toBe(false); - await expect(store.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toMatchObject({ + password: "new-password", + catalogOrigin: "https://other.test", + }); }); it("durably blocks a stale update secret across restart until cleanup succeeds", async () => { @@ -299,7 +368,7 @@ describe("OpdsCatalogStore", () => { }); await expect( - store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }), + store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds", password: "new-password" }), ).rejects.toThrow("Catalog update failed and rollback failed"); const failedState = JSON.parse(persisted() ?? "{}"); @@ -634,7 +703,7 @@ describe("OpdsCatalogStore", () => { vi.mocked(storage.kvSetItem).mockClear(); await expect( - store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }), + store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds", password: "new-password" }), ).rejects.toThrow("Catalog cleanup revision is exhausted"); expect(storage.kvSetItem).not.toHaveBeenCalled(); @@ -964,7 +1033,10 @@ describe("OpdsCatalogStore", () => { await store.updateCatalog(CUSTOM_ID, { name: "Still private" }); expect(storage.secretRemoveItem).not.toHaveBeenCalled(); - await store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }); + await store.updateCatalog(CUSTOM_ID, { + url: "https://other.test/opds", + password: "new-password", + }); expect(storage.secretRemoveItem).toHaveBeenCalledWith(opdsCatalogSecretKey(CUSTOM_ID)); vi.mocked(storage.secretRemoveItem).mockClear(); @@ -1140,7 +1212,7 @@ describe("OpdsCatalogStore", () => { expect(added.passwordStorage).toBe("session-only"); }); - it("durably blocks an old persistent secret when identity changes through a missing adapter", async () => { + it("rejects an unsafe blank identity change through a missing secret adapter", async () => { const { storage, secrets, persisted } = createStorage(); const complete = new OpdsCatalogStore(storage, () => CUSTOM_ID); await complete.load(); @@ -1160,27 +1232,25 @@ describe("OpdsCatalogStore", () => { await expect( missing.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }), - ).resolves.toMatchObject({ url: "https://other.test/opds", passwordStorage: "none" }); + ).rejects.toThrow("A password is required when changing catalog credentials"); expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("old-password"); - expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toEqual([ - { id: CUSTOM_ID, revision: 1, action: "remove-secret" }, - ]); + expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toBeUndefined(); await expect(missing.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); await missing.hideBuiltIn("gutenberg"); await missing.restoreBuiltIn("gutenberg"); - expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toEqual([ - { id: CUSTOM_ID, revision: 1, action: "remove-secret" }, - ]); + expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toBeUndefined(); vi.mocked(storage.secretRemoveItem).mockClear(); const restored = new OpdsCatalogStore(storage, () => OTHER_ID); await restored.load(); - expect(storage.secretRemoveItem).toHaveBeenCalledWith(opdsCatalogSecretKey(CUSTOM_ID)); - expect(secrets.has(opdsCatalogSecretKey(CUSTOM_ID))).toBe(false); + expect(storage.secretRemoveItem).not.toHaveBeenCalled(); + expect(secrets.has(opdsCatalogSecretKey(CUSTOM_ID))).toBe(true); expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toBeUndefined(); - await expect(restored.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + await expect(restored.getCredentials(CUSTOM_ID)).resolves.toMatchObject({ + password: "old-password", + }); }); it("allows safe missing-backend edits and deletion while retaining cleanup", async () => { @@ -1204,9 +1274,9 @@ describe("OpdsCatalogStore", () => { vi.mocked(storage.secretSetItem).mockClear(); vi.mocked(storage.secretRemoveItem).mockClear(); - await expect( - missing.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }), - ).resolves.toMatchObject({ url: "https://other.test/opds" }); + await expect(missing.updateCatalog(CUSTOM_ID, { auth: "anonymous" })).resolves.toMatchObject({ + auth: "anonymous", + }); await expect(missing.updateCatalog(CUSTOM_ID, { name: "Renamed" })).resolves.toMatchObject({ name: "Renamed", }); @@ -1252,7 +1322,10 @@ describe("OpdsCatalogStore", () => { }; const missing = new OpdsCatalogStore(missingStorage, () => OTHER_ID); await missing.load(); - await missing.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }); + await missing.updateCatalog(CUSTOM_ID, { + url: "https://other.test/opds", + password: "intermediate-password", + }); vi.mocked(storage.secretGetItem).mockClear(); vi.mocked(storage.secretSetItem).mockClear(); vi.mocked(storage.secretRemoveItem).mockClear(); @@ -1426,11 +1499,18 @@ describe("OpdsCatalogStore", () => { const reading = store.getCredentials(CUSTOM_ID); await vi.waitFor(() => expect(storage.secretGetItem).toHaveBeenCalled()); - await store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }); + await store.updateCatalog(CUSTOM_ID, { + url: "https://other.test/opds", + password: "new-password", + }); pendingSecret.resolve("old-password"); await expect(reading).resolves.toBeUndefined(); - expect(store.getCatalog(CUSTOM_ID)?.passwordStorage).toBe("none"); + expect(store.getCatalog(CUSTOM_ID)?.passwordStorage).toBe("persistent"); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toMatchObject({ + password: "new-password", + catalogOrigin: "https://other.test", + }); }); it("does not return or resurrect a secret read that loses a race with deletion", async () => { diff --git a/packages/core/src/opds/opds-catalog-store.ts b/packages/core/src/opds/opds-catalog-store.ts index 25c0b8e18..e63eab2f8 100644 --- a/packages/core/src/opds/opds-catalog-store.ts +++ b/packages/core/src/opds/opds-catalog-store.ts @@ -108,6 +108,28 @@ function normalizeUrl(value: unknown): string | undefined { } } +function urlOrigin(value: string): string | undefined { + try { + return new URL(value).origin; + } catch { + return undefined; + } +} + +export function canPreserveOpdsCatalogPassword( + current: Pick, + next: Pick, +): boolean { + return ( + current.passwordStorage !== "none" && + current.auth === "basic" && + next.auth === "basic" && + current.username === next.username && + urlOrigin(current.url) !== undefined && + urlOrigin(current.url) === urlOrigin(next.url) + ); +} + function normalizeCustomCatalog(value: unknown): CustomCatalogDefinition | undefined { if (!isRecord(value)) return undefined; const id = typeof value.id === "string" ? value.id : ""; @@ -323,11 +345,14 @@ export class OpdsCatalogStore { if (next.auth === "anonymous" && update.password !== undefined) { throw new Error("Anonymous catalogs cannot have a password"); } - const credentialChanged = - next.url !== current.url || - next.auth !== current.auth || - next.username !== current.username || - update.password !== undefined; + const preservesPassword = canPreserveOpdsCatalogPassword(this.toCustomCatalog(current), next); + const originChanged = urlOrigin(next.url) !== urlOrigin(current.url); + const identityChanged = + originChanged || next.auth !== current.auth || next.username !== current.username; + if (next.auth === "basic" && identityChanged && !update.password && !preservesPassword) { + throw new Error("A password is required when changing catalog credentials"); + } + const credentialChanged = identityChanged || update.password !== undefined; const cleanupAlreadyPending = this.pendingSecretCleanups.has(id); const previousPersistentPassword = credentialChanged && !cleanupAlreadyPending diff --git a/packages/core/src/opds/opds-client.test.ts b/packages/core/src/opds/opds-client.test.ts index a63dd2bdd..cceb3335a 100644 --- a/packages/core/src/opds/opds-client.test.ts +++ b/packages/core/src/opds/opds-client.test.ts @@ -119,6 +119,74 @@ async function expectOpdsError(promise: Promise, code: OpdsError["code" } describe("OpdsClient catalog requests", () => { + it.each([ + ["navigation", "http://127.0.0.1:8080/feed.xml", "open"], + ["different loopback port", "http://localhost:8081/feed.xml", "open"], + ["private address", "http://192.168.1.20/feed.xml", "open"], + ["local asset", "http://printer.local/cover.jpg", "asset"], + ] as const)( + "blocks an HTTPS catalog's feed-provided %s target before requesting it", + async (_name, target, kind) => { + const platform = fakePlatform(() => response(ATOM)); + const client = new OpdsClient(platform); + const request = + kind === "asset" + ? client.fetchAsset(target, "https://remote.test") + : client.open(target, undefined, undefined, "https://remote.test"); + + await expectOpdsError(request, "insecure-url"); + expect(platform.calls).toHaveLength(0); + }, + ); + + it("allows confirmed local HTTP only on the catalog's exact origin", async () => { + const platform = fakePlatform(() => response(ATOM)); + const client = new OpdsClient(platform); + + await client.open( + "http://localhost:8080/child.xml", + undefined, + undefined, + "http://localhost:8080/root.xml", + ); + await expectOpdsError( + client.open( + "http://localhost:8081/child.xml", + undefined, + undefined, + "http://localhost:8080/root.xml", + ), + "insecure-url", + ); + await expectOpdsError( + client.open( + "http://127.0.0.1:8080/child.xml", + undefined, + undefined, + "http://localhost:8080/root.xml", + ), + "insecure-url", + ); + expect(platform.calls.map((call) => call.url)).toEqual(["http://localhost:8080/child.xml"]); + }); + + it("does not let redirects expand a confirmed local HTTP origin", async () => { + const platform = fakePlatform(() => + response("", { status: 302, headers: { Location: "http://127.0.0.1:8080/feed.xml" } }), + ); + + await expectOpdsError( + new OpdsClient(platform).open( + "http://localhost:8080/feed.xml", + undefined, + undefined, + "http://localhost:8080", + ), + "insecure-url", + ); + expect(platform.calls).toHaveLength(1); + }); + it("sends Basic auth only to the configured catalog origin", async () => { const platform = fakePlatform(() => response(ATOM)); const client = new OpdsClient(platform); @@ -871,6 +939,50 @@ describe("OpdsClient assets", () => { }); describe("OpdsClient search", () => { + it("upgrades Gutenberg-style public HTTP search templates advertised by HTTPS", async () => { + const gutenbergDescriptor = `Gutenberg`; + const platform = fakePlatform((url) => { + if (url === "https://www.gutenberg.org/catalog/osd-books.xml") { + return response(gutenbergDescriptor, { + headers: { "Content-Type": "application/opensearchdescription+xml" }, + }); + } + if (url === "https://m.gutenberg.org/ebooks/search.opds/?query=alice") return response(ATOM); + throw new Error(`Unexpected test URL: ${url}`); + }); + + await new OpdsClient(platform).search( + { + kind: "openSearch", + descriptorUrl: "https://www.gutenberg.org/catalog/osd-books.xml", + }, + "alice", + undefined, + undefined, + "https://www.gutenberg.org", + ); + + expect(platform.calls.map((call) => call.url)).toEqual([ + "https://www.gutenberg.org/catalog/osd-books.xml", + "https://m.gutenberg.org/ebooks/search.opds/?query=alice", + ]); + }); + + it("does not upgrade or request a local HTTP search target advertised by HTTPS", async () => { + const platform = fakePlatform(() => response(ATOM)); + + await expectOpdsError( + new OpdsClient(platform).search( + { kind: "template", urlTemplate: "http://127.0.0.1:8080/search{?query}" }, + "books", + undefined, + undefined, + "https://remote.test", + ), + "insecure-url", + ); + expect(platform.calls).toHaveLength(0); + }); it("fetches an advertised OPDS 1 OpenSearch descriptor and encodes the query", async () => { const platform = fakePlatform((url) => { if (url === "https://catalog.test/open-search.xml") { diff --git a/packages/core/src/opds/opds-client.ts b/packages/core/src/opds/opds-client.ts index a4f8ebd0c..30b14beab 100644 --- a/packages/core/src/opds/opds-client.ts +++ b/packages/core/src/opds/opds-client.ts @@ -55,6 +55,7 @@ interface RequestOptions { accept: string; responseType: "text" | "arraybuffer"; credentials?: OpdsCredentials; + catalogOrigin?: string; } type OpdsFetchPlatform = Pick; @@ -207,16 +208,51 @@ function authError(response: Response): OpdsError | undefined { return new OpdsError("unsupported-auth"); } -function checkUrl(value: string): URL { +function getConfirmedInsecureOrigin(catalogOrigin?: string): string | undefined { + if (!catalogOrigin) return undefined; + const classification = classifyOpdsUrl(catalogOrigin); + if (!classification.allowed) throw new OpdsError("insecure-url"); + try { + const url = new URL(catalogOrigin); + return classification.requiresInsecureConfirmation ? url.origin : undefined; + } catch { + throw new OpdsError("insecure-url"); + } +} + +function checkUrl(value: string, confirmedInsecureOrigin?: string): URL { const classification = classifyOpdsUrl(value); if (!classification.allowed) throw new OpdsError("insecure-url"); try { - return new URL(value); + const url = new URL(value); + if (classification.requiresInsecureConfirmation && url.origin !== confirmedInsecureOrigin) { + throw new OpdsError("insecure-url"); + } + return url; } catch { throw new OpdsError("insecure-url"); } } +function canonicalizeAdvertisedSearchUrl(value: string, sourceUrl?: string): string { + if (!sourceUrl) return value; + let source: URL; + try { + source = new URL(sourceUrl); + } catch { + return value; + } + const classification = classifyOpdsUrl(value); + if (source.protocol !== "https:" || classification.reason !== "public-http") return value; + try { + const upgraded = new URL(value); + upgraded.protocol = "https:"; + return upgraded.href; + } catch { + return value; + } +} + function runPlatformFetch( platform: OpdsFetchPlatform, url: string, @@ -569,7 +605,8 @@ export class OpdsClient { lifecycle: RequestLifecycle, ): Promise { const authOrigin = getAuthOrigin(options.credentials); - let current = checkUrl(url); + const confirmedInsecureOrigin = getConfirmedInsecureOrigin(options.catalogOrigin); + let current = checkUrl(url, confirmedInsecureOrigin); for (let redirects = 0; ; redirects += 1) { lifecycle.throwIfAborted(); @@ -610,7 +647,7 @@ export class OpdsClient { if (!location) throw new OpdsError("invalid-catalog"); let next: URL; try { - next = checkUrl(new URL(location, current).href); + next = checkUrl(new URL(location, current).href, confirmedInsecureOrigin); } catch (error) { if (error instanceof OpdsError) throw error; throw new OpdsError("insecure-url"); @@ -622,7 +659,12 @@ export class OpdsClient { } } - async open(url: string, credentials?: OpdsCredentials, signal?: AbortSignal): Promise { + async open( + url: string, + credentials?: OpdsCredentials, + signal?: AbortSignal, + catalogOrigin?: string, + ): Promise { const lifecycle = new RequestLifecycle(signal); try { const { response, finalUrl } = await this.request( @@ -631,6 +673,7 @@ export class OpdsClient { accept: CATALOG_ACCEPT, responseType: "text", credentials, + catalogOrigin: catalogOrigin ?? credentials?.catalogOrigin, }, lifecycle, ); @@ -656,10 +699,15 @@ export class OpdsClient { query: string, credentials?: OpdsCredentials, signal?: AbortSignal, + catalogOrigin?: string, ): Promise { + const requestCatalogOrigin = catalogOrigin ?? credentials?.catalogOrigin; if (descriptor.kind === "template") { - const searchUrl = await expandTemplate(descriptor, query); - return this.open(searchUrl, credentials, signal); + const searchUrl = canonicalizeAdvertisedSearchUrl( + await expandTemplate(descriptor, query), + requestCatalogOrigin, + ); + return this.open(searchUrl, credentials, signal, requestCatalogOrigin); } const lifecycle = new RequestLifecycle(signal); @@ -671,6 +719,7 @@ export class OpdsClient { accept: OPENSEARCH_ACCEPT, responseType: "text", credentials, + catalogOrigin: requestCatalogOrigin, }, lifecycle, ); @@ -683,17 +732,18 @@ export class OpdsClient { throw new OpdsError("invalid-catalog"); } try { - searchUrl = new URL( - search.search(new Map([[null, new Map([["searchTerms", query]])]])), + searchUrl = canonicalizeAdvertisedSearchUrl( + new URL(search.search(new Map([[null, new Map([["searchTerms", query]])]])), finalUrl) + .href, finalUrl, - ).href; + ); } catch { throw new OpdsError("invalid-catalog"); } } finally { lifecycle.dispose(); } - return this.open(searchUrl, credentials, signal); + return this.open(searchUrl, credentials, signal, requestCatalogOrigin); } async fetchAsset( @@ -717,6 +767,7 @@ export class OpdsClient { accept: "*/*", responseType: "arraybuffer", credentials, + catalogOrigin: normalizedCatalogOrigin, }, lifecycle, ); diff --git a/packages/core/src/opds/opds-cover-cache.test.ts b/packages/core/src/opds/opds-cover-cache.test.ts index ca37df274..5fd23f2b7 100644 --- a/packages/core/src/opds/opds-cover-cache.test.ts +++ b/packages/core/src/opds/opds-cover-cache.test.ts @@ -38,7 +38,7 @@ describe("shared OPDS cover cache", () => { (await cache.acquire("first")).release(); (await cache.acquire("second")).release(); - expect(cache.snapshot()).toEqual({ entries: 1, sourceBytes: 4, urls: ["second"] }); + expect(cache.snapshot()).toMatchObject({ entries: 1, sourceBytes: 4, urls: ["second"] }); }); it("never admits beyond entry or byte bounds while every cached cover is leased", async () => { @@ -46,16 +46,54 @@ describe("shared OPDS cover cache", () => { load: async (url) => ({ uri: url, byteLength: 2 }), maxEntries: 12, maxBytes: 24, + maxLoadBytes: 2, }); - const leases = []; - for (let index = 0; index < 20; index += 1) { - leases.push(await cache.acquire(`cover-${index}`)); - const snapshot = cache.snapshot(); - expect(snapshot.entries).toBeLessThanOrEqual(12); - expect(snapshot.sourceBytes).toBeLessThanOrEqual(24); - } + const leases = await Promise.all( + Array.from({ length: 12 }, (_, index) => cache.acquire(`cover-${index}`)), + ); + await expect(cache.acquire("cover-12")).rejects.toThrow("cover-cache-full"); + expect(cache.snapshot()).toMatchObject({ + entries: 12, + sourceBytes: 24, + liveEntries: 12, + liveBytes: 24, + reservedBytes: 0, + }); + leases[0]?.release(); + const replacement = await cache.acquire("cover-12"); + expect(cache.snapshot()).toMatchObject({ liveEntries: 12, liveBytes: 24 }); + replacement.release(); + for (const lease of leases) lease.release(); + }); - expect(cache.snapshot()).toMatchObject({ entries: 12, sourceBytes: 24 }); + it("reserves the hard live-byte budget before starting distinct loads", async () => { + const gate = deferred(); + const load = vi.fn(async (url: string) => { + await gate.promise; + return { uri: url, byteLength: 4 }; + }); + const cache = createOpdsCoverCache({ + load, + maxEntries: 2, + maxBytes: 8, + maxLoadBytes: 4, + maxConcurrentLoads: 4, + }); + + const first = cache.acquire("first"); + const second = cache.acquire("second"); + await expect(cache.acquire("third")).rejects.toThrow("cover-cache-full"); + expect(load).toHaveBeenCalledTimes(2); + expect(cache.snapshot()).toMatchObject({ + entries: 0, + sourceBytes: 0, + liveEntries: 2, + liveBytes: 8, + reservedBytes: 8, + }); + gate.resolve(); + const leases = await Promise.all([first, second]); + expect(cache.snapshot()).toMatchObject({ liveEntries: 2, liveBytes: 8, reservedBytes: 0 }); for (const lease of leases) lease.release(); }); @@ -71,8 +109,9 @@ describe("shared OPDS cover cache", () => { active -= 1; return { uri: url, byteLength: 1 }; }, - maxEntries: 12, - maxBytes: 24, + maxEntries: 20, + maxBytes: 20, + maxLoadBytes: 1, maxConcurrentLoads: 3, }); diff --git a/packages/core/src/opds/opds-cover-cache.ts b/packages/core/src/opds/opds-cover-cache.ts index b3ce56d13..cd289d1e4 100644 --- a/packages/core/src/opds/opds-cover-cache.ts +++ b/packages/core/src/opds/opds-cover-cache.ts @@ -101,19 +101,23 @@ interface CacheEntry extends OpdsCoverValue { interface InFlightEntry { controller: AbortController; - promise: Promise; + promise: Promise; waiters: number; + releaseReservation(): void; } export function createOpdsCoverCache({ load, maxEntries, maxBytes, + maxLoadBytes = maxBytes, maxConcurrentLoads = 4, }: { load(url: string, signal: AbortSignal): Promise; maxEntries: number; maxBytes: number; + /** Maximum bytes one loader can return; reserved before the transport starts. */ + maxLoadBytes?: number; maxConcurrentLoads?: number; }) { const entries = new Map(); @@ -122,6 +126,7 @@ export function createOpdsCoverCache({ let clock = 0; let generation = 0; let activeLoads = 0; + let reservedBytes = 0; const queuedLoads: Array<() => void> = []; const runQueuedLoads = () => { @@ -164,6 +169,21 @@ export function createOpdsCoverCache({ } }; + const evictForReservation = (bytes: number) => { + while ( + entries.size + inFlight.size >= maxEntries || + sourceBytes + reservedBytes + bytes > maxBytes + ) { + const candidate = [...entries.entries()] + .filter(([, entry]) => entry.references === 0) + .sort(([, left], [, right]) => left.lastUsed - right.lastUsed)[0]; + if (!candidate) return false; + entries.delete(candidate[0]); + sourceBytes -= candidate[1].byteLength; + } + return true; + }; + const lease = (entry: CacheEntry): OpdsCoverLease => { entry.references += 1; entry.lastUsed = ++clock; @@ -189,11 +209,39 @@ export function createOpdsCoverCache({ let pending = inFlight.get(url); if (!pending) { + const reservation = Math.min(maxLoadBytes, maxBytes); + if ( + maxEntries <= 0 || + maxLoadBytes <= 0 || + maxLoadBytes > maxBytes || + !evictForReservation(reservation) + ) { + throw new Error("cover-cache-full"); + } const controller = new AbortController(); - const promise = scheduleLoad(url, controller.signal).finally(() => { - if (inFlight.get(url)?.promise === promise) inFlight.delete(url); - }); - pending = { controller, promise, waiters: 0 }; + reservedBytes += reservation; + let reservationActive = true; + const releaseReservation = () => { + if (!reservationActive) return; + reservationActive = false; + reservedBytes = Math.max(0, reservedBytes - reservation); + }; + const loadGeneration = generation; + const promise = scheduleLoad(url, controller.signal) + .then((value): CacheEntry => { + if (loadGeneration !== generation) throw new Error("cancelled"); + if (value.byteLength > reservation) throw new Error("cover-too-large"); + const entry = { ...value, references: 0, lastUsed: ++clock }; + entries.set(url, entry); + sourceBytes += value.byteLength; + releaseReservation(); + return entry; + }) + .finally(() => { + releaseReservation(); + if (inFlight.get(url)?.promise === promise) inFlight.delete(url); + }); + pending = { controller, promise, waiters: 0, releaseReservation }; inFlight.set(url, pending); } pending.waiters += 1; @@ -205,26 +253,10 @@ export function createOpdsCoverCache({ const onAbort = () => rejectCancelled?.(new Error("cancelled")); signal?.addEventListener("abort", onAbort, { once: true }); try { - const value = await Promise.race([pending.promise, cancelled]); + const entry = await Promise.race([pending.promise, cancelled]); settled = true; if (acquisitionGeneration !== generation) throw new Error("cancelled"); - let entry = entries.get(url); - if (!entry && value.byteLength <= maxBytes && maxEntries > 0) { - while (entries.size >= maxEntries || sourceBytes + value.byteLength > maxBytes) { - const candidate = [...entries.entries()] - .filter(([, cached]) => cached.references === 0) - .sort(([, left], [, right]) => left.lastUsed - right.lastUsed)[0]; - if (!candidate) break; - entries.delete(candidate[0]); - sourceBytes -= candidate[1].byteLength; - } - if (entries.size < maxEntries && sourceBytes + value.byteLength <= maxBytes) { - entry = { ...value, references: 0, lastUsed: ++clock }; - entries.set(url, entry); - sourceBytes += value.byteLength; - } - } - return entry ? lease(entry) : { uri: value.uri, release() {} }; + return lease(entry); } finally { signal?.removeEventListener("abort", onAbort); pending.waiters = Math.max(0, pending.waiters - 1); @@ -233,13 +265,24 @@ export function createOpdsCoverCache({ }, clear(): void { generation += 1; - for (const pending of inFlight.values()) pending.controller.abort(); + for (const pending of inFlight.values()) { + pending.controller.abort(); + pending.releaseReservation(); + } inFlight.clear(); entries.clear(); sourceBytes = 0; + reservedBytes = 0; }, snapshot() { - return { entries: entries.size, sourceBytes, urls: [...entries.keys()] }; + return { + entries: entries.size, + sourceBytes, + urls: [...entries.keys()], + liveEntries: entries.size + inFlight.size, + liveBytes: sourceBytes + reservedBytes, + reservedBytes, + }; }, }; } diff --git a/packages/core/src/opds/opds-parser.test.ts b/packages/core/src/opds/opds-parser.test.ts index cd2951f1f..0469807e8 100644 --- a/packages/core/src/opds/opds-parser.test.ts +++ b/packages/core/src/opds/opds-parser.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; +import { listSupportedAcquisitions } from "./opds-acquisition"; import { parseOpdsDocument } from "./opds-parser"; const ATOM = ` @@ -88,6 +89,50 @@ const OPDS2 = JSON.stringify({ }); describe("parseOpdsDocument", () => { + it.each([ + ["OPDS 2 acquisition", "json", "acquisition", "direct", true], + ["OPDS 2 download", "json", "download", "direct", true], + ["OPDS 2 borrow", "json", "borrow", "borrow", false], + ["OPDS 2 buy", "json", "buy", "buy", false], + ["OPDS 2 preview", "json", "preview", "preview", false], + ["OPDS 2 subscribe", "json", "subscribe", "subscribe", false], + ["OPDS 1 acquisition", "xml", "http://opds-spec.org/acquisition", "direct", true], + ["OPDS 1 open access", "xml", "http://opds-spec.org/acquisition/open-access", "direct", true], + ["OPDS 1 borrow", "xml", "http://opds-spec.org/acquisition/borrow", "borrow", false], + ["OPDS 1 buy", "xml", "http://opds-spec.org/acquisition/buy", "buy", false], + ["OPDS 1 sample", "xml", "http://opds-spec.org/acquisition/sample", "sample", false], + ["OPDS 1 subscribe", "xml", "http://opds-spec.org/acquisition/subscribe", "subscribe", false], + ] as const)( + "preserves %s relation semantics through acquisition selection", + (_name, version, rel, kind, downloadable) => { + const body = + version === "json" + ? JSON.stringify({ + metadata: { title: "Catalog" }, + links: [{ rel: "self", href: "feed.json", type: "application/opds+json" }], + publications: [ + { + metadata: { title: "Book" }, + links: [{ rel, href: "book.epub", type: "application/epub+zip" }], + }, + ], + }) + : `CatalogBook`; + const feed = parseOpdsDocument( + body, + version === "json" ? "application/opds+json" : "application/atom+xml;profile=opds-catalog", + `https://catalog.test/feed.${version}`, + ); + + expect(feed.publications[0]?.acquisitions[0]).toMatchObject({ + relation: { kind, downloadable }, + }); + expect(listSupportedAcquisitions(feed.publications[0] ?? ({} as never))).toHaveLength( + downloadable ? 1 : 0, + ); + }, + ); + it("normalizes an OPDS 1 Atom acquisition feed", () => { const feed = parseOpdsDocument( ATOM, @@ -460,6 +505,18 @@ describe("parseOpdsDocument", () => { ).toThrow("Invalid OPDS XML document"); }); + it.each([ + ["empty Atom feed", 'News'], + [ + "generic Atom feed", + 'NewsStory', + ], + ])("rejects a non-OPDS %s", (_name, body) => { + expect(() => + parseOpdsDocument(body, "application/atom+xml", "https://catalog.test/feed.xml"), + ).toThrow("Invalid OPDS XML document"); + }); + it("keeps compatibility with namespace-less Atom feeds", () => { const feed = parseOpdsDocument( 'Legacy CatalogBooks', diff --git a/packages/core/src/opds/opds-parser.ts b/packages/core/src/opds/opds-parser.ts index f0f8236d9..3d8137729 100644 --- a/packages/core/src/opds/opds-parser.ts +++ b/packages/core/src/opds/opds-parser.ts @@ -3,6 +3,7 @@ import { DOMParser } from "@xmldom/xmldom"; import { SYMBOL, getFeed } from "foliate-js/opds.js"; import type { BookFormat } from "../types/book"; +import { classifyOpdsAcquisitionRelation } from "./opds-relations"; import { sanitizeOpdsDescription } from "./opds-sanitize"; import type { OpdsAcquisition, @@ -12,15 +13,6 @@ import type { OpdsSearchDescriptor, } from "./opds-types"; -const ACQUISITION_REL = "http://opds-spec.org/acquisition"; -const ACQUISITION_RELS = new Set([ - "acquisition", - "borrow", - "buy", - "download", - "preview", - "subscribe", -]); const ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"; const IMAGE_RELS = new Set([ "cover", @@ -108,9 +100,7 @@ function normalizeRel(value: unknown): string[] { } function isAcquisitionRelation(rel: string): boolean { - return ( - ACQUISITION_RELS.has(rel) || rel === ACQUISITION_REL || rel.startsWith(`${ACQUISITION_REL}/`) - ); + return classifyOpdsAcquisitionRelation([rel]) !== undefined; } function isAcquisitionLink(value: unknown): value is UnknownRecord { @@ -215,7 +205,9 @@ function getBookFormat(type: string | undefined, url: string): BookFormat | null function mapAcquisition(value: unknown, documentUrl: string): OpdsAcquisition | undefined { const link = mapLink(value, documentUrl); if (!link || !isAcquisitionLink(value)) return undefined; - return { ...link, format: getBookFormat(link.type, link.url) }; + const relation = classifyOpdsAcquisitionRelation(link.rel); + if (!relation) return undefined; + return { ...link, format: getBookFormat(link.type, link.url), relation }; } function mapPublication(value: unknown, documentUrl: string): OpdsPublication { @@ -519,6 +511,19 @@ function parseXml(body: string, documentUrl: string): OpdsFeed { throw new Error("Invalid OPDS XML document"); } + const hasOpdsSemantics = Array.from(root.getElementsByTagName("link")).some((link) => { + const rel = (link.getAttribute("rel") ?? "").trim().split(/\s+/).filter(Boolean); + const type = (link.getAttribute("type") ?? "").toLowerCase(); + return ( + classifyOpdsAcquisitionRelation(rel) !== undefined || + (type.includes("application/atom+xml") && + /(?:^|;)\s*profile\s*=\s*["']?opds-catalog/i.test(type)) || + (rel.includes("search") && type.includes("application/opensearchdescription+xml")) || + rel.includes("http://opds-spec.org/facet") + ); + }); + if (!hasOpdsSemantics) throw new Error("Invalid OPDS XML document"); + try { const normalized = getFeed(document as unknown as Document); return mapFeed(normalized, documentUrl); diff --git a/packages/core/src/opds/opds-relations.ts b/packages/core/src/opds/opds-relations.ts new file mode 100644 index 000000000..9b5b83781 --- /dev/null +++ b/packages/core/src/opds/opds-relations.ts @@ -0,0 +1,41 @@ +export type OpdsAcquisitionRelationKind = + | "direct" + | "borrow" + | "buy" + | "preview" + | "sample" + | "subscribe"; + +export interface OpdsAcquisitionRelation { + kind: OpdsAcquisitionRelationKind; + downloadable: boolean; +} + +const OPDS1_ACQUISITION = "http://opds-spec.org/acquisition"; + +const RELATIONS: Readonly> = { + acquisition: { kind: "direct", downloadable: true }, + download: { kind: "direct", downloadable: true }, + borrow: { kind: "borrow", downloadable: false }, + buy: { kind: "buy", downloadable: false }, + preview: { kind: "preview", downloadable: false }, + sample: { kind: "sample", downloadable: false }, + subscribe: { kind: "subscribe", downloadable: false }, + [OPDS1_ACQUISITION]: { kind: "direct", downloadable: true }, + [`${OPDS1_ACQUISITION}/open-access`]: { kind: "direct", downloadable: true }, + [`${OPDS1_ACQUISITION}/borrow`]: { kind: "borrow", downloadable: false }, + [`${OPDS1_ACQUISITION}/buy`]: { kind: "buy", downloadable: false }, + [`${OPDS1_ACQUISITION}/preview`]: { kind: "preview", downloadable: false }, + [`${OPDS1_ACQUISITION}/sample`]: { kind: "sample", downloadable: false }, + [`${OPDS1_ACQUISITION}/subscribe`]: { kind: "subscribe", downloadable: false }, +}; + +export function classifyOpdsAcquisitionRelation( + relations: readonly string[], +): OpdsAcquisitionRelation | undefined { + for (const relation of relations) { + const classification = RELATIONS[relation.toLowerCase()]; + if (classification) return classification; + } + return undefined; +} diff --git a/packages/core/src/opds/opds-types.ts b/packages/core/src/opds/opds-types.ts index d68280e7e..b8e75eb1d 100644 --- a/packages/core/src/opds/opds-types.ts +++ b/packages/core/src/opds/opds-types.ts @@ -1,4 +1,5 @@ import type { BookFormat } from "../types/book"; +import type { OpdsAcquisitionRelation } from "./opds-relations"; export interface OpdsLink { rel: string[]; @@ -9,6 +10,8 @@ export interface OpdsLink { export interface OpdsAcquisition extends OpdsLink { format: BookFormat | null; + /** Normalized semantics for parsed links. Optional for legacy callers constructing view models. */ + relation?: OpdsAcquisitionRelation; } export interface OpdsPublication { From e6fa68e8191b374c8cb5d7c941ceb2ced415a5ad Mon Sep 17 00:00:00 2001 From: Chai Date: Mon, 17 Aug 2026 05:37:37 -0400 Subject: [PATCH 36/38] fix(opds): queue covers and select catalog search --- .../screens/library/opds-cover-cache.test.ts | 3 +- packages/core/src/opds/opds-client.test.ts | 119 +++++++- packages/core/src/opds/opds-client.ts | 84 +++++- .../core/src/opds/opds-cover-cache.test.ts | 119 ++++++-- packages/core/src/opds/opds-cover-cache.ts | 256 ++++++++++++------ packages/core/src/opds/opds-parser.test.ts | 40 +++ packages/core/src/opds/opds-parser.ts | 43 ++- 7 files changed, 534 insertions(+), 130 deletions(-) diff --git a/packages/app-expo/src/screens/library/opds-cover-cache.test.ts b/packages/app-expo/src/screens/library/opds-cover-cache.test.ts index 9e5b0b317..9b1799ed8 100644 --- a/packages/app-expo/src/screens/library/opds-cover-cache.test.ts +++ b/packages/app-expo/src/screens/library/opds-cover-cache.test.ts @@ -122,10 +122,11 @@ describe("OPDS cover streaming and cache", () => { maxEntries: 4, maxBytes: 100, }); - void cache.acquire("pending"); + const pending = cache.acquire("pending"); cache.clear(); + await expect(pending).rejects.toThrow("cancelled"); expect(loaderSignal?.aborted).toBe(true); expect(cache.snapshot()).toMatchObject({ entries: 0, sourceBytes: 0, urls: [] }); }); diff --git a/packages/core/src/opds/opds-client.test.ts b/packages/core/src/opds/opds-client.test.ts index cceb3335a..ec23d11b4 100644 --- a/packages/core/src/opds/opds-client.test.ts +++ b/packages/core/src/opds/opds-client.test.ts @@ -22,6 +22,37 @@ const OPENSEARCH = ` `; +const GUTENBERG_OPENSEARCH = ` + + + Project Gutenberg + Gutenberg + Search the Project Gutenberg ebook catalog. + free ebooks books public domain + Marcello Perathoner + webmaster@gutenberg.org + + + + + + + + + + + + Search Data Copyright 1971-2012, Project Gutenberg, All Rights Reserved. + open + en-us + UTF-8 + UTF-8 +`; + const credentials: OpdsCredentials = { username: "reader", password: "secret-password", @@ -940,10 +971,9 @@ describe("OpdsClient assets", () => { describe("OpdsClient search", () => { it("upgrades Gutenberg-style public HTTP search templates advertised by HTTPS", async () => { - const gutenbergDescriptor = `Gutenberg`; const platform = fakePlatform((url) => { if (url === "https://www.gutenberg.org/catalog/osd-books.xml") { - return response(gutenbergDescriptor, { + return response(GUTENBERG_OPENSEARCH, { headers: { "Content-Type": "application/opensearchdescription+xml" }, }); } @@ -968,6 +998,73 @@ describe("OpdsClient search", () => { ]); }); + it("prefers OPDS JSON and Atom search URLs over generic XML", async () => { + const descriptor = ` + Ranked search + + + + `; + const opdsJson = JSON.stringify({ + metadata: { title: "JSON results" }, + links: [{ rel: "self", href: "https://catalog.test/json?q=books" }], + navigation: [{ title: "More", href: "https://catalog.test/more" }], + }); + const platform = fakePlatform((url) => { + if (url === "https://catalog.test/open-search.xml") { + return response(descriptor, { + headers: { "Content-Type": "application/opensearchdescription+xml" }, + }); + } + if (url === "https://catalog.test/json?q=books") { + return response(opdsJson, { headers: { "Content-Type": "application/opds+json" } }); + } + throw new Error(`Unexpected test URL: ${url}`); + }); + + const feed = await new OpdsClient(platform).search( + { kind: "openSearch", descriptorUrl: "https://catalog.test/open-search.xml" }, + "books", + ); + + expect(feed.title).toBe("JSON results"); + expect(platform.calls.map((call) => call.url)).toEqual([ + "https://catalog.test/open-search.xml", + "https://catalog.test/json?q=books", + ]); + }); + + it.each([ + [ + "HTML only", + ``, + ], + [ + "POST method", + ``, + ], + [ + "missing search terms", + ``, + ], + ])("rejects an OpenSearch descriptor with %s", async (_name, urlElement) => { + const descriptor = `Bad search${urlElement}`; + const platform = fakePlatform(() => + response(descriptor, { + headers: { "Content-Type": "application/opensearchdescription+xml" }, + }), + ); + + await expectOpdsError( + new OpdsClient(platform).search( + { kind: "openSearch", descriptorUrl: "https://catalog.test/open-search.xml" }, + "books", + ), + "invalid-catalog", + ); + expect(platform.calls).toHaveLength(1); + }); + it("does not upgrade or request a local HTTP search target advertised by HTTPS", async () => { const platform = fakePlatform(() => response(ATOM)); @@ -983,6 +1080,24 @@ describe("OpdsClient search", () => { ); expect(platform.calls).toHaveLength(0); }); + + it("does not upgrade a local HTTP URL selected from an HTTPS OpenSearch descriptor", async () => { + const descriptor = `Unsafe`; + const platform = fakePlatform(() => + response(descriptor, { + headers: { "Content-Type": "application/opensearchdescription+xml" }, + }), + ); + + await expectOpdsError( + new OpdsClient(platform).search( + { kind: "openSearch", descriptorUrl: "https://remote.test/open-search.xml" }, + "books", + ), + "insecure-url", + ); + expect(platform.calls.map((call) => call.url)).toEqual(["https://remote.test/open-search.xml"]); + }); it("fetches an advertised OPDS 1 OpenSearch descriptor and encodes the query", async () => { const platform = fakePlatform((url) => { if (url === "https://catalog.test/open-search.xml") { diff --git a/packages/core/src/opds/opds-client.ts b/packages/core/src/opds/opds-client.ts index 30b14beab..1e831a6ab 100644 --- a/packages/core/src/opds/opds-client.ts +++ b/packages/core/src/opds/opds-client.ts @@ -1,5 +1,5 @@ import { DOMParser } from "@xmldom/xmldom"; -import { getOpenSearch, getSearch } from "foliate-js/opds.js"; +import { getSearch } from "foliate-js/opds.js"; import type { FetchOptions, IPlatformService, PlatformFetchResponse } from "../services/platform"; import { parseOpdsDocument } from "./opds-parser"; import { classifyOpdsUrl } from "./opds-security"; @@ -547,7 +547,7 @@ function removeDoctypeAndEntityReferences(body: string): string { return withoutDoctype.replace(/&(?!(?:amp|lt|gt|quot|apos);)[A-Za-z_][\w.:-]*;/g, ""); } -function parseOpenSearch(body: string): SearchDocument { +async function parseOpenSearch(body: string): Promise { const errors: string[] = []; const document = new DOMParser({ errorHandler: { @@ -556,16 +556,78 @@ function parseOpenSearch(body: string): SearchDocument { fatalError: (message) => errors.push(message), }, }).parseFromString(removeDoctypeAndEntityReferences(body), "application/xml"); - if (errors.length > 0) throw new OpdsError("invalid-catalog"); - try { - const result = getOpenSearch(document as unknown as Document) as Partial; - if (typeof result.search !== "function" || !Array.isArray(result.params)) { - throw new OpdsError("invalid-catalog"); - } - return result as SearchDocument; - } catch { + const root = document.documentElement; + if ( + errors.length > 0 || + root.localName !== "OpenSearchDescription" || + root.namespaceURI !== "http://a9.com/-/spec/opensearch/1.1/" + ) { throw new OpdsError("invalid-catalog"); } + const children = Array.from(root.childNodes).filter( + (node): node is Element => node.nodeType === 1, + ); + const title = children + .find( + (element) => element.localName === "ShortName" && element.namespaceURI === root.namespaceURI, + ) + ?.textContent?.trim(); + const candidates = children + .filter((element) => element.localName === "Url" && element.namespaceURI === root.namespaceURI) + .flatMap((element, index) => { + const method = (element.getAttribute("method") ?? "").trim().toUpperCase() || "GET"; + const template = element.getAttribute("template")?.trim(); + const rawType = element.getAttribute("type") ?? ""; + const [rawMediaType = "", ...rawParameters] = rawType.split(";"); + const mediaType = rawMediaType.trim().toLowerCase(); + const parameters = new Map(); + for (const rawParameter of rawParameters) { + const separator = rawParameter.indexOf("="); + if (separator < 0) continue; + const name = rawParameter.slice(0, separator).trim().toLowerCase(); + const rawValue = rawParameter.slice(separator + 1).trim(); + const value = + (rawValue.startsWith('"') && rawValue.endsWith('"')) || + (rawValue.startsWith("'") && rawValue.endsWith("'")) + ? rawValue.slice(1, -1) + : rawValue; + parameters.set(name, value.trim().toLowerCase()); + } + const rank = + mediaType === "application/opds+json" + ? 4 + : mediaType === "application/atom+xml" && parameters.get("profile") === "opds-catalog" + ? 3 + : mediaType === "application/atom+xml" + ? 2 + : mediaType === "application/xml" || mediaType === "text/xml" + ? 1 + : 0; + return method === "GET" && template && rank > 0 + ? [{ index, rank, template, type: rawType }] + : []; + }) + .sort((left, right) => right.rank - left.rank || left.index - right.index); + + for (const candidate of candidates) { + try { + const result = (await getSearch({ + href: candidate.template, + title, + type: candidate.type, + })) as Partial; + if ( + typeof result.search === "function" && + Array.isArray(result.params) && + result.params.some((param) => param.name === "searchTerms" && !param.ns) + ) { + return result as SearchDocument; + } + } catch { + // Try the next advertised catalog representation. + } + } + throw new OpdsError("invalid-catalog"); } async function expandTemplate( @@ -727,7 +789,7 @@ export class OpdsClient { discardResponse(response); throw new OpdsError("invalid-catalog"); } - const search = parseOpenSearch(await readLimitedText(response, lifecycle)); + const search = await parseOpenSearch(await readLimitedText(response, lifecycle)); if (!search.params.some((param) => param.name === "searchTerms" && !param.ns)) { throw new OpdsError("invalid-catalog"); } diff --git a/packages/core/src/opds/opds-cover-cache.test.ts b/packages/core/src/opds/opds-cover-cache.test.ts index 5fd23f2b7..2fdd1072c 100644 --- a/packages/core/src/opds/opds-cover-cache.test.ts +++ b/packages/core/src/opds/opds-cover-cache.test.ts @@ -41,29 +41,30 @@ describe("shared OPDS cover cache", () => { expect(cache.snapshot()).toMatchObject({ entries: 1, sourceBytes: 4, urls: ["second"] }); }); - it("never admits beyond entry or byte bounds while every cached cover is leased", async () => { + it("loads a dense FIFO window as earlier leases release capacity", async () => { + const loaded: string[] = []; const cache = createOpdsCoverCache({ - load: async (url) => ({ uri: url, byteLength: 2 }), - maxEntries: 12, - maxBytes: 24, + load: async (url) => { + loaded.push(url); + return { uri: url, byteLength: 2 }; + }, + maxEntries: 2, + maxBytes: 4, maxLoadBytes: 2, }); - const leases = await Promise.all( - Array.from({ length: 12 }, (_, index) => cache.acquire(`cover-${index}`)), - ); - await expect(cache.acquire("cover-12")).rejects.toThrow("cover-cache-full"); - expect(cache.snapshot()).toMatchObject({ - entries: 12, - sourceBytes: 24, - liveEntries: 12, - liveBytes: 24, - reservedBytes: 0, - }); - leases[0]?.release(); - const replacement = await cache.acquire("cover-12"); - expect(cache.snapshot()).toMatchObject({ liveEntries: 12, liveBytes: 24 }); - replacement.release(); - for (const lease of leases) lease.release(); + const pending = Array.from({ length: 8 }, (_, index) => cache.acquire(`cover-${index}`)); + const held = await Promise.all(pending.slice(0, 2)); + expect(loaded).toEqual(["cover-0", "cover-1"]); + + for (let index = 2; index < pending.length; index += 1) { + held.shift()?.release(); + const next = await pending[index]; + held.push(next); + expect(cache.snapshot().liveBytes).toBeLessThanOrEqual(4); + } + + for (const lease of held) lease.release(); + expect(loaded).toEqual(Array.from({ length: 8 }, (_, index) => `cover-${index}`)); }); it("reserves the hard live-byte budget before starting distinct loads", async () => { @@ -82,7 +83,7 @@ describe("shared OPDS cover cache", () => { const first = cache.acquire("first"); const second = cache.acquire("second"); - await expect(cache.acquire("third")).rejects.toThrow("cover-cache-full"); + const third = cache.acquire("third"); expect(load).toHaveBeenCalledTimes(2); expect(cache.snapshot()).toMatchObject({ entries: 0, @@ -95,6 +96,82 @@ describe("shared OPDS cover cache", () => { const leases = await Promise.all([first, second]); expect(cache.snapshot()).toMatchObject({ liveEntries: 2, liveBytes: 8, reservedBytes: 0 }); for (const lease of leases) lease.release(); + const thirdLease = await third; + thirdLease.release(); + }); + + it("deduplicates a queued URL and gives both waiters leases when capacity frees", async () => { + const load = vi.fn(async (url: string) => ({ uri: url, byteLength: 2 })); + const cache = createOpdsCoverCache({ load, maxEntries: 1, maxBytes: 2, maxLoadBytes: 2 }); + const first = await cache.acquire("first"); + const secondA = cache.acquire("second"); + const secondB = cache.acquire("second"); + await Promise.resolve(); + expect(load).toHaveBeenCalledTimes(1); + + first.release(); + const [leaseA, leaseB] = await Promise.all([secondA, secondB]); + expect(load).toHaveBeenCalledTimes(2); + expect(leaseA.uri).toBe(leaseB.uri); + leaseA.release(); + leaseB.release(); + }); + + it("releases queue capacity after a load failure", async () => { + const load = vi.fn(async (url: string) => { + if (url === "bad") throw new Error("bad-cover"); + return { uri: url, byteLength: 1 }; + }); + const cache = createOpdsCoverCache({ load, maxEntries: 1, maxBytes: 1, maxLoadBytes: 1 }); + const bad = cache.acquire("bad"); + const good = cache.acquire("good"); + + await expect(bad).rejects.toThrow("bad-cover"); + const lease = await good; + expect(load.mock.calls.map(([url]) => url)).toEqual(["bad", "good"]); + lease.release(); + }); + + it("cancels queued and in-flight covers on clear without late repopulation", async () => { + let resolveFirst!: (value: { uri: string; byteLength: number }) => void; + const load = vi.fn(async (url: string) => + url === "first" + ? new Promise<{ uri: string; byteLength: number }>((resolve) => { + resolveFirst = resolve; + }) + : { uri: url, byteLength: 1 }, + ); + const cache = createOpdsCoverCache({ load, maxEntries: 1, maxBytes: 1, maxLoadBytes: 1 }); + const first = cache.acquire("first"); + const queued = cache.acquire("queued"); + await Promise.resolve(); + expect(load).toHaveBeenCalledTimes(1); + + cache.clear(); + resolveFirst({ uri: "late", byteLength: 1 }); + await expect(Promise.allSettled([first, queued])).resolves.toEqual([ + expect.objectContaining({ status: "rejected" }), + expect.objectContaining({ status: "rejected" }), + ]); + expect(cache.snapshot()).toMatchObject({ entries: 0, liveBytes: 0, queued: 0 }); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("keeps a cleared generation's leased bytes live until the lease releases", async () => { + const load = vi.fn(async (url: string) => ({ uri: url, byteLength: 1 })); + const cache = createOpdsCoverCache({ load, maxEntries: 1, maxBytes: 1, maxLoadBytes: 1 }); + const oldLease = await cache.acquire("old"); + + cache.clear(); + const nextLease = cache.acquire("next"); + await Promise.resolve(); + expect(load).toHaveBeenCalledTimes(1); + expect(cache.snapshot()).toMatchObject({ entries: 0, liveEntries: 1, liveBytes: 1 }); + + oldLease.release(); + const next = await nextLease; + expect(load).toHaveBeenCalledTimes(2); + next.release(); }); it("caps concurrent distinct loads", async () => { diff --git a/packages/core/src/opds/opds-cover-cache.ts b/packages/core/src/opds/opds-cover-cache.ts index cd289d1e4..70e90690b 100644 --- a/packages/core/src/opds/opds-cover-cache.ts +++ b/packages/core/src/opds/opds-cover-cache.ts @@ -99,11 +99,17 @@ interface CacheEntry extends OpdsCoverValue { lastUsed: number; } -interface InFlightEntry { +interface PendingEntry { + readonly url: string; + readonly generation: number; controller: AbortController; promise: Promise; + resolve(entry: CacheEntry): void; + reject(error: Error): void; waiters: number; + state: "queued" | "loading" | "resolved" | "rejected" | "cancelled"; releaseReservation(): void; + releaseActiveLoad(): void; } export function createOpdsCoverCache({ @@ -112,6 +118,7 @@ export function createOpdsCoverCache({ maxBytes, maxLoadBytes = maxBytes, maxConcurrentLoads = 4, + maxQueuedLoads = Math.max(1, maxEntries * 4), }: { load(url: string, signal: AbortSignal): Promise; maxEntries: number; @@ -119,69 +126,129 @@ export function createOpdsCoverCache({ /** Maximum bytes one loader can return; reserved before the transport starts. */ maxLoadBytes?: number; maxConcurrentLoads?: number; + /** Maximum number of distinct queued/loading covers. Duplicate URLs share one slot. */ + maxQueuedLoads?: number; }) { const entries = new Map(); - const inFlight = new Map(); + const retiredEntries = new Set(); + const pendingByUrl = new Map(); + const pendingQueue: PendingEntry[] = []; let sourceBytes = 0; + let retiredBytes = 0; let clock = 0; let generation = 0; let activeLoads = 0; let reservedBytes = 0; - const queuedLoads: Array<() => void> = []; - const runQueuedLoads = () => { - const limit = Math.max(1, maxConcurrentLoads); - while (activeLoads < limit) { - const start = queuedLoads.shift(); - if (!start) return; - activeLoads += 1; - start(); + const evictOldestReleased = () => { + const candidate = [...entries.entries()] + .filter(([url, entry]) => { + const pending = pendingByUrl.get(url); + return entry.references === 0 && !(pending?.state === "resolved" && pending.waiters > 0); + }) + .sort(([, left], [, right]) => left.lastUsed - right.lastUsed)[0]; + if (!candidate) return false; + entries.delete(candidate[0]); + sourceBytes -= candidate[1].byteLength; + return true; + }; + + const reserveCapacity = (pending: PendingEntry, bytes: number) => { + while ( + entries.size + retiredEntries.size + activeLoads >= maxEntries || + sourceBytes + retiredBytes + reservedBytes + bytes > maxBytes + ) { + if (!evictOldestReleased()) return false; } + reservedBytes += bytes; + let reservationActive = true; + pending.releaseReservation = () => { + if (!reservationActive) return; + reservationActive = false; + reservedBytes = Math.max(0, reservedBytes - bytes); + }; + return true; }; - const scheduleLoad = (url: string, signal: AbortSignal): Promise => - new Promise((resolve, reject) => { - queuedLoads.push(() => { - if (signal.aborted) { - activeLoads -= 1; - reject(new Error("cancelled")); - runQueuedLoads(); - return; - } - void load(url, signal) - .then(resolve, reject) - .finally(() => { - activeLoads -= 1; - runQueuedLoads(); - }); - }); - runQueuedLoads(); - }); + const removePending = (pending: PendingEntry) => { + if (pendingByUrl.get(pending.url) === pending) pendingByUrl.delete(pending.url); + }; - const evict = () => { - while (entries.size > maxEntries || sourceBytes > maxBytes) { - const candidate = [...entries.entries()] - .filter(([, entry]) => entry.references === 0) - .sort(([, left], [, right]) => left.lastUsed - right.lastUsed)[0]; - if (!candidate) return; - entries.delete(candidate[0]); - sourceBytes -= candidate[1].byteLength; + let drainQueue = () => {}; + + const cancelPending = (pending: PendingEntry, shouldDrain = true) => { + if ( + pending.state === "resolved" || + pending.state === "rejected" || + pending.state === "cancelled" + ) { + removePending(pending); + return; } + pending.state = "cancelled"; + pending.controller.abort(); + pending.releaseReservation(); + pending.releaseActiveLoad(); + pending.reject(new Error("cancelled")); + removePending(pending); + if (shouldDrain) drainQueue(); }; - const evictForReservation = (bytes: number) => { - while ( - entries.size + inFlight.size >= maxEntries || - sourceBytes + reservedBytes + bytes > maxBytes - ) { - const candidate = [...entries.entries()] - .filter(([, entry]) => entry.references === 0) - .sort(([, left], [, right]) => left.lastUsed - right.lastUsed)[0]; - if (!candidate) return false; - entries.delete(candidate[0]); - sourceBytes -= candidate[1].byteLength; + const finishPending = (pending: PendingEntry) => { + pending.releaseReservation(); + pending.releaseActiveLoad(); + if (pending.waiters === 0) { + removePending(pending); + drainQueue(); + } else if (pending.state !== "resolved") { + drainQueue(); + } + }; + + const startLoad = (pending: PendingEntry, reservation: number) => { + pending.state = "loading"; + activeLoads += 1; + let active = true; + pending.releaseActiveLoad = () => { + if (!active) return; + active = false; + activeLoads = Math.max(0, activeLoads - 1); + }; + let loaded: Promise; + try { + loaded = load(pending.url, pending.controller.signal); + } catch (error) { + loaded = Promise.reject(error); + } + void loaded + .then((value) => { + if (pending.state === "cancelled" || pending.generation !== generation) return; + if (value.byteLength > reservation) throw new Error("cover-too-large"); + const entry = { ...value, references: 0, lastUsed: ++clock }; + entries.set(pending.url, entry); + sourceBytes += value.byteLength; + pending.state = "resolved"; + pending.resolve(entry); + }) + .catch((error: unknown) => { + if (pending.state === "cancelled") return; + pending.state = "rejected"; + pending.reject(error instanceof Error ? error : new Error(String(error))); + }) + .finally(() => finishPending(pending)); + }; + + drainQueue = () => { + const concurrencyLimit = Math.max(1, maxConcurrentLoads); + const reservation = Math.min(maxLoadBytes, maxBytes); + while (activeLoads < concurrencyLimit) { + while (pendingQueue[0] && pendingQueue[0].state !== "queued") pendingQueue.shift(); + const pending = pendingQueue[0]; + if (!pending) return; + if (!reserveCapacity(pending, reservation)) return; + pendingQueue.shift(); + startLoad(pending, reservation); } - return true; }; const lease = (entry: CacheEntry): OpdsCoverLease => { @@ -195,7 +262,10 @@ export function createOpdsCoverCache({ released = true; entry.references = Math.max(0, entry.references - 1); entry.lastUsed = ++clock; - evict(); + if (entry.references === 0 && retiredEntries.delete(entry)) { + retiredBytes = Math.max(0, retiredBytes - entry.byteLength); + } + drainQueue(); }, }; }; @@ -207,45 +277,38 @@ export function createOpdsCoverCache({ const cached = entries.get(url); if (cached) return lease(cached); - let pending = inFlight.get(url); + let pending = pendingByUrl.get(url); if (!pending) { - const reservation = Math.min(maxLoadBytes, maxBytes); - if ( - maxEntries <= 0 || - maxLoadBytes <= 0 || - maxLoadBytes > maxBytes || - !evictForReservation(reservation) - ) { + if (maxEntries <= 0 || maxLoadBytes <= 0 || maxLoadBytes > maxBytes) { throw new Error("cover-cache-full"); } + if (pendingByUrl.size >= Math.max(1, maxQueuedLoads)) throw new Error("cover-cache-full"); const controller = new AbortController(); - reservedBytes += reservation; - let reservationActive = true; - const releaseReservation = () => { - if (!reservationActive) return; - reservationActive = false; - reservedBytes = Math.max(0, reservedBytes - reservation); + let resolvePending!: (entry: CacheEntry) => void; + let rejectPending!: (error: Error) => void; + const promise = new Promise((resolve, reject) => { + resolvePending = resolve; + rejectPending = reject; + }); + void promise.catch(() => {}); + pending = { + url, + generation, + controller, + promise, + resolve: resolvePending, + reject: rejectPending, + waiters: 0, + state: "queued", + releaseReservation: () => {}, + releaseActiveLoad: () => {}, }; - const loadGeneration = generation; - const promise = scheduleLoad(url, controller.signal) - .then((value): CacheEntry => { - if (loadGeneration !== generation) throw new Error("cancelled"); - if (value.byteLength > reservation) throw new Error("cover-too-large"); - const entry = { ...value, references: 0, lastUsed: ++clock }; - entries.set(url, entry); - sourceBytes += value.byteLength; - releaseReservation(); - return entry; - }) - .finally(() => { - releaseReservation(); - if (inFlight.get(url)?.promise === promise) inFlight.delete(url); - }); - pending = { controller, promise, waiters: 0, releaseReservation }; - inFlight.set(url, pending); + pendingByUrl.set(url, pending); + pendingQueue.push(pending); + drainQueue(); } pending.waiters += 1; - let settled = false; + let leased = false; let rejectCancelled: ((error: Error) => void) | undefined; const cancelled = new Promise((_resolve, reject) => { rejectCancelled = reject; @@ -254,22 +317,34 @@ export function createOpdsCoverCache({ signal?.addEventListener("abort", onAbort, { once: true }); try { const entry = await Promise.race([pending.promise, cancelled]); - settled = true; if (acquisitionGeneration !== generation) throw new Error("cancelled"); - return lease(entry); + const result = lease(entry); + leased = true; + return result; } finally { signal?.removeEventListener("abort", onAbort); pending.waiters = Math.max(0, pending.waiters - 1); - if (!settled && pending.waiters === 0) pending.controller.abort(); + if (pending.waiters === 0) { + if (!leased && (pending.state === "queued" || pending.state === "loading")) { + cancelPending(pending); + } else { + removePending(pending); + drainQueue(); + } + } } }, clear(): void { generation += 1; - for (const pending of inFlight.values()) { - pending.controller.abort(); - pending.releaseReservation(); + for (const pending of pendingByUrl.values()) cancelPending(pending, false); + pendingByUrl.clear(); + pendingQueue.length = 0; + for (const entry of entries.values()) { + if (entry.references > 0 && !retiredEntries.has(entry)) { + retiredEntries.add(entry); + retiredBytes += entry.byteLength; + } } - inFlight.clear(); entries.clear(); sourceBytes = 0; reservedBytes = 0; @@ -279,9 +354,10 @@ export function createOpdsCoverCache({ entries: entries.size, sourceBytes, urls: [...entries.keys()], - liveEntries: entries.size + inFlight.size, - liveBytes: sourceBytes + reservedBytes, + liveEntries: entries.size + retiredEntries.size + activeLoads, + liveBytes: sourceBytes + retiredBytes + reservedBytes, reservedBytes, + queued: pendingQueue.filter((pending) => pending.state === "queued").length, }; }, }; diff --git a/packages/core/src/opds/opds-parser.test.ts b/packages/core/src/opds/opds-parser.test.ts index 0469807e8..dbed1746f 100644 --- a/packages/core/src/opds/opds-parser.test.ts +++ b/packages/core/src/opds/opds-parser.test.ts @@ -538,6 +538,46 @@ describe("parseOpdsDocument", () => { ).toThrow("Invalid OPDS XML document"); }); + it.each([ + [ + "an unrelated namespace descendant", + `NewsStory`, + ], + [ + "a nested Atom link", + `NewsStory`, + ], + ])("rejects OPDS evidence from %s", (_name, body) => { + expect(() => + parseOpdsDocument(body, "application/atom+xml", "https://catalog.test/feed.xml"), + ).toThrow("Invalid OPDS XML document"); + }); + + it.each([ + [ + "navigation", + `Books`, + ], + [ + "search", + ``, + ], + [ + "facet", + ``, + ], + [ + "acquisition", + `Book`, + ], + ])("accepts a valid Atom OPDS %s feed", (_name, evidence) => { + const body = `Catalog${evidence}`; + + expect( + parseOpdsDocument(body, "application/atom+xml", "https://catalog.test/feed.xml").title, + ).toBe("Catalog"); + }); + it("preserves distinct Atom IDs when grouped entries share an acquisition URL", () => { const body = ` Grouped catalog diff --git a/packages/core/src/opds/opds-parser.ts b/packages/core/src/opds/opds-parser.ts index 3d8137729..1d0c5691b 100644 --- a/packages/core/src/opds/opds-parser.ts +++ b/packages/core/src/opds/opds-parser.ts @@ -488,6 +488,39 @@ function hasOnlyNamespaceLessAtomStructure(root: Element): boolean { ); } +function parseMediaType(value: string): { type: string; parameters: Map } { + const [rawType = "", ...rawParameters] = value.split(";"); + const parameters = new Map(); + for (const parameter of rawParameters) { + const separator = parameter.indexOf("="); + if (separator < 0) continue; + const name = parameter.slice(0, separator).trim().toLowerCase(); + const rawValue = parameter.slice(separator + 1).trim(); + const unquoted = + (rawValue.startsWith('"') && rawValue.endsWith('"')) || + (rawValue.startsWith("'") && rawValue.endsWith("'")) + ? rawValue.slice(1, -1) + : rawValue; + parameters.set(name, unquoted.trim().toLowerCase()); + } + return { type: rawType.trim().toLowerCase(), parameters }; +} + +function getDirectAtomLinks(root: Element): Element[] { + const namespace = root.namespaceURI || null; + const belongsToFeed = (element: Element, localName: string) => + element.localName === localName && (element.namespaceURI || null) === namespace; + const feedChildren = getElementChildren(root); + return [ + ...feedChildren.filter((child) => belongsToFeed(child, "link")), + ...feedChildren + .filter((child) => belongsToFeed(child, "entry")) + .flatMap((entry) => + getElementChildren(entry).filter((child) => belongsToFeed(child, "link")), + ), + ]; +} + function parseXml(body: string, documentUrl: string): OpdsFeed { const errors: string[] = []; const document = new DOMParser({ @@ -511,14 +544,14 @@ function parseXml(body: string, documentUrl: string): OpdsFeed { throw new Error("Invalid OPDS XML document"); } - const hasOpdsSemantics = Array.from(root.getElementsByTagName("link")).some((link) => { + const hasOpdsSemantics = getDirectAtomLinks(root).some((link) => { const rel = (link.getAttribute("rel") ?? "").trim().split(/\s+/).filter(Boolean); - const type = (link.getAttribute("type") ?? "").toLowerCase(); + const media = parseMediaType(link.getAttribute("type") ?? ""); return ( classifyOpdsAcquisitionRelation(rel) !== undefined || - (type.includes("application/atom+xml") && - /(?:^|;)\s*profile\s*=\s*["']?opds-catalog/i.test(type)) || - (rel.includes("search") && type.includes("application/opensearchdescription+xml")) || + (media.type === "application/atom+xml" && + media.parameters.get("profile") === "opds-catalog") || + (rel.includes("search") && media.type === "application/opensearchdescription+xml") || rel.includes("http://opds-spec.org/facet") ); }); From a560c0e4fe5cb5bebebf875580023ab3c248f311 Mon Sep 17 00:00:00 2001 From: Chai Date: Mon, 17 Aug 2026 05:49:53 -0400 Subject: [PATCH 37/38] fix(opds): validate Atom semantic links --- packages/core/src/opds/opds-parser.test.ts | 53 ++++++++++++++++++++++ packages/core/src/opds/opds-parser.ts | 35 +++++++++++--- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/packages/core/src/opds/opds-parser.test.ts b/packages/core/src/opds/opds-parser.test.ts index dbed1746f..13efa8882 100644 --- a/packages/core/src/opds/opds-parser.test.ts +++ b/packages/core/src/opds/opds-parser.test.ts @@ -553,6 +553,59 @@ describe("parseOpdsDocument", () => { ).toThrow("Invalid OPDS XML document"); }); + it.each([ + [ + "a search link without href", + ``, + ], + [ + "a navigation link without href", + ``, + ], + [ + "a search link with a blank href", + ``, + ], + [ + "a navigation link with an invalid href", + ``, + ], + [ + "a feed-level acquisition", + ``, + ], + [ + "an entry acquisition without href", + `Book`, + ], + [ + "an entry acquisition with an invalid href", + `Book`, + ], + ])("rejects an otherwise empty Atom feed with %s", (_name, evidence) => { + const body = `Generic feed${evidence}`; + + expect(() => + parseOpdsDocument(body, "application/atom+xml", "https://catalog.test/feed.xml"), + ).toThrow("Invalid OPDS XML document"); + }); + + it("accepts a direct feed navigation link with a valid href", () => { + const body = `Catalog`; + + expect( + parseOpdsDocument(body, "application/atom+xml", "https://catalog.test/feed.xml"), + ).toMatchObject({ title: "Catalog", nextUrl: "https://catalog.test/page-2.xml" }); + }); + + it("accepts an acquisition link owned by a direct entry", () => { + const body = `CatalogBook`; + + expect( + parseOpdsDocument(body, "application/atom+xml", "https://catalog.test/feed.xml").publications, + ).toHaveLength(1); + }); + it.each([ [ "navigation", diff --git a/packages/core/src/opds/opds-parser.ts b/packages/core/src/opds/opds-parser.ts index 1d0c5691b..1e28c7fea 100644 --- a/packages/core/src/opds/opds-parser.ts +++ b/packages/core/src/opds/opds-parser.ts @@ -506,21 +506,41 @@ function parseMediaType(value: string): { type: string; parameters: Map element.localName === localName && (element.namespaceURI || null) === namespace; const feedChildren = getElementChildren(root); return [ - ...feedChildren.filter((child) => belongsToFeed(child, "link")), + ...feedChildren + .filter((child) => belongsToFeed(child, "link")) + .map((element) => ({ element, owner: "feed" as const })), ...feedChildren .filter((child) => belongsToFeed(child, "entry")) .flatMap((entry) => - getElementChildren(entry).filter((child) => belongsToFeed(child, "link")), + getElementChildren(entry) + .filter((child) => belongsToFeed(child, "link")) + .map((element) => ({ element, owner: "entry" as const })), ), ]; } +function hasValidAtomHref(link: Element, documentUrl: string): boolean { + const href = link.getAttribute("href")?.trim(); + if (!href) return false; + try { + new URL(href, documentUrl); + return true; + } catch { + return false; + } +} + function parseXml(body: string, documentUrl: string): OpdsFeed { const errors: string[] = []; const document = new DOMParser({ @@ -544,11 +564,12 @@ function parseXml(body: string, documentUrl: string): OpdsFeed { throw new Error("Invalid OPDS XML document"); } - const hasOpdsSemantics = getDirectAtomLinks(root).some((link) => { - const rel = (link.getAttribute("rel") ?? "").trim().split(/\s+/).filter(Boolean); - const media = parseMediaType(link.getAttribute("type") ?? ""); + const hasOpdsSemantics = getDirectAtomLinks(root).some(({ element, owner }) => { + if (!hasValidAtomHref(element, documentUrl)) return false; + const rel = (element.getAttribute("rel") ?? "").trim().split(/\s+/).filter(Boolean); + const media = parseMediaType(element.getAttribute("type") ?? ""); return ( - classifyOpdsAcquisitionRelation(rel) !== undefined || + (owner === "entry" && classifyOpdsAcquisitionRelation(rel) !== undefined) || (media.type === "application/atom+xml" && media.parameters.get("profile") === "opds-catalog") || (rel.includes("search") && media.type === "application/opensearchdescription+xml") || From ba7aefbbf401d97e990b5a90862860c79e3c4dc5 Mon Sep 17 00:00:00 2001 From: Chai Date: Mon, 17 Aug 2026 11:03:26 -0400 Subject: [PATCH 38/38] fix(opds): store readable book descriptions --- .../src/screens/library/OpdsBrowserScreen.tsx | 17 ++--------------- packages/core/src/index.ts | 5 ++++- .../core/src/opds/opds-acquisition.test.ts | 8 ++++++++ packages/core/src/opds/opds-acquisition.ts | 6 +++++- packages/core/src/opds/opds-sanitize.ts | 18 ++++++++++++++++++ 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/app-expo/src/screens/library/OpdsBrowserScreen.tsx b/packages/app-expo/src/screens/library/OpdsBrowserScreen.tsx index caa61896f..a90059b55 100644 --- a/packages/app-expo/src/screens/library/OpdsBrowserScreen.tsx +++ b/packages/app-expo/src/screens/library/OpdsBrowserScreen.tsx @@ -20,7 +20,7 @@ import { type OpdsFeed, type OpdsPublication, listSupportedAcquisitions, - sanitizeOpdsDescription, + opdsDescriptionToPlainText, } from "@readany/core"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -75,20 +75,7 @@ const MAX_COVER_CACHE_BYTES = 8 * 1024 * 1024; const MAX_COVER_CACHE_ENTRIES = 12; function plainDescription(description: string | undefined): string | undefined { - if (!description) return undefined; - const sanitized = sanitizeOpdsDescription(description); - const text = sanitized - .replace(//gi, "\n") - .replace(/<\/(?:p|li|blockquote)>/gi, "\n") - .replace(/<[^>]+>/g, "") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'|'/g, "'") - .replace(/&/g, "&") - .replace(/\n{3,}/g, "\n\n") - .trim(); - return text || undefined; + return description ? opdsDescriptionToPlainText(description) : undefined; } function toErrorCode(error: unknown): OpdsErrorCode { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8b804fad5..d9e9978b1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,7 +102,10 @@ export { type OpdsCoverLease, type OpdsCoverValue, } from "./opds/opds-cover-cache"; -export { sanitizeOpdsDescription } from "./opds/opds-sanitize"; +export { + opdsDescriptionToPlainText, + sanitizeOpdsDescription, +} from "./opds/opds-sanitize"; export type { OpdsAcquisition, OpdsCredentials, diff --git a/packages/core/src/opds/opds-acquisition.test.ts b/packages/core/src/opds/opds-acquisition.test.ts index bb2ba7027..50f3dce8a 100644 --- a/packages/core/src/opds/opds-acquisition.test.ts +++ b/packages/core/src/opds/opds-acquisition.test.ts @@ -150,6 +150,14 @@ describe("toBookMeta", () => { expect(toBookMeta(input)).not.toHaveProperty("isbn"); }); + + it("stores an OPDS HTML description as readable plain text", () => { + const input = publication([]); + input.description = + "

A safe description.

Second & final.
Line.

"; + + expect(toBookMeta(input).description).toBe("A safe description.\nSecond & final.\nLine."); + }); }); describe("downloadOpdsAcquisition", () => { diff --git a/packages/core/src/opds/opds-acquisition.ts b/packages/core/src/opds/opds-acquisition.ts index bce1c97b4..ae9a566b6 100644 --- a/packages/core/src/opds/opds-acquisition.ts +++ b/packages/core/src/opds/opds-acquisition.ts @@ -3,6 +3,7 @@ import type { BookFormat, BookMeta } from "../types/book"; import { normalizeIsbn } from "../utils/book-metadata"; import { type OpdsAssetResponse, type OpdsClient, OpdsError } from "./opds-client"; import { classifyOpdsAcquisitionRelation } from "./opds-relations"; +import { opdsDescriptionToPlainText } from "./opds-sanitize"; import type { OpdsAcquisition, OpdsCredentials, OpdsPublication } from "./opds-types"; const FORMAT_BY_MEDIA_TYPE: Readonly> = { @@ -177,6 +178,9 @@ export function listSupportedAcquisitions( export function toBookMeta(publication: OpdsPublication): Partial { const isbn = normalizeIsbn(publication.identifier); + const description = publication.description + ? opdsDescriptionToPlainText(publication.description) + : undefined; return { title: publication.title, author: publication.authors.join(", "), @@ -184,7 +188,7 @@ export function toBookMeta(publication: OpdsPublication): Partial { ...(publication.language ? { language: publication.language } : {}), ...(isbn ? { isbn } : {}), ...(publication.published ? { publishDate: publication.published } : {}), - ...(publication.description ? { description: publication.description } : {}), + ...(description ? { description } : {}), ...(publication.subjects.length > 0 ? { subjects: [...publication.subjects] } : {}), }; } diff --git a/packages/core/src/opds/opds-sanitize.ts b/packages/core/src/opds/opds-sanitize.ts index 758f6dd79..3fe061abc 100644 --- a/packages/core/src/opds/opds-sanitize.ts +++ b/packages/core/src/opds/opds-sanitize.ts @@ -128,3 +128,21 @@ export function sanitizeOpdsDescription(input: string, documentUrl?: string): st while (allowedStack.length > 0) output.push(``); return output.join(""); } + +/** Converts an untrusted OPDS description into plain text for persisted book metadata. */ +export function opdsDescriptionToPlainText( + input: string, + documentUrl?: string, +): string | undefined { + const text = sanitizeOpdsDescription(input, documentUrl) + .replace(//gi, "\n") + .replace(/<\/(?:p|li|blockquote)>/gi, "\n") + .replace(/<[^>]+>/g, ""); + const normalized = decodeEntities(text) + .replace(/\u00a0/g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n[ \t]+/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return normalized || undefined; +}