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/lib/book/auto-metadata.ts b/packages/app-expo/src/lib/book/auto-metadata.ts index 61549549c..3401fd472 100644 --- a/packages/app-expo/src/lib/book/auto-metadata.ts +++ b/packages/app-expo/src/lib/book/auto-metadata.ts @@ -1,12 +1,21 @@ -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"; +import type { ExtractedMeta } from "./metadata-extractor"; -const MOBILE_DETAILS_METADATA_MAX_BYTES = 32 * 1024 * 1024; +export type MobileExtractedBookMetadata = ExtractedBookMetadata & + Pick; -export async function extractLocalBookMetadata(book: Book): Promise { - if (book.syncStatus === "remote" || book.format !== "epub" || !book.filePath) return null; +export async function extractLocalBookMetadata( + book: Book, +): Promise { + if (book.syncStatus === "remote" || !isRepairableFormat(book.format) || !book.filePath) { + return null; + } try { const platform = getPlatformService(); @@ -15,21 +24,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 +48,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/cover-storage.test.ts b/packages/app-expo/src/lib/book/cover-storage.test.ts new file mode 100644 index 000000000..bb7bbbe1b --- /dev/null +++ b/packages/app-expo/src/lib/book/cover-storage.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const platform = vi.hoisted(() => ({ + getAppDataDir: vi.fn(async () => "/app"), + joinPath: vi.fn(async (...parts: string[]) => parts.join("/")), + mkdir: vi.fn(async () => undefined), + writeFile: vi.fn(async () => undefined), + deleteFile: vi.fn(async () => undefined), +})); + +vi.mock("@readany/core/services", () => ({ getPlatformService: () => platform })); +import * as coverStorage from "./cover-storage"; + +describe("mobile cover file extensions", () => { + const getCoverFileExtension = ( + coverStorage as typeof coverStorage & { + getCoverFileExtension?: (bytes: Uint8Array, mimeType?: string | null) => string; + } + ).getCoverFileExtension; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps recognized image MIME types", () => { + expect(getCoverFileExtension).toBeTypeOf("function"); + if (!getCoverFileExtension) return; + + expect(getCoverFileExtension(new Uint8Array(), "image/webp")).toBe("webp"); + expect(getCoverFileExtension(new Uint8Array(), "image/gif")).toBe("gif"); + expect(getCoverFileExtension(new Uint8Array(), "image/png")).toBe("png"); + expect(getCoverFileExtension(new Uint8Array(), "image/jpeg")).toBe("jpg"); + }); + + it("sniffs image bytes when the MIME type is absent", () => { + expect(getCoverFileExtension).toBeTypeOf("function"); + if (!getCoverFileExtension) return; + + expect( + getCoverFileExtension( + new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]), + ), + ).toBe("webp"); + expect(getCoverFileExtension(new TextEncoder().encode("GIF89a"))).toBe("gif"); + expect( + getCoverFileExtension(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), + ).toBe("png"); + expect(getCoverFileExtension(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpg"); + }); + + it("deletes only a newly extracted cover when a custom cover wins during persistence", async () => { + const saveExtractedCoverIfStillMissing = ( + coverStorage as typeof coverStorage & { + saveExtractedCoverIfStillMissing?: ( + bookId: string, + bytes: Uint8Array, + mimeType: string | null, + getCurrentCoverUrl: () => string | undefined, + ) => Promise; + } + ).saveExtractedCoverIfStillMissing; + expect(saveExtractedCoverIfStillMissing).toBeTypeOf("function"); + if (!saveExtractedCoverIfStillMissing) return; + + let currentCoverUrl = ""; + platform.writeFile.mockImplementationOnce(async () => { + currentCoverUrl = "covers/book-custom-user.webp"; + }); + + await expect( + saveExtractedCoverIfStillMissing( + "book", + new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]), + null, + () => currentCoverUrl, + ), + ).resolves.toBeUndefined(); + expect(platform.deleteFile).toHaveBeenCalledWith("/app/covers/book.webp"); + expect(platform.deleteFile).not.toHaveBeenCalledWith("/app/covers/book-custom-user.webp"); + }); + + it("cleans an extracted cover when custom selection completes after persistence", async () => { + const commitCustomCover = ( + coverStorage as typeof coverStorage & { + commitCustomCover?: ( + bookId: string, + customCoverUrl: string, + persist: (coverUrl: string) => Promise, + ) => Promise; + } + ).commitCustomCover; + expect(commitCustomCover).toBeTypeOf("function"); + if (!commitCustomCover) return; + + await expect( + coverStorage.saveExtractedCoverIfStillMissing( + "book", + new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]), + null, + () => "", + ), + ).resolves.toBe("covers/book.webp"); + + const persisted: string[] = []; + await commitCustomCover("book", "covers/book-custom-user.png", async (coverUrl) => { + persisted.push(coverUrl); + }); + + expect(persisted).toEqual(["covers/book-custom-user.png"]); + expect(platform.deleteFile).toHaveBeenCalledWith("/app/covers/book.webp"); + expect(platform.deleteFile).not.toHaveBeenCalledWith("/app/covers/book-custom-user.png"); + }); +}); diff --git a/packages/app-expo/src/lib/book/cover-storage.ts b/packages/app-expo/src/lib/book/cover-storage.ts new file mode 100644 index 000000000..ded5a734f --- /dev/null +++ b/packages/app-expo/src/lib/book/cover-storage.ts @@ -0,0 +1,123 @@ +import { getPlatformService } from "@readany/core/services"; + +const extractedCoverPaths = new Map>(); + +export async function saveCoverBytesToAppData( + bookId: string, + coverBytes: Uint8Array, + coverMimeType?: string | null, +): Promise { + const platform = getPlatformService(); + const appData = await platform.getAppDataDir(); + const coversDir = await platform.joinPath(appData, "covers"); + try { + await platform.mkdir(coversDir); + } catch { + // Directory may already exist. + } + + const extension = getCoverFileExtension(coverBytes, coverMimeType); + const relativePath = `covers/${bookId}.${extension}`; + const absolutePath = await platform.joinPath(appData, relativePath); + await platform.writeFile(absolutePath, coverBytes); + return relativePath; +} + +export function getCoverFileExtension( + coverBytes: Uint8Array, + coverMimeType?: string | null, +): string { + switch (coverMimeType?.toLowerCase().split(";", 1)[0]?.trim()) { + case "image/webp": + return "webp"; + case "image/gif": + return "gif"; + case "image/png": + return "png"; + case "image/jpg": + case "image/jpeg": + return "jpg"; + } + + if (coverBytes[0] === 0xff && coverBytes[1] === 0xd8 && coverBytes[2] === 0xff) return "jpg"; + if ( + coverBytes[0] === 0x89 && + coverBytes[1] === 0x50 && + coverBytes[2] === 0x4e && + coverBytes[3] === 0x47 + ) { + return "png"; + } + if ( + coverBytes[0] === 0x47 && + coverBytes[1] === 0x49 && + coverBytes[2] === 0x46 && + coverBytes[3] === 0x38 + ) { + return "gif"; + } + if ( + coverBytes[0] === 0x52 && + coverBytes[1] === 0x49 && + coverBytes[2] === 0x46 && + coverBytes[3] === 0x46 && + coverBytes[8] === 0x57 && + coverBytes[9] === 0x45 && + coverBytes[10] === 0x42 && + coverBytes[11] === 0x50 + ) { + return "webp"; + } + return "jpg"; +} + +export async function saveExtractedCoverIfStillMissing( + bookId: string, + coverBytes: Uint8Array, + coverMimeType: string | null | undefined, + getCurrentCoverUrl: () => string | undefined, +): Promise { + if (getCurrentCoverUrl()?.trim()) return undefined; + + const relativePath = await saveCoverBytesToAppData(bookId, coverBytes, coverMimeType); + trackExtractedCover(bookId, relativePath); + if (!getCurrentCoverUrl()?.trim()) return relativePath; + + await deleteTrackedExtractedCover(bookId, relativePath); + return undefined; +} + +export async function commitCustomCover( + bookId: string, + customCoverUrl: string, + persist: (coverUrl: string) => Promise, +): Promise { + await persist(customCoverUrl); + const paths = extractedCoverPaths.get(bookId); + if (!paths) return; + + for (const relativePath of [...paths]) { + if (relativePath !== customCoverUrl) { + await deleteTrackedExtractedCover(bookId, relativePath); + } + } +} + +function trackExtractedCover(bookId: string, relativePath: string): void { + const paths = extractedCoverPaths.get(bookId) ?? new Set(); + paths.add(relativePath); + extractedCoverPaths.set(bookId, paths); +} + +async function deleteTrackedExtractedCover(bookId: string, relativePath: string): Promise { + try { + const platform = getPlatformService(); + const appData = await platform.getAppDataDir(); + await platform.deleteFile(await platform.joinPath(appData, relativePath)); + const paths = extractedCoverPaths.get(bookId); + paths?.delete(relativePath); + if (paths?.size === 0) extractedCoverPaths.delete(bookId); + } catch (error) { + console.warn("[BookMetadata] Failed to clean up rejected extracted cover:", error); + } +} 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..f3c5e8401 --- /dev/null +++ b/packages/app-expo/src/lib/book/imported-book-meta.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import * as importedBookMeta from "./imported-book-meta"; + +const { buildImportedBookMeta } = importedBookMeta; + +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", + }); + }); + + 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, + }); + }); + + it("restores saved publication values byte-for-byte while catalog metadata fills blanks", () => { + expect( + buildImportedBookMeta({ + existing: { + title: " Saved Mobile Title ", + author: "", + publisher: " Saved Mobile Press ", + language: "en-US", + isbn: " ISBN 978-1-4028-9462-6 ", + publishDate: " 2020-4-3 ", + description: " Saved mobile description ", + subjects: [" History ", "History"], + }, + opds: { author: " Catalog author ", language: "fr-FR" }, + embedded: { author: "Embedded author" }, + fallbackTitle: "filename", + }), + ).toMatchObject({ + title: " Saved Mobile Title ", + author: "Catalog author", + publisher: " Saved Mobile Press ", + language: "en-US", + isbn: " ISBN 978-1-4028-9462-6 ", + publishDate: " 2020-4-3 ", + description: " Saved mobile description ", + subjects: [" History ", "History"], + }); + }); + + it("skips embedded cover persistence when saved or OPDS metadata owns the cover", () => { + const shouldPersistEmbeddedCover = ( + importedBookMeta as typeof importedBookMeta & { + shouldPersistEmbeddedCover?: ( + existing?: { coverUrl?: string }, + imported?: { coverUrl?: string }, + ) => boolean; + } + ).shouldPersistEmbeddedCover; + expect(shouldPersistEmbeddedCover).toBeTypeOf("function"); + if (!shouldPersistEmbeddedCover) return; + + expect(shouldPersistEmbeddedCover({ coverUrl: "covers/saved.jpg" }, undefined)).toBe(false); + expect(shouldPersistEmbeddedCover(undefined, { coverUrl: "https://catalog/cover.jpg" })).toBe( + false, + ); + expect(shouldPersistEmbeddedCover({ coverUrl: " " }, { coverUrl: "" })).toBe(true); + }); +}); 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..7b1d6ea34 --- /dev/null +++ b/packages/app-expo/src/lib/book/imported-book-meta.ts @@ -0,0 +1,28 @@ +import type { BookMeta } from "@readany/core/types"; +import { mergeBookMetadataSources } from "@readany/core/utils"; +import type { ExtractedMeta } from "./metadata-extractor"; + +export function shouldPersistEmbeddedCover( + existing?: Partial, + imported?: Partial, +): boolean { + return !existing?.coverUrl?.trim() && !imported?.coverUrl?.trim(); +} + +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 { + ...input.existing, + ...merged, + title: merged.title || input.existing?.title || "Untitled", + author: merged.author || input.existing?.author || "", + }; +} 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..edc4166b9 --- /dev/null +++ b/packages/app-expo/src/lib/book/metadata-extractor.test.ts @@ -0,0 +1,294 @@ +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("/")), + mkdir: vi.fn(async () => undefined), + writeFile: vi.fn(async () => undefined), + 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", + coverBytes: expect.any(Uint8Array), + coverMimeType: "image/png", + }); + expect(platform.readFile).not.toHaveBeenCalled(); + expect(platform.writeFile).not.toHaveBeenCalled(); + }); + + it("returns extracted text and cover bytes without persisting during extraction", async () => { + const file = createLargeEpubFile(); + mobileFile.size = file.size; + mobileFile.read = file.read; + + await expect( + extractLocalBookMetadata({ + id: "cover-failure", + 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", + coverBytes: expect.any(Uint8Array), + }); + expect(platform.writeFile).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 coverBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const entries = [ + { name: "META-INF/container.xml", bytes: containerXml }, + { name: "content.opf", bytes: opfXml }, + { name: "cover.png", bytes: coverBytes }, + ]; + 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/lib/book/metadata-extractor.ts b/packages/app-expo/src/lib/book/metadata-extractor.ts index 360903b8a..8b8a86bbc 100644 --- a/packages/app-expo/src/lib/book/metadata-extractor.ts +++ b/packages/app-expo/src/lib/book/metadata-extractor.ts @@ -1,3 +1,4 @@ +import { normalizeIsbn } from "@readany/core/utils"; /** * Book metadata + cover extraction for React Native (Expo). * @@ -852,15 +853,9 @@ function extractOpfIsbn(opfXml: string): string { const identifierRegex = /<[^>]*identifier\b([^>]*)>([^<]*)<\/[^>]*identifier>/gi; let match = identifierRegex.exec(opfXml); while (match !== null) { - const attrs = match[1] || ""; const value = (match[2] || "").trim(); - const scheme = getAttr(attrs, "opf:scheme") || getAttr(attrs, "scheme"); - if ( - scheme.toLowerCase() === "isbn" || - /(?:97[89][-\s]?)?(?:\d[-\s]?){9,12}[\dXx]/.test(value) - ) { - return value; - } + const isbn = normalizeIsbn(value); + if (isbn) return isbn; match = identifierRegex.exec(opfXml); } return ""; diff --git a/packages/app-expo/src/lib/platform/expo-platform-service.test.ts b/packages/app-expo/src/lib/platform/expo-platform-service.test.ts new file mode 100644 index 000000000..488228612 --- /dev/null +++ b/packages/app-expo/src/lib/platform/expo-platform-service.test.ts @@ -0,0 +1,329 @@ +import { OpdsClient, type OpdsCredentials } from "@readany/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { expoFetch, secureDelete, secureGet, secureSet } = vi.hoisted(() => ({ + expoFetch: vi.fn(), + secureDelete: vi.fn(), + secureGet: vi.fn(), + secureSet: vi.fn(), +})); + +vi.mock("expo/fetch", () => ({ fetch: expoFetch })); +vi.mock("@readany/core/i18n", () => ({ default: { t: (key: string) => key } })); +vi.mock("expo-clipboard", () => ({})); +vi.mock("expo-constants", () => ({ default: {} })); +vi.mock("expo-document-picker", () => ({})); +vi.mock("expo-file-system", () => ({ + Directory: class {}, + File: class {}, + Paths: { document: { uri: "file:///test" } }, +})); +vi.mock("expo-file-system/legacy", () => ({})); +vi.mock("expo-network", () => ({})); +vi.mock("expo-secure-store", () => ({ + deleteItemAsync: secureDelete, + getItemAsync: secureGet, + setItemAsync: secureSet, +})); +vi.mock("expo-sharing", () => ({})); + +import { ExpoPlatformService } from "./expo-platform-service"; + +const ATOM = ` + + Catalog + Book +`; + +const credentials: OpdsCredentials = { + username: "reader", + password: "secret-password", + catalogOrigin: "https://catalog.test", +}; + +function header(init: RequestInit | undefined, name: string): string | null { + return new Headers(init?.headers).get(name); +} + +function signalForCall(index = 0): AbortSignal { + const signal = (expoFetch.mock.calls[index]?.[1] as RequestInit | undefined)?.signal; + if (!signal) throw new Error(`Missing signal for Expo fetch call ${index}`); + return signal; +} + +describe("ExpoPlatformService standards fetch contract", () => { + beforeEach(() => { + expoFetch.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses expo/fetch for manual requests and preserves request and response web APIs", async () => { + const controller = new AbortController(); + const redirect = new Response(null, { + status: 302, + headers: { + Location: "https://other.test/feed.xml", + "Content-Type": "application/atom+xml", + "WWW-Authenticate": 'Basic realm="Books"', + }, + }); + expoFetch.mockResolvedValue(redirect); + const requestHeaders = new Headers({ + Accept: "application/atom+xml", + Authorization: "Basic test-token", + }); + + const result = await new ExpoPlatformService().fetch("https://catalog.test/feed.xml", { + headers: requestHeaders, + redirect: "manual", + signal: controller.signal, + responseType: "text", + timeoutMs: 15_000, + }); + + expect(expoFetch).toHaveBeenCalledTimes(1); + const [url, init] = expoFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://catalog.test/feed.xml"); + expect(init.redirect).toBe("manual"); + expect(init.signal).not.toBe(controller.signal); + expect(init.signal?.aborted).toBe(false); + expect(header(init, "Accept")).toBe("application/atom+xml"); + expect(header(init, "Authorization")).toBe("Basic test-token"); + expect(result.status).toBe(302); + expect(result.headers.get("Location")).toBe("https://other.test/feed.xml"); + expect(result.headers.get("Content-Type")).toBe("application/atom+xml"); + expect(result.headers.get("WWW-Authenticate")).toBe('Basic realm="Books"'); + }); + + it("lets core inspect each Expo redirect and strips auth across origins", async () => { + expoFetch.mockImplementation(async (url: string, init: RequestInit) => { + if (url === "https://catalog.test/feed.xml") { + return new Response(null, { + status: 302, + headers: { Location: "https://cdn.test/feed.xml" }, + }); + } + expect(signalForCall(0).aborted).toBe(true); + expect(init.signal).not.toBe(signalForCall(0)); + expect(init.signal?.aborted).toBe(false); + return new Response(ATOM, { + headers: { "Content-Type": "application/atom+xml" }, + }); + }); + + await new OpdsClient(new ExpoPlatformService()).open( + "https://catalog.test/feed.xml", + credentials, + ); + + expect(expoFetch).toHaveBeenCalledTimes(2); + expect(header(expoFetch.mock.calls[0]?.[1], "Authorization")).not.toBeNull(); + expect(header(expoFetch.mock.calls[1]?.[1], "Authorization")).toBeNull(); + expect(expoFetch.mock.calls.every(([, init]) => init.redirect === "manual")).toBe(true); + expect(signalForCall(0).aborted).toBe(true); + expect(signalForCall(1).aborted).toBe(false); + }); + + it("aborts the Expo transport when Content-Length rejects a catalog before reading", async () => { + expoFetch.mockResolvedValue( + new Response(new ReadableStream(), { + headers: { + "Content-Type": "application/atom+xml", + "Content-Length": "5242881", + }, + }), + ); + + await expect( + new OpdsClient(new ExpoPlatformService()).open("https://catalog.test/feed.xml"), + ).rejects.toMatchObject({ code: "too-large" }); + + expect(signalForCall().aborted).toBe(true); + }); + + it("lets core reject HTTPS downgrades and public HTTP targets before Expo follows", async () => { + expoFetch.mockResolvedValue( + new Response(null, { + status: 302, + headers: { Location: "http://catalog.example/feed.xml" }, + }), + ); + + await expect( + new OpdsClient(new ExpoPlatformService()).open("https://catalog.test/feed.xml", credentials), + ).rejects.toMatchObject({ code: "insecure-url" }); + expect(expoFetch).toHaveBeenCalledTimes(1); + }); + + it("exposes authentication challenges to core", async () => { + expoFetch.mockResolvedValue( + new Response(null, { + status: 401, + headers: { "WWW-Authenticate": 'Digest realm="Books"' }, + }), + ); + + await expect( + new OpdsClient(new ExpoPlatformService()).open("https://catalog.test/feed.xml"), + ).rejects.toMatchObject({ code: "unsupported-auth" }); + expect(signalForCall().aborted).toBe(true); + }); + + it("forwards AbortSignal to expo/fetch", async () => { + expoFetch.mockImplementation( + async (_url: string, init: RequestInit) => + new Promise((_resolve, reject) => { + if (init.signal?.aborted) { + reject(new Error("aborted")); + return; + } + init.signal?.addEventListener("abort", () => reject(new Error("aborted")), { + once: true, + }); + }), + ); + const controller = new AbortController(); + const request = new ExpoPlatformService().fetch("https://catalog.test/feed.xml", { + redirect: "manual", + signal: controller.signal, + responseType: "text", + }); + + controller.abort(); + + await expect(request).rejects.toThrow("aborted"); + }); + + it("preserves streaming so core cancels an oversized Expo response before full buffering", async () => { + let pulls = 0; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + pulls += 1; + controller.enqueue(new Uint8Array(3 * 1024 * 1024)); + }, + cancel() { + cancelled = true; + }, + }); + expoFetch.mockResolvedValue( + new Response(body, { headers: { "Content-Type": "application/atom+xml" } }), + ); + + await expect( + new OpdsClient(new ExpoPlatformService()).open("https://catalog.test/feed.xml"), + ).rejects.toMatchObject({ code: "too-large" }); + expect(pulls).toBeLessThanOrEqual(3); + expect(cancelled).toBe(true); + expect(signalForCall().aborted).toBe(true); + }); + + it("aborts the Expo transport when streaming the response body fails", async () => { + expoFetch.mockResolvedValue( + new Response( + new ReadableStream({ + pull(controller) { + controller.error(new Error("native stream failed")); + }, + }), + { headers: { "Content-Type": "application/atom+xml" } }, + ), + ); + + await expect( + new OpdsClient(new ExpoPlatformService()).open("https://catalog.test/feed.xml"), + ).rejects.toMatchObject({ code: "unreachable" }); + + expect(signalForCall().aborted).toBe(true); + }); + + it("aborts the Expo transport when a returned asset body is cancelled", async () => { + expoFetch.mockResolvedValue( + new Response(new ReadableStream(), { + headers: { "Content-Type": "application/epub+zip" }, + }), + ); + const asset = await new OpdsClient(new ExpoPlatformService()).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + ); + + await asset.body?.cancel(); + + expect(signalForCall().aborted).toBe(true); + }); + + it("keeps explicit asset cancellation stable for future consumers", async () => { + expoFetch.mockResolvedValue( + new Response(new ReadableStream(), { + headers: { "Content-Type": "application/epub+zip" }, + }), + ); + const asset = await new OpdsClient(new ExpoPlatformService()).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + ); + + await Promise.all([asset.cancel(), asset.cancel()]); + + expect(signalForCall().aborted).toBe(true); + await expect(asset.arrayBuffer()).rejects.toMatchObject({ code: "cancelled" }); + }); + + it("reads exact asset bytes when React Native Response cannot wrap a stream", async () => { + const bytes = Uint8Array.of(1, 0, 255, 127, 64); + const source = new Response(bytes, { + headers: { "Content-Type": "application/octet-stream" }, + }); + Object.defineProperties(source, { + url: { value: "https://catalog.test/final-book.epub" }, + redirected: { value: true }, + }); + expoFetch.mockResolvedValue(source); + vi.stubGlobal( + "Response", + class NonStreamingWhatwgResponse { + constructor() { + throw new Error("React Native whatwg Response does not accept ReadableStream"); + } + }, + ); + + const asset = await new OpdsClient(new ExpoPlatformService()).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + ); + + expect(Array.from(new Uint8Array(await asset.arrayBuffer()))).toEqual(Array.from(bytes)); + expect(asset.url).toBe("https://catalog.test/final-book.epub"); + expect(asset.redirected).toBe(true); + expect(signalForCall().aborted).toBe(false); + }); +}); + +describe("ExpoPlatformService secret contract", () => { + beforeEach(() => { + secureDelete.mockReset(); + secureGet.mockReset(); + secureSet.mockReset(); + }); + + it("delegates secrets directly to SecureStore without the general KV index", async () => { + secureGet.mockResolvedValue("stored-password"); + const service = new ExpoPlatformService(); + + await expect(service.secretGetItem("opds.catalog.one.password")).resolves.toBe( + "stored-password", + ); + await service.secretSetItem("opds.catalog.one.password", "new-password"); + await service.secretRemoveItem("opds.catalog.one.password"); + + expect(secureGet).toHaveBeenCalledWith("opds.catalog.one.password"); + expect(secureSet).toHaveBeenCalledWith("opds.catalog.one.password", "new-password"); + expect(secureDelete).toHaveBeenCalledWith("opds.catalog.one.password"); + expect(secureSet).not.toHaveBeenCalledWith("__readany_kv_keys__", expect.anything()); + }); +}); diff --git a/packages/app-expo/src/lib/platform/expo-platform-service.ts b/packages/app-expo/src/lib/platform/expo-platform-service.ts index 6b20d9daa..2255f7f14 100644 --- a/packages/app-expo/src/lib/platform/expo-platform-service.ts +++ b/packages/app-expo/src/lib/platform/expo-platform-service.ts @@ -17,6 +17,7 @@ import type { IDatabase, IPlatformService, IWebSocket, + PlatformFetchResponse, WebSocketOptions, } from "@readany/core/services"; import * as Clipboard from "expo-clipboard"; @@ -27,6 +28,7 @@ import * as LegacyFS from "expo-file-system/legacy"; import * as Network from "expo-network"; import * as SecureStore from "expo-secure-store"; import * as Sharing from "expo-sharing"; +import type { FetchRequestInit } from "expo/fetch"; /** Simple KV storage keys tracking (SecureStore doesn't have getAllKeys) */ const KV_KEYS_INDEX = "__readany_kv_keys__"; @@ -281,10 +283,55 @@ export class ExpoPlatformService implements IPlatformService { // ---- Network ---- - async fetch(url: string, options?: FetchOptions): Promise { + async fetch(url: string, options?: FetchOptions): Promise { const { allowInsecure, timeoutMs, responseType, onDownloadProgress, ...fetchOptions } = options ?? {}; const effectiveUrl = allowInsecure ? url.replace(/^https:\/\//i, "http://") : url; + if (fetchOptions.redirect === "manual") { + const { fetch: expoFetch } = await import("expo/fetch"); + const transportController = new AbortController(); + const sourceSignal = fetchOptions.signal; + const onSourceAbort = () => transportController.abort(sourceSignal?.reason); + if (sourceSignal?.aborted) { + onSourceAbort(); + } else { + sourceSignal?.addEventListener("abort", onSourceAbort, { once: true }); + } + let disposed = false; + const onDispose = () => { + if (disposed) return; + disposed = true; + sourceSignal?.removeEventListener("abort", onSourceAbort); + }; + const cancelTransport = () => { + transportController.abort(); + onDispose(); + }; + const expoOptions: FetchRequestInit = { + body: fetchOptions.body ?? undefined, + credentials: fetchOptions.credentials, + headers: fetchOptions.headers, + integrity: fetchOptions.integrity, + keepalive: fetchOptions.keepalive, + method: fetchOptions.method, + mode: fetchOptions.mode, + redirect: fetchOptions.redirect, + referrer: fetchOptions.referrer, + signal: transportController.signal, + window: fetchOptions.window, + }; + try { + const response = (await expoFetch(effectiveUrl, expoOptions)) as PlatformFetchResponse; + Object.defineProperties(response, { + cancelTransport: { value: cancelTransport }, + onDispose: { value: onDispose }, + }); + return response; + } catch (error) { + onDispose(); + throw error; + } + } const method = fetchOptions?.method?.toUpperCase() || "GET"; // Always use XHR for WebDAV to handle large binary files properly @@ -643,6 +690,20 @@ export class ExpoPlatformService implements IPlatformService { return 0; } + // ---- Secret Storage (direct Expo SecureStore boundary; not indexed as general KV) ---- + + async secretGetItem(key: string): Promise { + return SecureStore.getItemAsync(key); + } + + async secretSetItem(key: string, value: string): Promise { + await SecureStore.setItemAsync(key, value); + } + + async secretRemoveItem(key: string): Promise { + await SecureStore.deleteItemAsync(key); + } + // ---- KV Storage (backed by expo-secure-store) ---- private async _getKeysIndex(): Promise { diff --git a/packages/app-expo/src/navigation/RootNavigator.tsx b/packages/app-expo/src/navigation/RootNavigator.tsx index d720f534b..9d448a440 100644 --- a/packages/app-expo/src/navigation/RootNavigator.tsx +++ b/packages/app-expo/src/navigation/RootNavigator.tsx @@ -7,6 +7,8 @@ import { FullScreenNotesScreen } from "@/screens/FullScreenNotesScreen"; import { ReaderScreen } from "@/screens/ReaderScreen"; import SkillsScreen from "@/screens/SkillsScreen"; import StatsScreen from "@/screens/StatsScreen"; +import { OpdsBrowserScreen } from "@/screens/library/OpdsBrowserScreen"; +import { OpdsCatalogsScreen } from "@/screens/library/OpdsCatalogsScreen"; import { WebDavImportBrowserScreen } from "@/screens/library/WebDavImportBrowserScreen"; import AISettingsScreen from "@/screens/settings/AISettingsScreen"; import AboutScreen from "@/screens/settings/AboutScreen"; @@ -47,6 +49,8 @@ export type RootStackParamList = { FullScreenNotes: { bookId: string }; FontSettings: undefined; WebDavImportBrowser: { source: WebDavImportSource }; + OpdsCatalogs: { editCatalogId?: string } | undefined; + OpdsBrowser: { catalogId: string }; }; const Stack = createNativeStackNavigator(); @@ -123,6 +127,16 @@ export function RootNavigator() { component={WebDavImportBrowserScreen} options={{ animation: "slide_from_right" }} /> + + (null); const autoFilledBookIdRef = useRef(null); const latestValuesRef = useRef(null); + const commitValues = useCallback( + ( + update: + | BookMetadataFormValues + | ((current: BookMetadataFormValues) => BookMetadataFormValues), + ) => applyBookMetadataFormUpdate(latestValuesRef, setValues, update), + [], + ); useEffect(() => { void loadBooks(); @@ -305,12 +315,9 @@ export function BookDetailsScreen({ route }: Props) { if (!book) return; if (hydratedBookIdRef.current === book.id) return; hydratedBookIdRef.current = book.id; - setValues(createBookMetadataFormValues(book)); - }, [book]); - - useEffect(() => { - latestValuesRef.current = values; - }, [values]); + const nextValues = createBookMetadataFormValues(book); + commitValues(nextValues); + }, [book, commitValues]); useEffect(() => { if (!book || !values) return; @@ -319,20 +326,35 @@ export function BookDetailsScreen({ route }: Props) { autoFilledBookIdRef.current = book.id; let cancelled = false; - void extractLocalBookMetadata(book).then((metadata) => { + void extractLocalBookMetadata(book).then(async (metadata) => { if (cancelled || !metadata) return; + let extracted = metadata; + if (metadata.coverBytes?.length) { + try { + const coverUrl = await saveExtractedCoverIfStillMissing( + book.id, + metadata.coverBytes, + metadata.coverMimeType, + () => (cancelled ? "__cancelled__" : latestValuesRef.current?.coverUrl), + ); + if (coverUrl) extracted = { ...metadata, coverUrl }; + } catch (error) { + console.warn("[BookMetadata] Failed to persist extracted mobile cover:", error); + } + } + if (cancelled) return; const nextValues = latestValuesRef.current - ? mergeMissingBookMetadataValues(latestValuesRef.current, metadata) + ? mergeMissingBookMetadataValues(latestValuesRef.current, extracted) : null; if (!nextValues) return; - setValues(nextValues); + commitValues(nextValues); updateBook(book.id, buildBookMetadataUpdate(book, nextValues)); }); return () => { cancelled = true; }; - }, [book, updateBook, values]); + }, [book, commitValues, updateBook, values]); useEffect(() => { const raw = values?.coverUrl; @@ -362,73 +384,75 @@ export function BookDetailsScreen({ route }: Props) { const setField = useCallback( (field: K, value: BookMetadataFormValues[K]) => { - setValues((current) => { - if (!current) return current; - const next = { ...current, [field]: value }; - if (book) updateBook(book.id, buildBookMetadataUpdate(book, next)); - return next; - }); + const next = commitValues((current) => ({ ...current, [field]: value })); + if (book && next) updateBook(book.id, buildBookMetadataUpdate(book, next)); }, - [book, updateBook], + [book, commitValues, updateBook], ); const persistCoverUrl = useCallback( async (coverUrl: string) => { - if (!book || !values) return; - const nextValues = { ...values, coverUrl }; - setValues(nextValues); + if (!book) return; + const nextValues = commitValues((current) => ({ ...current, coverUrl })); + if (!nextValues) return; await updateBook(book.id, buildBookMetadataUpdate(book, nextValues)); }, - [book, updateBook, values], + [book, commitValues, updateBook], ); const setRating = useCallback( (rating: number) => { - if (!book || !values) return; - const next = { ...values, rating: values.rating === rating ? null : rating }; - setValues(next); + if (!book) return; + const next = commitValues((current) => ({ + ...current, + rating: current.rating === rating ? null : rating, + })); + if (!next) return; updateBook(book.id, buildBookMetadataUpdate(book, next)); }, - [book, updateBook, values], + [book, commitValues, updateBook], ); const addReview = useCallback( (content: string) => { - if (!book || !values) return; + if (!book) return; const review = { ...createEmptyBookReview(), content }; - const next = { ...values, reviews: [...values.reviews, review] }; - setValues(next); + const next = commitValues((current) => ({ + ...current, + reviews: [...current.reviews, review], + })); + if (!next) return; updateBook(book.id, buildBookMetadataUpdate(book, next)); }, - [book, updateBook, values], + [book, commitValues, updateBook], ); const updateReview = useCallback( (reviewId: string, content: string) => { - if (!book || !values) return; - const next = { - ...values, - reviews: values.reviews.map((review) => + if (!book) return; + const next = commitValues((current) => ({ + ...current, + reviews: current.reviews.map((review) => review.id === reviewId ? { ...review, content } : review, ), - }; - setValues(next); + })); + if (!next) return; updateBook(book.id, buildBookMetadataUpdate(book, next)); }, - [book, updateBook, values], + [book, commitValues, updateBook], ); const removeReview = useCallback( (reviewId: string) => { - if (!book || !values) return; - const next = { - ...values, - reviews: values.reviews.filter((review) => review.id !== reviewId), - }; - setValues(next); + if (!book) return; + const next = commitValues((current) => ({ + ...current, + reviews: current.reviews.filter((review) => review.id !== reviewId), + })); + if (!next) return; updateBook(book.id, buildBookMetadataUpdate(book, next)); }, - [book, updateBook, values], + [book, commitValues, updateBook], ); const handleTextEditorDone = useCallback( @@ -478,7 +502,7 @@ export function BookDetailsScreen({ route }: Props) { const targetPath = await platform.joinPath(appData, relativePath); const bytes = await platform.readFile(selected.uri); await platform.writeFile(targetPath, bytes); - await persistCoverUrl(relativePath); + await commitCustomCover(book.id, relativePath, persistCoverUrl); Alert.alert(t("common.success", "成功"), t("library.detailsCoverSaved", "封面已保存")); } catch (error) { console.warn("[BookDetailsScreen] Failed to change cover:", error); diff --git a/packages/app-expo/src/screens/LibraryScreen.tsx b/packages/app-expo/src/screens/LibraryScreen.tsx index d5f3a1fb6..6578c1149 100644 --- a/packages/app-expo/src/screens/LibraryScreen.tsx +++ b/packages/app-expo/src/screens/LibraryScreen.tsx @@ -537,6 +537,11 @@ export function LibraryScreen() { setTemporaryWebDavOpen(true); }, []); + const handleOpenOpdsCatalogs = useCallback(() => { + setSourceSheetOpen(false); + nav.navigate("OpdsCatalogs"); + }, [nav]); + const handleConnectTemporaryWebDav = useCallback( async (source: WebDavImportSource) => { const { WebDavImportService } = await import("@readany/core"); @@ -1166,6 +1171,7 @@ export function LibraryScreen() { onPickLocal={handlePickLocalFromSourceMenu} onPickSavedWebDav={() => void handleOpenSavedWebDav()} onPickTemporaryWebDav={handleOpenTemporaryWebDav} + onPickOpds={handleOpenOpdsCatalogs} /> ; + +interface BrowserOperation { + key: string; + mode: OpdsLoadMode; + execute(credentials: OpdsCredentials | undefined, signal: AbortSignal): Promise; +} + +interface FormatChoice { + publication: OpdsPublication; + acquisitions: ReturnType; +} + +const MAX_COVER_BYTES = 4 * 1024 * 1024; +const MAX_COVER_CACHE_BYTES = 8 * 1024 * 1024; +const MAX_COVER_CACHE_ENTRIES = 12; + +function plainDescription(description: string | undefined): string | undefined { + return description ? opdsDescriptionToPlainText(description) : undefined; +} + +function toErrorCode(error: unknown): OpdsErrorCode { + return error instanceof OpdsError ? error.code : "unreachable"; +} + +function getContentSnapshot(state: ReturnType) { + if (state.content.status === "ready") return state.content; + if (state.content.status === "loading" || state.content.status === "error") { + return state.content.previous; + } + return undefined; +} + +function AuthenticatedCover({ + publication, + cache, + style, +}: { + publication: OpdsPublication; + cache: ReturnType; + style: object; +}) { + const [uri, setUri] = useState(); + const imageUrl = publication.images[0]?.url; + + useEffect(() => { + setUri(undefined); + if (!imageUrl) return; + const controller = new AbortController(); + let release: (() => void) | undefined; + void cache + .acquire(imageUrl, controller.signal) + .then((lease) => { + release = lease.release; + if (!controller.signal.aborted) setUri(lease.uri); + else lease.release(); + }) + .catch(() => {}); + return () => { + controller.abort(); + release?.(); + }; + }, [cache, imageUrl]); + + return uri ? : null; +} + +export function OpdsBrowserScreen({ navigation, route }: Props) { + const { t } = useTranslation(); + const colors = useColors(); + const layout = useResponsiveLayout(); + const insets = useSafeAreaInsets(); + const catalogId = route.params.catalogId; + const store = useMemo(() => opdsMobileRuntime.getCatalogStore(), []); + const client = useMemo(() => opdsMobileRuntime.getClient(), []); + const [state, dispatch] = useReducer(opdsViewReducer, undefined, createInitialOpdsViewState); + const [catalogName, setCatalogName] = useState(""); + const [catalogUrl, setCatalogUrl] = useState(""); + const [query, setQuery] = useState(""); + const [expandedPublication, setExpandedPublication] = useState(); + 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); + const stateRef = useRef(state); + stateRef.current = state; + const operations = useRef(new Map()); + const lastOperation = useRef(undefined); + const lastDownload = useRef< + { publication: OpdsPublication; acquisition: OpdsAcquisition } | undefined + >(undefined); + const { download } = useOpdsDownload(); + const feed = selectOpdsFeed(state); + + const executeOperation = useCallback( + async (operation: BrowserOperation, requestId: number) => { + requestController.current?.abort(); + const controller = new AbortController(); + requestController.current = controller; + lastOperation.current = operation; + operations.current.set(operation.key, operation); + try { + const credentials = await store.getCredentials(catalogId); + if (controller.signal.aborted) return; + const nextFeed = await operation.execute(credentials, controller.signal); + if (!controller.signal.aborted && mounted.current) { + dispatch({ type: "loadSucceeded", requestId, feed: nextFeed }); + } + } catch (error) { + if (!controller.signal.aborted && mounted.current) { + dispatch({ type: "loadFailed", requestId, error: toErrorCode(error) }); + } + } + }, + [catalogId, store], + ); + + const startOperation = useCallback( + (operation: BrowserOperation) => { + const requestId = ++requestSequence.current; + dispatch({ + type: "loadStarted", + requestId, + url: operation.key, + mode: operation.mode, + }); + void executeOperation(operation, requestId); + }, + [executeOperation], + ); + + const openUrl = useCallback( + (url: string, mode: OpdsLoadMode) => { + startOperation({ + key: url, + mode, + execute: (credentials, signal) => + client.open(url, credentials, signal, catalogOrigin.current), + }); + }, + [client, startOperation], + ); + + const initializeCatalog = useCallback( + async (generation: number) => { + try { + await opdsMobileRuntime.ensureCatalogsLoaded(); + 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"); + } catch (error) { + if (!mounted.current || lifecycleGeneration.current !== generation) return; + const requestId = ++requestSequence.current; + dispatch({ type: "loadStarted", requestId, url: "catalog", mode: "replace" }); + dispatch({ type: "loadFailed", requestId, error: toErrorCode(error) }); + } + }, + [catalogId, openUrl, store], + ); + + useEffect(() => { + const generation = ++lifecycleGeneration.current; + mounted.current = true; + void initializeCatalog(generation); + return () => { + mounted.current = false; + if (lifecycleGeneration.current === generation) lifecycleGeneration.current += 1; + requestController.current?.abort(); + }; + }, [initializeCatalog]); + + const feedScope = getContentSnapshot(state)?.currentUrl ?? catalogUrl; + const coverCache = useMemo(() => { + const cacheScope = feedScope; + 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; + const credentials = await store.getCredentials(catalogId); + if (signal.aborted) throw new Error("cancelled"); + const response = await client.fetchAsset( + url, + new URL(catalogUrl).origin, + credentials, + signal, + ); + if (signal.aborted) { + await response.cancel("cancelled"); + throw new Error("cancelled"); + } + return readOpdsCover(response, signal, MAX_COVER_BYTES); + }, + }); + }, [catalogId, catalogUrl, client, feedScope, store]); + useEffect(() => () => coverCache.clear(), [coverCache]); + + const downloadController = useMemo( + () => + createOpdsDownloadController({ + onEvent: (event) => { + if (mounted.current) dispatch(event); + }, + }), + [], + ); + useEffect(() => () => void downloadController.cancel(), [downloadController]); + + useEffect(() => { + if (!formatChoice) return; + const focusTimer = setTimeout(() => { + const node = findNodeHandle(formatHeadingRef.current); + if (node) AccessibilityInfo.setAccessibilityFocus(node); + }, 100); + return () => clearTimeout(focusTimer); + }, [formatChoice]); + + const runDownload = useCallback( + async (publication: OpdsPublication, acquisition: OpdsAcquisition) => { + lastDownload.current = { publication, acquisition }; + try { + await downloadController.start({ + publicationTitle: publication.title, + prepare: () => store.getCredentials(catalogId), + execute: async ({ credentials, signal, onProgress, onImportStart }) => { + const result = await download({ + publication, + acquisition, + catalogOrigin: new URL(catalogUrl).origin, + credentials, + signal, + onProgress: (progress) => onProgress(progress.loaded, progress.total), + onImportStart, + }); + return { importedCount: result.importResult.imported.length }; + }, + }); + } catch { + // The controller owns the stable, sanitized error state. + } + }, + [catalogId, catalogUrl, download, downloadController, store], + ); + + const chooseDownload = (publication: OpdsPublication) => { + const acquisitions = listSupportedAcquisitions(publication); + if (acquisitions.length === 0) return; + if (acquisitions.length === 1) { + void runDownload(publication, acquisitions[0]); + return; + } + setFormatChoice({ publication, acquisitions }); + }; + + const handleSearch = () => { + const descriptor = feed?.search; + const trimmed = query.trim(); + if (!descriptor || !trimmed) return; + const key = `opds-search:${encodeURIComponent(trimmed)}`; + startOperation({ + key, + mode: "push", + execute: (credentials, signal) => + client.search(descriptor, trimmed, credentials, signal, catalogOrigin.current), + }); + }; + + const backController = useMemo( + () => + createOpdsBackController({ + getState: () => stateRef.current, + cancelRequest: () => requestController.current?.abort(), + dispatch, + startBack: (target) => { + const operation = operations.current.get(target); + if (operation) startOperation({ ...operation, mode: "back" }); + else openUrl(target, "back"); + }, + exit: navigation.goBack, + }), + [navigation.goBack, openUrl, startOperation], + ); + useEffect( + () => navigation.addListener("beforeRemove", backController.handleBeforeRemove), + [backController, navigation], + ); + + const handleRefresh = () => { + const snapshot = getContentSnapshot(state); + const operation = snapshot ? operations.current.get(snapshot.currentUrl) : undefined; + if (operation) startOperation({ ...operation, mode: "refresh" }); + }; + + const handleRetry = () => { + const operation = lastOperation.current; + if (state.content.status !== "error") return; + if (!operation) { + void initializeCatalog(lifecycleGeneration.current); + return; + } + const requestId = ++requestSequence.current; + dispatch({ type: "retryStarted", requestId }); + void executeOperation(operation, requestId); + }; + + const cancelDownload = () => { + downloadController.cancel(); + }; + + const retryDownload = () => { + const retry = lastDownload.current; + if (retry) void runDownload(retry.publication, retry.acquisition); + }; + + const errorMessage = (code: OpdsErrorCode) => t(`library.opds.errors.${code}`); + + const s = useMemo( + () => + StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + header: { + paddingHorizontal: layout.horizontalPadding, + paddingTop: 12, + paddingBottom: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: withOpacity(colors.border, 0.9), + alignItems: "center", + }, + headerInner: { width: "100%", maxWidth: layout.centeredContentWidth, gap: 12 }, + headerRow: { flexDirection: "row", alignItems: "center", gap: 12 }, + iconButton: { + width: 44, + height: 44, + borderRadius: radius.full, + backgroundColor: colors.card, + alignItems: "center", + justifyContent: "center", + }, + headerCopy: { flex: 1, minWidth: 0 }, + eyebrow: { + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + color: colors.mutedForeground, + textTransform: "uppercase", + letterSpacing: 0.7, + }, + title: { + marginTop: 2, + fontSize: fontSize.xl, + fontWeight: fontWeight.semibold, + color: colors.foreground, + }, + searchRow: { + minHeight: 46, + paddingHorizontal: 12, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.92), + backgroundColor: colors.card, + flexDirection: "row", + alignItems: "center", + gap: 9, + }, + searchInput: { + flex: 1, + minWidth: 0, + padding: 0, + fontSize: fontSize.base, + color: colors.foreground, + }, + searchButton: { + minWidth: 44, + minHeight: 44, + alignItems: "center", + justifyContent: "center", + }, + scrollContent: { + width: "100%", + maxWidth: layout.centeredContentWidth, + alignSelf: "center", + paddingHorizontal: layout.horizontalPadding, + paddingTop: 16, + paddingBottom: 130, + gap: 16, + }, + listRow: { marginBottom: 10 }, + feedIntro: { paddingHorizontal: 2 }, + feedTitle: { + fontSize: fontSize.lg, + fontWeight: fontWeight.semibold, + color: colors.foreground, + }, + feedSubtitle: { + marginTop: 4, + fontSize: fontSize.sm, + lineHeight: 21, + color: colors.mutedForeground, + }, + errorBox: { + padding: 14, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: withOpacity(colors.destructive, 0.22), + backgroundColor: withOpacity(colors.destructive, 0.08), + gap: 10, + }, + errorText: { fontSize: fontSize.sm, lineHeight: 20, color: colors.foreground }, + errorActions: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + smallButton: { + minHeight: 44, + paddingHorizontal: 14, + borderRadius: radius.full, + backgroundColor: colors.card, + alignItems: "center", + justifyContent: "center", + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.9), + }, + smallButtonText: { + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + color: colors.foreground, + }, + section: { gap: 9 }, + sectionTitle: { + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + color: colors.mutedForeground, + textTransform: "uppercase", + letterSpacing: 0.8, + paddingHorizontal: 2, + }, + linkCard: { + minHeight: 54, + paddingHorizontal: 14, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.9), + backgroundColor: colors.card, + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + linkText: { + flex: 1, + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + color: colors.foreground, + }, + publication: { + borderRadius: radius.xxl, + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.92), + backgroundColor: colors.card, + overflow: "hidden", + }, + publicationMain: { minHeight: 94, padding: 14, flexDirection: "row", gap: 13 }, + cover: { + width: 56, + height: 78, + borderRadius: radius.md, + backgroundColor: withOpacity(colors.primary, 0.08), + overflow: "hidden", + alignItems: "center", + justifyContent: "center", + }, + coverImage: { position: "absolute", top: 0, left: 0, width: 56, height: 78 }, + publicationCopy: { flex: 1, minWidth: 0 }, + publicationTitle: { + fontSize: fontSize.base, + lineHeight: 21, + fontWeight: fontWeight.semibold, + color: colors.foreground, + }, + publicationAuthor: { marginTop: 4, fontSize: fontSize.sm, color: colors.mutedForeground }, + publicationMeta: { marginTop: 7, fontSize: fontSize.xs, color: colors.mutedForeground }, + details: { + paddingHorizontal: 14, + paddingBottom: 14, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: withOpacity(colors.border, 0.82), + gap: 12, + }, + description: { + paddingTop: 12, + fontSize: fontSize.sm, + lineHeight: 21, + color: colors.foreground, + }, + subjectRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, + subject: { + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: radius.full, + backgroundColor: colors.muted, + }, + subjectText: { fontSize: fontSize.xs, color: colors.mutedForeground }, + downloadButton: { + minHeight: 46, + borderRadius: radius.xl, + backgroundColor: colors.primary, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 8, + }, + downloadText: { + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + color: colors.primaryForeground, + }, + unsupported: { fontSize: fontSize.sm, lineHeight: 20, color: colors.mutedForeground }, + pagination: { flexDirection: "row", gap: 10 }, + pageButton: { + flex: 1, + minHeight: 46, + borderRadius: radius.xl, + backgroundColor: colors.card, + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.9), + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: 6, + }, + pageText: { + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + color: colors.foreground, + }, + centerState: { + minHeight: 360, + alignItems: "center", + justifyContent: "center", + padding: 28, + }, + centerTitle: { + marginTop: 14, + fontSize: fontSize.lg, + fontWeight: fontWeight.semibold, + color: colors.foreground, + }, + centerText: { + marginTop: 7, + fontSize: fontSize.sm, + lineHeight: 21, + textAlign: "center", + color: colors.mutedForeground, + }, + downloadPanel: { + position: "absolute", + left: layout.horizontalPadding, + right: layout.horizontalPadding, + bottom: 16, + alignItems: "center", + }, + downloadInner: { + width: "100%", + maxWidth: layout.centeredContentWidth, + padding: 14, + borderRadius: 22, + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.92), + backgroundColor: colors.card, + shadowColor: "#000", + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.14, + shadowRadius: 18, + elevation: 7, + gap: 9, + }, + downloadRow: { flexDirection: "row", alignItems: "center", gap: 10 }, + downloadCopy: { flex: 1, minWidth: 0 }, + downloadTitle: { + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + color: colors.foreground, + }, + downloadMeta: { marginTop: 2, fontSize: fontSize.xs, color: colors.mutedForeground }, + progressTrack: { + height: 4, + borderRadius: radius.full, + backgroundColor: colors.muted, + overflow: "hidden", + }, + progressFill: { + height: "100%", + borderRadius: radius.full, + backgroundColor: colors.primary, + }, + overlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.4)", justifyContent: "flex-end" }, + picker: { + maxHeight: "88%", + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: Math.max(20, insets.bottom + 12), + borderTopLeftRadius: 26, + borderTopRightRadius: 26, + backgroundColor: colors.background, + gap: 12, + }, + pickerHandle: { + alignSelf: "center", + width: 38, + height: 4, + borderRadius: radius.full, + backgroundColor: colors.border, + }, + pickerTitle: { + fontSize: fontSize.xl, + fontWeight: fontWeight.semibold, + color: colors.foreground, + }, + pickerSubtitle: { fontSize: fontSize.sm, color: colors.mutedForeground }, + formatButton: { + minHeight: 52, + paddingHorizontal: 14, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.9), + backgroundColor: colors.card, + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + formatText: { + flex: 1, + fontSize: fontSize.base, + fontWeight: fontWeight.medium, + color: colors.foreground, + textTransform: "uppercase", + }, + formatList: { flexGrow: 0 }, + formatListContent: { gap: 10 }, + }), + [colors, insets.bottom, layout.centeredContentWidth, layout.horizontalPadding], + ); + + const renderPublication = (publication: OpdsPublication, keyPrefix = "publication") => { + const key = `${keyPrefix}:${publication.id ?? publication.title}`; + const expanded = expandedPublication === key; + const formats = listSupportedAcquisitions(publication); + const description = plainDescription(publication.description); + return ( + + setExpandedPublication(expanded ? undefined : key)} + accessibilityRole="button" + accessibilityState={{ expanded }} + accessibilityLabel={t("library.opds.publicationDetails", { + title: publication.title, + })} + > + + + + + + {publication.title} + + {publication.authors.join(", ") || t("library.opds.unknownAuthor")} + + + {formats.length > 0 + ? formats.map((item) => item.format.toUpperCase()).join(" · ") + : t("library.opds.noCompatibleFormat")} + + + + + {expanded ? ( + + {description ? {description} : null} + {publication.subjects.length > 0 ? ( + + {publication.subjects.slice(0, 8).map((subject) => ( + + {subject} + + ))} + + ) : null} + {formats.length > 0 ? ( + chooseDownload(publication)} + disabled={ + state.download.status === "downloading" || state.download.status === "importing" + } + accessibilityRole="button" + accessibilityState={{ + disabled: + state.download.status === "downloading" || + state.download.status === "importing", + }} + accessibilityLabel={t("library.opds.downloadTitle", { + title: publication.title, + })} + > + + + {formats.length > 1 + ? t("library.opds.chooseFormat") + : t("library.opds.downloadAndImport")} + + + ) : ( + {t("library.opds.unsupportedExplanation")} + )} + + ) : null} + + ); + }; + + const contentError = state.content.status === "error" ? state.content.error : undefined; + const initialLoading = + state.content.status === "idle" || (state.content.status === "loading" && !feed); + const feedRows = useMemo(() => (feed ? createOpdsFeedRows(feed) : []), [feed]); + const downloadAccessibility = getOpdsDownloadAccessibility(state.download); + + const renderFeedRow = ({ item }: { item: OpdsFeedRow }) => { + if (item.kind === "intro") { + return ( + + {item.feed.title} + {item.feed.subtitle ? {item.feed.subtitle} : null} + + ); + } + if (item.kind === "section") { + const title = + item.title === "collections" + ? t("library.opds.collections") + : item.title === "books" + ? t("library.opds.books") + : item.title; + return {title}; + } + if (item.kind === "link") { + return ( + openUrl(item.url, "push")} + accessibilityRole="button" + accessibilityLabel={item.title} + > + {item.icon ? : null} + {item.title} + + + ); + } + if (item.kind === "publication") { + return {renderPublication(item.publication, item.keyPrefix)}; + } + if (item.kind === "empty") { + return ( + + + {t("library.opds.empty")} + {t("library.opds.emptyHint")} + + ); + } + return ( + + {item.previousUrl ? ( + openUrl(item.previousUrl as string, "push")} + accessibilityRole="button" + accessibilityState={{ disabled: false }} + accessibilityLabel={t("library.opds.previous")} + > + + {t("library.opds.previous")} + + ) : null} + {item.nextUrl ? ( + openUrl(item.nextUrl as string, "push")} + accessibilityRole="button" + accessibilityState={{ disabled: false }} + accessibilityLabel={t("library.opds.next")} + > + {t("library.opds.next")} + + + ) : null} + + ); + }; + + return ( + + + + + + + + + + {catalogName || t("library.opds.catalog")} + + + {feed?.title ?? t("library.opds.loading")} + + + + {state.content.status === "ready" && state.content.refreshing ? ( + + ) : ( + + )} + + + {canSearchOpds(state) ? ( + + + + + + + + ) : null} + + + + {initialLoading ? ( + + + {t("library.opds.loading")} + {t("library.opds.loadingHint")} + + ) : !feed && contentError ? ( + + + {t("library.opds.loadFailed")} + {errorMessage(contentError)} + + + {t("library.opds.retry")} + + {shouldEditOpdsCredentials(state) ? ( + navigation.navigate("OpdsCatalogs", { editCatalogId: catalogId })} + accessibilityRole="button" + accessibilityState={{ disabled: false }} + accessibilityLabel={t("library.opds.editCredentials")} + > + {t("library.opds.editCredentials")} + + ) : null} + + + ) : ( + item.key} + renderItem={renderFeedRow} + contentContainerStyle={s.scrollContent} + showsVerticalScrollIndicator={false} + initialNumToRender={8} + maxToRenderPerBatch={8} + windowSize={7} + removeClippedSubviews + ListHeaderComponent={ + contentError ? ( + + {errorMessage(contentError)} + + + {t("library.opds.retry")} + + {shouldEditOpdsCredentials(state) ? ( + + navigation.navigate("OpdsCatalogs", { editCatalogId: catalogId }) + } + accessibilityRole="button" + accessibilityLabel={t("library.opds.editCredentials")} + > + {t("library.opds.editCredentials")} + + ) : null} + + + ) : null + } + /> + )} + + {state.download.status !== "idle" ? ( + + + + {state.download.status === "downloading" || state.download.status === "importing" ? ( + + ) : state.download.status === "success" ? ( + + ) : ( + + )} + + + {state.download.publicationTitle} + + + {state.download.status === "downloading" + ? state.download.total > 0 + ? t("library.opds.downloadingProgress", { + percent: Math.round((state.download.loaded / state.download.total) * 100), + }) + : t("library.opds.downloading") + : state.download.status === "importing" + ? t("library.opds.importing") + : state.download.status === "success" + ? state.download.importedCount > 0 + ? t("library.opds.imported") + : t("library.opds.alreadyImported") + : errorMessage(state.download.error)} + + + {state.download.status === "downloading" ? ( + + {t("library.opds.cancel")} + + ) : state.download.status === "error" && lastDownload.current ? ( + + {t("library.opds.retry")} + + ) : state.download.status === "success" ? ( + dispatch({ type: "downloadReset" })} + accessibilityRole="button" + accessibilityState={{ disabled: false }} + accessibilityLabel={t("library.opds.done")} + > + {t("library.opds.done")} + + ) : null} + + {state.download.status === "downloading" || state.download.status === "importing" ? ( + 0 + ? downloadAccessibility.value + : { + text: + state.download.status === "importing" + ? t("library.opds.importing") + : t("library.opds.downloading"), + } + } + > + 0 ? Math.min(100, (state.download.loaded / state.download.total) * 100) : 8}%`, + }, + ]} + /> + + ) : null} + + + ) : null} + + setFormatChoice(undefined)} + > + setFormatChoice(undefined)}> + event.stopPropagation()} + accessibilityViewIsModal + > + + + {t("library.opds.chooseFormat")} + + + {formatChoice?.publication.title} + + `${acquisition.format}:${acquisition.url}`} + initialNumToRender={8} + renderItem={({ item: acquisition }) => ( + { + const publication = formatChoice?.publication; + setFormatChoice(undefined); + if (publication) void runDownload(publication, acquisition); + }} + accessibilityRole="button" + accessibilityState={{ disabled: false }} + accessibilityLabel={t("library.opds.downloadFormat", { + format: acquisition.format.toUpperCase(), + })} + > + + {acquisition.format} + + + )} + /> + + + + + ); +} diff --git a/packages/app-expo/src/screens/library/OpdsCatalogFormSheet.tsx b/packages/app-expo/src/screens/library/OpdsCatalogFormSheet.tsx new file mode 100644 index 000000000..583903c12 --- /dev/null +++ b/packages/app-expo/src/screens/library/OpdsCatalogFormSheet.tsx @@ -0,0 +1,468 @@ +import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; +import { fontSize, fontWeight, radius, useColors, withOpacity } from "@/styles/theme"; +import { + type OpdsCatalog, + type OpdsCatalogAuth, + type OpdsCatalogStore, + canPreserveOpdsCatalogPassword, + classifyOpdsUrl, +} from "@readany/core"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + Alert, + KeyboardAvoidingView, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { createOpdsFormSaveOwner } from "./opds-form-save-owner"; + +interface OpdsCatalogFormSheetProps { + visible: boolean; + catalog?: OpdsCatalog; + store: OpdsCatalogStore; + onClose: () => void; + onSaved: () => void; + onBackgroundSaved?: () => void; +} + +export function OpdsCatalogFormSheet({ + visible, + catalog, + store, + onClose, + onSaved, + onBackgroundSaved, +}: OpdsCatalogFormSheetProps) { + const { t } = useTranslation(); + const colors = useColors(); + const insets = useSafeAreaInsets(); + const layout = useResponsiveLayout(); + 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 [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); + 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 || preservesPassword))) && + !submitting; + const savingCurrentOpen = saveOwner.current.isSavingCurrent(renderedOpenGeneration); + + const s = useMemo( + () => + StyleSheet.create({ + overlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.4)", justifyContent: "flex-end" }, + keyboardWrap: { width: "100%", justifyContent: "flex-end" }, + sheet: { + alignSelf: "center", + width: "100%", + maxWidth: layout.isTablet ? 620 : undefined, + maxHeight: "94%", + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: Math.max(insets.bottom, 16) + 12, + borderTopLeftRadius: 26, + borderTopRightRadius: 26, + backgroundColor: colors.background, + gap: 14, + }, + handle: { + alignSelf: "center", + width: 38, + height: 4, + borderRadius: radius.full, + backgroundColor: withOpacity(colors.border, 0.95), + }, + title: { fontSize: fontSize.xl, fontWeight: fontWeight.semibold, color: colors.foreground }, + subtitle: { + marginTop: 4, + fontSize: fontSize.sm, + lineHeight: 20, + color: colors.mutedForeground, + }, + scroll: { flexGrow: 0 }, + content: { gap: 14, paddingBottom: 4 }, + field: { gap: 7 }, + label: { fontSize: fontSize.sm, fontWeight: fontWeight.medium, color: colors.foreground }, + input: { + minHeight: 48, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.92), + backgroundColor: colors.card, + paddingHorizontal: 14, + fontSize: fontSize.base, + color: colors.foreground, + }, + segmented: { + minHeight: 48, + padding: 4, + borderRadius: radius.xl, + backgroundColor: colors.muted, + flexDirection: "row", + gap: 4, + }, + segment: { + flex: 1, + minHeight: 40, + borderRadius: radius.lg, + alignItems: "center", + justifyContent: "center", + }, + segmentActive: { backgroundColor: colors.card }, + segmentText: { + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + color: colors.mutedForeground, + }, + segmentTextActive: { color: colors.foreground }, + helper: { fontSize: fontSize.xs, lineHeight: 18, color: colors.mutedForeground }, + switchRow: { + minHeight: 56, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.92), + backgroundColor: colors.card, + paddingHorizontal: 14, + paddingVertical: 10, + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }, + switchCopy: { flex: 1 }, + switchTitle: { + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + color: colors.foreground, + }, + switchHint: { marginTop: 2, fontSize: fontSize.xs, color: colors.mutedForeground }, + errorBox: { + padding: 12, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: withOpacity(colors.destructive, 0.24), + backgroundColor: withOpacity(colors.destructive, 0.08), + }, + errorText: { fontSize: fontSize.sm, lineHeight: 20, color: colors.destructive }, + footer: { flexDirection: "row", gap: 10 }, + button: { + minHeight: 48, + borderRadius: radius.xl, + alignItems: "center", + justifyContent: "center", + }, + cancelButton: { + flex: 1, + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.9), + backgroundColor: colors.card, + }, + saveButton: { + flex: 1.2, + flexDirection: "row", + gap: 8, + backgroundColor: canSubmit ? colors.primary : withOpacity(colors.primary, 0.42), + }, + cancelText: { + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + color: colors.foreground, + }, + saveText: { + fontSize: fontSize.sm, + fontWeight: fontWeight.semibold, + color: colors.primaryForeground, + }, + }), + [canSubmit, colors, insets.bottom, layout.isTablet], + ); + + 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(), + url: url.trim(), + enabled, + auth, + ...(auth === "basic" + ? { username: username.trim(), ...(password ? { password } : {}) } + : {}), + }; + if (catalog) await store.updateCatalog(catalog.id, input); + else await store.addCatalog(input); + succeeded = true; + } catch { + // Ownership is resolved in finally so stale failures cannot touch a reopened form. + } finally { + 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")); + } + } + }; + + const handleSave = () => { + 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) { + void persist(); + return; + } + Alert.alert(t("library.opds.form.localHttpTitle"), t("library.opds.form.localHttpWarning"), [ + { text: t("library.opds.cancel"), style: "cancel" }, + { + text: t("library.opds.continue"), + onPress: () => void persist(), + }, + ]); + }; + + return ( + { + if (!savingCurrentOpen) onClose(); + }} + > + { + if (!savingCurrentOpen) onClose(); + }} + > + + event.stopPropagation()}> + + + + {catalog ? t("library.opds.form.editTitle") : t("library.opds.form.addTitle")} + + {t("library.opds.form.subtitle")} + + + + {t("library.opds.form.name")} + + + + {t("library.opds.form.url")} + + + + {t("library.opds.form.authentication")} + + {(["anonymous", "basic"] as const).map((mode) => ( + setAuth(mode)} + accessibilityRole="radio" + accessibilityState={{ checked: auth === mode }} + disabled={savingCurrentOpen} + > + + {mode === "anonymous" + ? t("library.opds.form.anonymous") + : t("library.opds.form.basic")} + + + ))} + + + {auth === "basic" ? ( + <> + + {t("library.opds.form.username")} + + + + {t("library.opds.form.password")} + + {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")} + + + + {error ? ( + + {error} + + ) : null} + + + + {t("library.opds.cancel")} + + + {submitting ? ( + + ) : null} + {t("library.opds.save")} + + + + + + + ); +} diff --git a/packages/app-expo/src/screens/library/OpdsCatalogsScreen.tsx b/packages/app-expo/src/screens/library/OpdsCatalogsScreen.tsx new file mode 100644 index 000000000..cda3835ba --- /dev/null +++ b/packages/app-expo/src/screens/library/OpdsCatalogsScreen.tsx @@ -0,0 +1,488 @@ +import { + ChevronLeftIcon, + ChevronRightIcon, + EditIcon, + EyeOffIcon, + GlobeIcon, + PlusIcon, + RotateCcwIcon, + Trash2Icon, +} from "@/components/ui/Icon"; +import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; +import type { RootStackParamList } from "@/navigation/RootNavigator"; +import { fontSize, fontWeight, radius, useColors, withOpacity } from "@/styles/theme"; +import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import type { OpdsCatalog } from "@readany/core"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + ActivityIndicator, + Alert, + ScrollView, + StyleSheet, + Switch, + Text, + TouchableOpacity, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { OpdsCatalogFormSheet } from "./OpdsCatalogFormSheet"; +import { opdsMobileRuntime } from "./opds-mobile-runtime"; +import { createOpdsBrowserRouteParams } from "./opds-view-state"; + +type Props = NativeStackScreenProps; + +export function OpdsCatalogsScreen({ navigation, route }: Props) { + const { t } = useTranslation(); + const colors = useColors(); + const layout = useResponsiveLayout(); + const store = useMemo(() => opdsMobileRuntime.getCatalogStore(), []); + const [catalogs, setCatalogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(); + const [formOpen, setFormOpen] = useState(false); + const [editing, setEditing] = useState(); + const [busyId, setBusyId] = useState(); + + const syncCatalogs = useCallback( + () => setCatalogs(store.listCatalogs({ includeHidden: true })), + [store], + ); + + useEffect(() => { + let active = true; + void opdsMobileRuntime + .ensureCatalogsLoaded() + .then(() => { + if (!active) return; + syncCatalogs(); + setError(undefined); + }) + .catch(() => { + if (active) { + setError(t("library.opds.catalogsLoadFailed")); + } + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, [syncCatalogs, t]); + + useEffect(() => { + const editCatalogId = route.params?.editCatalogId; + if (!editCatalogId || loading) return; + const catalog = store.getCatalog(editCatalogId); + if (catalog && !catalog.builtIn) { + setEditing(catalog); + setFormOpen(true); + navigation.setParams({ editCatalogId: undefined }); + } + }, [loading, navigation, route.params?.editCatalogId, store]); + + const visibleCatalogs = catalogs.filter((catalog) => !catalog.hidden); + const hiddenBuiltIns = catalogs.filter((catalog) => catalog.builtIn && catalog.hidden); + + const s = useMemo( + () => + StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.background }, + header: { + paddingHorizontal: layout.horizontalPadding, + paddingTop: 12, + paddingBottom: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: withOpacity(colors.border, 0.9), + alignItems: "center", + }, + headerInner: { + width: "100%", + maxWidth: layout.centeredContentWidth, + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + iconButton: { + width: 44, + height: 44, + borderRadius: radius.full, + backgroundColor: colors.card, + alignItems: "center", + justifyContent: "center", + }, + titleWrap: { flex: 1, minWidth: 0 }, + title: { fontSize: fontSize.xl, fontWeight: fontWeight.semibold, color: colors.foreground }, + subtitle: { marginTop: 2, fontSize: fontSize.sm, color: colors.mutedForeground }, + scrollContent: { + width: "100%", + maxWidth: layout.centeredContentWidth, + alignSelf: "center", + paddingHorizontal: layout.horizontalPadding, + paddingTop: 18, + paddingBottom: 32, + gap: 18, + }, + intro: { + padding: 16, + borderRadius: radius.xxl, + backgroundColor: withOpacity(colors.primary, 0.07), + borderWidth: 1, + borderColor: withOpacity(colors.primary, 0.12), + }, + introEyebrow: { + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + color: colors.primary, + textTransform: "uppercase", + letterSpacing: 0.8, + }, + introText: { + marginTop: 6, + fontSize: fontSize.sm, + lineHeight: 21, + color: colors.foreground, + }, + section: { gap: 10 }, + sectionTitle: { + fontSize: fontSize.xs, + fontWeight: fontWeight.semibold, + color: colors.mutedForeground, + textTransform: "uppercase", + letterSpacing: 0.8, + paddingHorizontal: 2, + }, + card: { + borderRadius: radius.xxl, + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.92), + backgroundColor: colors.card, + overflow: "hidden", + }, + cardMain: { + minHeight: 76, + paddingHorizontal: 14, + paddingVertical: 13, + flexDirection: "row", + alignItems: "center", + gap: 12, + }, + catalogIcon: { + width: 42, + height: 42, + borderRadius: 15, + alignItems: "center", + justifyContent: "center", + backgroundColor: withOpacity(colors.primary, 0.09), + }, + catalogCopy: { flex: 1, minWidth: 0 }, + nameRow: { flexDirection: "row", alignItems: "center", gap: 8 }, + catalogName: { + flexShrink: 1, + fontSize: fontSize.base, + fontWeight: fontWeight.semibold, + color: colors.foreground, + }, + badge: { + borderRadius: radius.full, + paddingHorizontal: 7, + paddingVertical: 3, + backgroundColor: colors.muted, + }, + badgeText: { fontSize: 10, fontWeight: fontWeight.semibold, color: colors.mutedForeground }, + catalogUrl: { marginTop: 4, fontSize: fontSize.xs, color: colors.mutedForeground }, + status: { marginTop: 4, fontSize: fontSize.xs, color: colors.mutedForeground }, + disabledCard: { opacity: 0.62 }, + actions: { + minHeight: 48, + paddingHorizontal: 10, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: withOpacity(colors.border, 0.86), + flexDirection: "row", + alignItems: "center", + justifyContent: "flex-end", + gap: 4, + }, + action: { + minWidth: 44, + minHeight: 44, + alignItems: "center", + justifyContent: "center", + borderRadius: radius.lg, + }, + switchLabel: { + marginRight: "auto", + paddingLeft: 4, + fontSize: fontSize.xs, + color: colors.mutedForeground, + }, + hiddenCard: { + minHeight: 58, + paddingHorizontal: 14, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: withOpacity(colors.border, 0.8), + backgroundColor: withOpacity(colors.card, 0.72), + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + hiddenName: { flex: 1, fontSize: fontSize.sm, color: colors.foreground }, + restoreText: { + fontSize: fontSize.sm, + fontWeight: fontWeight.medium, + color: colors.primary, + }, + state: { flex: 1, alignItems: "center", justifyContent: "center", padding: 28 }, + stateTitle: { + marginTop: 14, + fontSize: fontSize.lg, + fontWeight: fontWeight.semibold, + color: colors.foreground, + }, + stateText: { + marginTop: 6, + fontSize: fontSize.sm, + lineHeight: 21, + textAlign: "center", + color: colors.mutedForeground, + }, + }), + [colors, layout.centeredContentWidth, layout.horizontalPadding], + ); + + 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 confirmDelete = (catalog: OpdsCatalog) => { + Alert.alert(t("library.opds.deleteTitle"), t("library.opds.deleteDescription"), [ + { text: t("library.opds.cancel"), style: "cancel" }, + { + text: t("library.opds.delete"), + style: "destructive", + onPress: () => void mutate(catalog.id, () => store.removeCatalog(catalog.id)), + }, + ]); + }; + + 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"); + }; + + if (loading) { + return ( + + + + {t("library.opds.loadingCatalogs")} + + + ); + } + + return ( + + + + navigation.goBack()} + accessibilityRole="button" + accessibilityLabel={t("library.opds.back")} + > + + + + {t("library.opds.catalogsTitle")} + {t("library.opds.catalogsSubtitle")} + + { + setEditing(undefined); + setFormOpen(true); + }} + accessibilityRole="button" + accessibilityLabel={t("library.opds.form.addTitle")} + > + + + + + + + + {t("library.opds.readerEyebrow")} + {t("library.opds.readerIntro")} + + + {error ? ( + + {error} + + ) : null} + + + {t("library.opds.available")} + {visibleCatalogs.map((catalog) => { + const busy = busyId === catalog.id; + return ( + + + navigation.navigate("OpdsBrowser", createOpdsBrowserRouteParams(catalog.id)) + } + accessibilityRole="button" + accessibilityState={{ disabled: !catalog.enabled || busy }} + accessibilityLabel={t("library.opds.browseCatalog", { + name: catalog.name, + })} + > + + {busy ? ( + + ) : ( + + )} + + + + + {catalog.name} + + {catalog.builtIn ? ( + + {t("library.opds.builtIn")} + + ) : null} + + + {catalog.url} + + {authenticationLabel(catalog)} + + {catalog.enabled ? ( + + ) : null} + + + {catalog.builtIn ? ( + <> + {t("library.opds.builtInLocked")} + void mutate(catalog.id, () => store.hideBuiltIn(catalog.id))} + accessibilityRole="button" + accessibilityLabel={t("library.opds.hideCatalog", { + name: catalog.name, + })} + > + + + + ) : ( + <> + + {catalog.enabled ? t("library.opds.enabled") : t("library.opds.disabled")} + + + void mutate(catalog.id, () => store.setCatalogEnabled(catalog.id, value)) + } + accessibilityLabel={t("library.opds.toggleCatalog", { + name: catalog.name, + })} + /> + { + setEditing(catalog); + setFormOpen(true); + }} + accessibilityRole="button" + accessibilityLabel={t("library.opds.editCatalog", { + name: catalog.name, + })} + > + + + confirmDelete(catalog)} + accessibilityRole="button" + accessibilityLabel={t("library.opds.deleteCatalog", { + name: catalog.name, + })} + > + + + + )} + + + ); + })} + + + {hiddenBuiltIns.length > 0 ? ( + + {t("library.opds.hiddenPresets")} + {hiddenBuiltIns.map((catalog) => ( + + + {catalog.name} + void mutate(catalog.id, () => store.restoreBuiltIn(catalog.id))} + accessibilityRole="button" + accessibilityLabel={t("library.opds.restoreCatalog", { + name: catalog.name, + })} + > + {t("library.opds.restore")} + + + ))} + + ) : null} + + + setFormOpen(false)} + onSaved={() => { + setFormOpen(false); + setEditing(undefined); + syncCatalogs(); + }} + onBackgroundSaved={syncCatalogs} + /> + + ); +} diff --git a/packages/app-expo/src/screens/library/WebDavImportSourceSheet.tsx b/packages/app-expo/src/screens/library/WebDavImportSourceSheet.tsx index fb4e84758..e4f39ebe1 100644 --- a/packages/app-expo/src/screens/library/WebDavImportSourceSheet.tsx +++ b/packages/app-expo/src/screens/library/WebDavImportSourceSheet.tsx @@ -3,6 +3,7 @@ import { ChevronRightIcon, CloudIcon, GlobeIcon, + LibraryIcon, } from "@/components/ui/Icon"; import { useResponsiveLayout } from "@/hooks/use-responsive-layout"; import { fontSize, fontWeight, radius, useColors, withOpacity } from "@/styles/theme"; @@ -35,6 +36,7 @@ interface WebDavImportSourceSheetProps { onPickLocal: () => void; onPickSavedWebDav: () => void; onPickTemporaryWebDav: () => void; + onPickOpds: () => void; } export function WebDavImportSourceSheet({ @@ -47,6 +49,7 @@ export function WebDavImportSourceSheet({ onPickLocal, onPickSavedWebDav, onPickTemporaryWebDav, + onPickOpds, }: WebDavImportSourceSheetProps) { const { t } = useTranslation(); const colors = useColors(); @@ -65,11 +68,10 @@ export function WebDavImportSourceSheet({ Math.max(screenPadding, preferredLeft), layout.width - popoverWidth - screenPadding, ); - const showBelow = - activeAnchor.y + activeAnchor.height + 12 + 230 < layout.height - screenPadding; + const showBelow = activeAnchor.y + activeAnchor.height + 12 + 284 < layout.height - screenPadding; const popoverTop = showBelow ? activeAnchor.y + activeAnchor.height + 10 - : Math.max(screenPadding, activeAnchor.y - 230); + : Math.max(screenPadding, activeAnchor.y - 284); const s = useMemo( () => @@ -97,7 +99,7 @@ export function WebDavImportSourceSheet({ elevation: 8, }, options: { - maxHeight: 240, + maxHeight: 294, }, optionCard: { paddingHorizontal: 14, @@ -131,7 +133,7 @@ export function WebDavImportSourceSheet({ marginHorizontal: 14, }, }), - [colors, layout.height, popoverLeft, popoverTop, popoverWidth], + [colors, popoverLeft, popoverTop, popoverWidth], ); return ( @@ -143,10 +145,7 @@ export function WebDavImportSourceSheet({ onDismiss={onDismiss} > - event.stopPropagation()} - > + event.stopPropagation()}> + + + + + + + + {t("library.opds.catalogsTitle")} + + + diff --git a/packages/app-expo/src/screens/library/opds-back-controller.test.ts b/packages/app-expo/src/screens/library/opds-back-controller.test.ts new file mode 100644 index 000000000..93f53a3cd --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-back-controller.test.ts @@ -0,0 +1,123 @@ +import type { OpdsFeed } from "@readany/core"; +import { describe, expect, it, vi } from "vitest"; +import { createOpdsBackController } from "./opds-back-controller"; +import { type OpdsViewState, createInitialOpdsViewState, opdsViewReducer } from "./opds-view-state"; + +const feed = (title: string): OpdsFeed => ({ + title, + navigation: [], + publications: [], + groups: [], + facets: [], +}); + +function readyWithHistory(): OpdsViewState { + let state = opdsViewReducer(createInitialOpdsViewState(), { + type: "loadStarted", + requestId: 1, + url: "root", + mode: "replace", + }); + state = opdsViewReducer(state, { type: "loadSucceeded", requestId: 1, feed: feed("Root") }); + state = opdsViewReducer(state, { + type: "loadStarted", + requestId: 2, + url: "child", + mode: "push", + }); + return opdsViewReducer(state, { + type: "loadSucceeded", + requestId: 2, + feed: feed("Child"), + }); +} + +function setup(initial: OpdsViewState) { + let state = initial; + const cancelRequest = vi.fn(); + const startBack = vi.fn(); + const exit = vi.fn(); + const controller = createOpdsBackController({ + getState: () => state, + cancelRequest, + dispatch: (action) => { + state = opdsViewReducer(state, action); + }, + startBack, + exit, + }); + return { controller, cancelRequest, startBack, exit, getState: () => state }; +} + +describe("OPDS native back controller", () => { + it("uses internal history for the header back action", () => { + const { controller, startBack, exit } = setup(readyWithHistory()); + + controller.handleHeaderBack(); + + expect(startBack).toHaveBeenCalledWith("root"); + expect(exit).not.toHaveBeenCalled(); + }); + + it("prevents a native route pop and uses the same internal history", () => { + const { controller, startBack } = setup(readyWithHistory()); + const event = { preventDefault: vi.fn() }; + + controller.handleBeforeRemove(event); + + expect(event.preventDefault).toHaveBeenCalledOnce(); + expect(startBack).toHaveBeenCalledWith("root"); + }); + + it("cancels an in-flight push and restores the previous ready feed", () => { + const child = readyWithHistory(); + const pushing = opdsViewReducer(child, { + type: "loadStarted", + requestId: 3, + url: "grandchild", + mode: "push", + }); + const { controller, cancelRequest, getState } = setup(pushing); + + controller.handleBeforeRemove({ preventDefault: vi.fn() }); + + expect(cancelRequest).toHaveBeenCalledOnce(); + expect(getState().content).toMatchObject({ status: "ready", currentUrl: "child" }); + }); + + it("restores the previous feed when native back follows a failed push", () => { + const child = readyWithHistory(); + const pushing = opdsViewReducer(child, { + type: "loadStarted", + requestId: 3, + url: "grandchild", + mode: "push", + }); + const failed = opdsViewReducer(pushing, { + type: "loadFailed", + requestId: 3, + error: "unreachable", + }); + const { controller, getState, exit } = setup(failed); + + controller.handleBeforeRemove({ preventDefault: vi.fn() }); + + expect(getState().content).toMatchObject({ status: "ready", currentUrl: "child" }); + expect(exit).not.toHaveBeenCalled(); + }); + + it("allows the root route to pop", () => { + let root = opdsViewReducer(createInitialOpdsViewState(), { + type: "loadStarted", + requestId: 1, + url: "root", + mode: "replace", + }); + root = opdsViewReducer(root, { type: "loadSucceeded", requestId: 1, feed: feed("Root") }); + const { controller, exit } = setup(root); + + controller.handleHeaderBack(); + + expect(exit).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app-expo/src/screens/library/opds-back-controller.ts b/packages/app-expo/src/screens/library/opds-back-controller.ts new file mode 100644 index 000000000..f6555e948 --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-back-controller.ts @@ -0,0 +1 @@ +export { createOpdsBackController } from "@readany/core"; 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 new file mode 100644 index 000000000..9b1799ed8 --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-cover-cache.test.ts @@ -0,0 +1,152 @@ +import type { OpdsAssetResponse } from "@readany/core"; +import { describe, expect, it, vi } from "vitest"; +import { createOpdsCoverCache, readOpdsCover } from "./opds-cover-cache"; + +function response(chunks: Uint8Array[], headers: Record = {}) { + const cancel = vi.fn(async () => undefined); + const body = new ReadableStream({ + pull(controller) { + const chunk = chunks.shift(); + if (chunk) controller.enqueue(chunk); + else controller.close(); + }, + cancel, + }); + return { + body, + bodyUsed: false, + headers: new Headers({ "Content-Type": "image/jpeg", ...headers }), + ok: true, + redirected: false, + status: 200, + statusText: "OK", + type: "default", + url: "https://catalog.test/cover.jpg", + arrayBuffer: vi.fn(), + blob: vi.fn(), + json: vi.fn(), + text: vi.fn(), + cancel, + } as unknown as OpdsAssetResponse & { cancel: ReturnType }; +} + +describe("OPDS cover streaming and cache", () => { + it("cancels a chunked response as soon as the real byte ceiling is crossed", async () => { + const asset = response([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])]); + + await expect(readOpdsCover(asset, new AbortController().signal, 5)).rejects.toThrow( + "cover-too-large", + ); + expect(asset.cancel).toHaveBeenCalledOnce(); + expect(asset.arrayBuffer).not.toHaveBeenCalled(); + }); + + it("does not trust a dishonest short Content-Length", async () => { + const asset = response([new Uint8Array([1, 2, 3]), new Uint8Array([4, 5, 6])], { + "Content-Length": "2", + }); + + await expect(readOpdsCover(asset, new AbortController().signal, 5)).rejects.toThrow( + "cover-too-large", + ); + expect(asset.cancel).toHaveBeenCalledOnce(); + }); + + it("aborts the exact cover transport", async () => { + const asset = response([new Uint8Array([1])]); + const controller = new AbortController(); + controller.abort(); + + await expect(readOpdsCover(asset, controller.signal, 5)).rejects.toThrow("cancelled"); + expect(asset.cancel).toHaveBeenCalledOnce(); + }); + + it("deduplicates duplicate cover URLs into one shared fetch", async () => { + const load = vi.fn(async () => ({ uri: "data:image/jpeg;base64,AQ==", byteLength: 1 })); + const cache = createOpdsCoverCache({ load, maxEntries: 4, maxBytes: 100 }); + + const [first, second] = await Promise.all([ + cache.acquire("https://catalog.test/cover.jpg"), + cache.acquire("https://catalog.test/cover.jpg"), + ]); + + expect(load).toHaveBeenCalledOnce(); + expect(first.uri).toBe(second.uri); + first.release(); + second.release(); + }); + + it("evicts the least-recently-used released entry within entry and byte bounds", async () => { + const load = vi.fn(async (url: string) => ({ + uri: `data:image/jpeg;base64,${url}`, + byteLength: 6, + })); + const cache = createOpdsCoverCache({ load, maxEntries: 1, maxBytes: 8 }); + + (await cache.acquire("one")).release(); + (await cache.acquire("two")).release(); + expect(cache.snapshot()).toMatchObject({ entries: 1, sourceBytes: 6, urls: ["two"] }); + (await cache.acquire("one")).release(); + expect(load).toHaveBeenCalledTimes(3); + }); + + it("uses reference-counted cancellation for a shared in-flight fetch", async () => { + let loaderSignal: AbortSignal | undefined; + const load = vi.fn( + async (_url: string, signal: AbortSignal) => + new Promise<{ uri: string; byteLength: number }>((_resolve, reject) => { + loaderSignal = signal; + signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }); + }), + ); + const cache = createOpdsCoverCache({ load, maxEntries: 4, maxBytes: 100 }); + const first = new AbortController(); + const second = new AbortController(); + const firstLease = cache.acquire("shared", first.signal); + const secondLease = cache.acquire("shared", second.signal); + + first.abort(); + expect(loaderSignal?.aborted).toBe(false); + second.abort(); + await expect(Promise.allSettled([firstLease, secondLease])).resolves.toHaveLength(2); + expect(loaderSignal?.aborted).toBe(true); + }); + + it("clears cached and in-flight resources on feed change or unmount", async () => { + let loaderSignal: AbortSignal | undefined; + const cache = createOpdsCoverCache({ + load: async (_url, signal) => { + loaderSignal = signal; + return new Promise(() => {}); + }, + maxEntries: 4, + maxBytes: 100, + }); + 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: [] }); + }); + + it("does not repopulate a cleared feed when a stale loader resolves late", async () => { + let resolveLoad!: (value: { uri: string; byteLength: number }) => void; + const cache = createOpdsCoverCache({ + load: async () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + maxEntries: 4, + maxBytes: 100, + }); + const stale = cache.acquire("stale"); + + cache.clear(); + resolveLoad({ uri: "data:image/jpeg;base64,AQ==", byteLength: 1 }); + + await expect(stale).rejects.toThrow("cancelled"); + expect(cache.snapshot()).toMatchObject({ entries: 0, sourceBytes: 0, urls: [] }); + }); +}); diff --git a/packages/app-expo/src/screens/library/opds-cover-cache.ts b/packages/app-expo/src/screens/library/opds-cover-cache.ts new file mode 100644 index 000000000..b56cf5ef0 --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-cover-cache.ts @@ -0,0 +1,6 @@ +export { + createOpdsCoverCache, + readOpdsCover, + type OpdsCoverLease, + type OpdsCoverValue, +} from "@readany/core"; diff --git a/packages/app-expo/src/screens/library/opds-download-controller.test.ts b/packages/app-expo/src/screens/library/opds-download-controller.test.ts new file mode 100644 index 000000000..15df4f1b1 --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-download-controller.test.ts @@ -0,0 +1,147 @@ +import { OpdsError } from "@readany/core"; +import { describe, expect, it, vi } from "vitest"; +import { + createOpdsDownloadController, + getOpdsDownloadAccessibility, +} from "./opds-download-controller"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +describe("OPDS screen download controller", () => { + it("owns cancellation before credential lookup and never starts a cancelled download", async () => { + const credentials = deferred(); + const execute = vi.fn(async () => ({ importedCount: 1 })); + const events: unknown[] = []; + let credentialSignal: AbortSignal | undefined; + const controller = createOpdsDownloadController({ + onEvent: (event) => events.push(event), + }); + + const operation = controller.start({ + publicationTitle: "Book", + prepare: (signal) => { + credentialSignal = signal; + return credentials.promise; + }, + execute, + }); + + expect(controller.cancel()).toBe(true); + expect(credentialSignal?.aborted).toBe(true); + credentials.resolve("secret"); + await expect(operation).resolves.toBeUndefined(); + expect(execute).not.toHaveBeenCalled(); + expect(events.map((event) => (event as { type: string }).type)).toEqual([ + "downloadStarted", + "downloadCancelled", + ]); + }); + + it("transitions synchronously to noncancellable importing at the commit point", async () => { + const imported = deferred<{ importedCount: number }>(); + const events: unknown[] = []; + const controller = createOpdsDownloadController({ + onEvent: (event) => events.push(event), + }); + + const operation = controller.start({ + publicationTitle: "Book", + prepare: async () => "secret", + execute: async ({ onImportStart }) => { + onImportStart(); + return imported.promise; + }, + }); + + await vi.waitFor(() => expect(controller.getPhase()).toBe("importing")); + expect(controller.cancel()).toBe(false); + expect(events.at(-1)).toMatchObject({ type: "downloadImporting" }); + imported.resolve({ importedCount: 1 }); + await expect(operation).resolves.toEqual({ importedCount: 1 }); + expect(events.at(-1)).toMatchObject({ type: "downloadSucceeded", importedCount: 1 }); + }); + + it("surfaces a committed import failure even when cancel is tapped late", async () => { + const imported = deferred<{ importedCount: number }>(); + const events: unknown[] = []; + const controller = createOpdsDownloadController({ + onEvent: (event) => events.push(event), + }); + const operation = controller.start({ + publicationTitle: "Book", + prepare: async () => undefined, + execute: async ({ onImportStart }) => { + onImportStart(); + return imported.promise; + }, + }); + + await vi.waitFor(() => expect(controller.getPhase()).toBe("importing")); + expect(controller.cancel()).toBe(false); + imported.reject(new OpdsError("import-failed")); + await expect(operation).rejects.toMatchObject({ code: "import-failed" }); + expect(events.at(-1)).toMatchObject({ type: "downloadFailed", error: "import-failed" }); + }); + + it("rejects a second request while credential lookup is active", async () => { + const credentials = deferred(); + const controller = createOpdsDownloadController({ onEvent: vi.fn() }); + const first = controller.start({ + publicationTitle: "First", + prepare: () => credentials.promise, + execute: async () => ({ importedCount: 1 }), + }); + + await expect( + controller.start({ + publicationTitle: "Second", + prepare: async () => undefined, + execute: async () => ({ importedCount: 1 }), + }), + ).rejects.toMatchObject({ code: "download-in-progress" }); + + controller.cancel(); + credentials.resolve(); + await first; + }); + + it("describes determinate, indeterminate, importing, success, and error states accessibly", () => { + expect( + getOpdsDownloadAccessibility({ + status: "downloading", + requestId: 1, + publicationTitle: "Book", + loaded: 25, + total: 100, + }), + ).toMatchObject({ role: "progressbar", value: { min: 0, max: 100, now: 25 } }); + expect( + getOpdsDownloadAccessibility({ + status: "downloading", + requestId: 1, + publicationTitle: "Book", + loaded: 25, + total: 0, + }), + ).toMatchObject({ role: "progressbar", value: { text: "downloading" } }); + expect( + getOpdsDownloadAccessibility({ status: "importing", requestId: 1, publicationTitle: "Book" }), + ).toMatchObject({ role: "progressbar", value: { text: "importing" }, liveRegion: "polite" }); + expect( + getOpdsDownloadAccessibility({ + status: "error", + requestId: 1, + publicationTitle: "Book", + error: "import-failed", + }), + ).toMatchObject({ role: "alert", liveRegion: "assertive" }); + }); +}); diff --git a/packages/app-expo/src/screens/library/opds-download-controller.ts b/packages/app-expo/src/screens/library/opds-download-controller.ts new file mode 100644 index 000000000..307f6ae68 --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-download-controller.ts @@ -0,0 +1,111 @@ +import { OpdsError, type OpdsErrorCode } from "@readany/core"; +import type { OpdsDownloadState, OpdsViewAction } from "./opds-view-state"; + +type DownloadEvent = Extract; +type DownloadPhase = "idle" | "preparing" | "downloading" | "importing"; + +interface DownloadResult { + readonly importedCount: number; +} + +interface DownloadControllerOptions { + onEvent(event: DownloadEvent): void; +} + +interface DownloadStart { + readonly publicationTitle: string; + prepare(signal: AbortSignal): Promise; + execute(options: { + credentials: TCredentials; + signal: AbortSignal; + onProgress(loaded: number, total: number): void; + onImportStart(): void; + }): Promise; +} + +function errorCode(error: unknown): OpdsErrorCode { + return error instanceof OpdsError ? error.code : "download-failed"; +} + +export function createOpdsDownloadController({ onEvent }: DownloadControllerOptions) { + let sequence = 0; + let active: + | { requestId: number; controller: AbortController; phase: Exclude } + | undefined; + + const isCurrent = (requestId: number) => + active?.requestId === requestId && !active.controller.signal.aborted; + + return { + async start(operation: DownloadStart): Promise { + if (active) throw new OpdsError("download-in-progress"); + const requestId = ++sequence; + const controller = new AbortController(); + active = { requestId, controller, phase: "preparing" }; + onEvent({ type: "downloadStarted", requestId, publicationTitle: operation.publicationTitle }); + + try { + const credentials = await operation.prepare(controller.signal); + if (!isCurrent(requestId)) return undefined; + active.phase = "downloading"; + const result = await operation.execute({ + credentials, + signal: controller.signal, + onProgress: (loaded, total) => { + if (!isCurrent(requestId) || active?.phase !== "downloading") return; + onEvent({ type: "downloadProgress", requestId, loaded, total }); + }, + onImportStart: () => { + if (!isCurrent(requestId) || active?.phase !== "downloading") { + throw new OpdsError("cancelled"); + } + active.phase = "importing"; + onEvent({ type: "downloadImporting", requestId }); + }, + }); + if (!isCurrent(requestId)) return undefined; + onEvent({ type: "downloadSucceeded", requestId, importedCount: result.importedCount }); + return result; + } catch (error) { + if (!isCurrent(requestId)) return undefined; + onEvent({ type: "downloadFailed", requestId, error: errorCode(error) }); + throw error; + } finally { + if (active?.requestId === requestId) active = undefined; + } + }, + cancel(): boolean { + if (!active || active.phase === "importing") return false; + const { requestId, controller } = active; + active = undefined; + controller.abort(); + onEvent({ type: "downloadCancelled", requestId }); + return true; + }, + getPhase(): DownloadPhase { + return active?.phase ?? "idle"; + }, + }; +} + +export function getOpdsDownloadAccessibility(state: OpdsDownloadState): { + role?: "progressbar" | "status" | "alert"; + value?: { min?: number; max?: number; now?: number; text?: string }; + liveRegion?: "polite" | "assertive"; +} { + if (state.status === "downloading") { + return state.total > 0 + ? { + role: "progressbar", + value: { min: 0, max: state.total, now: Math.min(state.loaded, state.total) }, + liveRegion: "polite", + } + : { role: "progressbar", value: { text: "downloading" }, liveRegion: "polite" }; + } + if (state.status === "importing") { + return { role: "progressbar", value: { text: "importing" }, liveRegion: "polite" }; + } + if (state.status === "success") return { role: "status", liveRegion: "polite" }; + if (state.status === "error") return { role: "alert", liveRegion: "assertive" }; + return {}; +} diff --git a/packages/app-expo/src/screens/library/opds-feed-rows.test.ts b/packages/app-expo/src/screens/library/opds-feed-rows.test.ts new file mode 100644 index 000000000..7b9b8bbc9 --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-feed-rows.test.ts @@ -0,0 +1,29 @@ +import type { OpdsFeed } from "@readany/core"; +import { describe, expect, it } from "vitest"; +import { createOpdsFeedRows } from "./opds-feed-rows"; + +describe("OPDS virtual feed rows", () => { + it("flattens a dense catalog into stable rows for lazy rendering", () => { + const feed: OpdsFeed = { + title: "Dense shelf", + navigation: [], + facets: [], + groups: [], + publications: Array.from({ length: 200 }, (_, index) => ({ + id: `book-${index}`, + title: `Book ${index}`, + authors: [], + subjects: [], + images: [], + acquisitions: [], + readingOrder: [], + })), + }; + + const rows = createOpdsFeedRows(feed); + + expect(rows).toHaveLength(202); + expect(rows.filter((row) => row.kind === "publication")).toHaveLength(200); + expect(new Set(rows.map((row) => row.key))).toHaveLength(rows.length); + }); +}); diff --git a/packages/app-expo/src/screens/library/opds-feed-rows.ts b/packages/app-expo/src/screens/library/opds-feed-rows.ts new file mode 100644 index 000000000..b5643560d --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-feed-rows.ts @@ -0,0 +1,85 @@ +import type { OpdsFeed, OpdsPublication } from "@readany/core"; + +export type OpdsFeedRow = + | { kind: "intro"; key: string; feed: OpdsFeed } + | { kind: "section"; key: string; title: string } + | { kind: "link"; key: string; title: string; url: string; icon: boolean } + | { + kind: "publication"; + key: string; + publication: OpdsPublication; + keyPrefix: string; + } + | { kind: "empty"; key: string } + | { kind: "pagination"; key: string; previousUrl?: string; nextUrl?: string }; + +export function createOpdsFeedRows(feed: OpdsFeed): OpdsFeedRow[] { + const rows: OpdsFeedRow[] = [{ kind: "intro", key: "intro", feed }]; + if (feed.navigation.length > 0) { + rows.push({ kind: "section", key: "navigation-title", title: "collections" }); + feed.navigation.forEach((item, index) => + rows.push({ + kind: "link", + key: `navigation:${index}:${item.url}`, + title: item.title, + url: item.url, + icon: true, + }), + ); + } + feed.facets.forEach((facet, facetIndex) => { + rows.push({ kind: "section", key: `facet-title:${facetIndex}`, title: facet.title }); + facet.links.forEach((link, linkIndex) => + rows.push({ + kind: "link", + key: `facet:${facetIndex}:${linkIndex}:${link.url}`, + title: link.title ?? link.url, + url: link.url, + icon: false, + }), + ); + }); + if (feed.publications.length > 0) { + rows.push({ kind: "section", key: "books-title", title: "books" }); + feed.publications.forEach((publication, index) => + rows.push({ + kind: "publication", + key: `publication:${publication.id ?? index}:${publication.title}`, + publication, + keyPrefix: "publication", + }), + ); + } + feed.groups.forEach((group, groupIndex) => { + rows.push({ kind: "section", key: `group-title:${groupIndex}`, title: group.title }); + group.navigation.forEach((item, itemIndex) => + rows.push({ + kind: "link", + key: `group-link:${groupIndex}:${itemIndex}:${item.url}`, + title: item.title, + url: item.url, + icon: false, + }), + ); + group.publications.forEach((publication, publicationIndex) => + rows.push({ + kind: "publication", + key: `group-publication:${groupIndex}:${publication.id ?? publicationIndex}`, + publication, + keyPrefix: `group-${groupIndex}`, + }), + ); + }); + if (feed.navigation.length === 0 && feed.publications.length === 0 && feed.groups.length === 0) { + rows.push({ kind: "empty", key: "empty" }); + } + if (feed.previousUrl || feed.nextUrl) { + rows.push({ + kind: "pagination", + key: "pagination", + ...(feed.previousUrl ? { previousUrl: feed.previousUrl } : {}), + ...(feed.nextUrl ? { nextUrl: feed.nextUrl } : {}), + }); + } + return rows; +} 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-expo/src/screens/library/opds-mobile-runtime.test.ts b/packages/app-expo/src/screens/library/opds-mobile-runtime.test.ts new file mode 100644 index 000000000..b5f69d5f6 --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-mobile-runtime.test.ts @@ -0,0 +1,37 @@ +import type { IPlatformService } from "@readany/core/services"; +import { describe, expect, it, vi } from "vitest"; +import { createOpdsMobileRuntime } from "./opds-mobile-runtime"; + +describe("mobile OPDS runtime", () => { + it("shares one catalog store so session-only credentials survive screen navigation", async () => { + let persisted: string | null = null; + const platform = { + kvGetItem: vi.fn(async () => persisted), + kvSetItem: vi.fn(async (_key: string, value: string) => { + persisted = value; + }), + } as unknown as IPlatformService; + const runtime = createOpdsMobileRuntime(() => platform); + await runtime.ensureCatalogsLoaded(); + const firstScreenStore = runtime.getCatalogStore(); + + const catalog = await firstScreenStore.addCatalog({ + name: "Private catalog", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "session-secret", + }); + + await runtime.ensureCatalogsLoaded(); + const browserStore = runtime.getCatalogStore(); + expect(browserStore).toBe(firstScreenStore); + expect(await browserStore.getCredentials(catalog.id)).toMatchObject({ + username: "reader", + password: "session-secret", + catalogOrigin: "https://catalog.test", + }); + expect(platform.kvGetItem).toHaveBeenCalledOnce(); + expect(runtime.getClient()).toBe(runtime.getClient()); + }); +}); diff --git a/packages/app-expo/src/screens/library/opds-mobile-runtime.ts b/packages/app-expo/src/screens/library/opds-mobile-runtime.ts new file mode 100644 index 000000000..b5a272ee2 --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-mobile-runtime.ts @@ -0,0 +1,5 @@ +import { createOpdsRuntime, getPlatformService } from "@readany/core"; + +export const createOpdsMobileRuntime = createOpdsRuntime; + +export const opdsMobileRuntime = createOpdsMobileRuntime(getPlatformService); diff --git a/packages/app-expo/src/screens/library/opds-view-state.test.ts b/packages/app-expo/src/screens/library/opds-view-state.test.ts new file mode 100644 index 000000000..1e624bf66 --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-view-state.test.ts @@ -0,0 +1,303 @@ +import type { OpdsFeed } from "@readany/core"; +import { describe, expect, it } from "vitest"; +import { + canSearchOpds, + createInitialOpdsViewState, + createOpdsBrowserRouteParams, + getOpdsPagination, + opdsViewReducer, + selectOpdsFeed, + shouldEditOpdsCredentials, +} from "./opds-view-state"; + +function feed(overrides: Partial = {}): OpdsFeed { + return { + title: "Catalog", + navigation: [], + publications: [], + groups: [], + facets: [], + ...overrides, + }; +} + +function readyState() { + const loading = opdsViewReducer(createInitialOpdsViewState(), { + type: "loadStarted", + requestId: 1, + url: "https://catalog.test/root", + mode: "replace", + }); + return opdsViewReducer(loading, { + type: "loadSucceeded", + requestId: 1, + feed: feed(), + }); +} + +describe("OPDS mobile view state", () => { + it("starts idle with no visible feed or active download", () => { + const state = createInitialOpdsViewState(); + + expect(state.content).toEqual({ status: "idle" }); + expect(state.download).toEqual({ status: "idle" }); + expect(selectOpdsFeed(state)).toBeUndefined(); + }); + + it("uses loading for the first catalog request", () => { + const state = opdsViewReducer(createInitialOpdsViewState(), { + type: "loadStarted", + requestId: 1, + url: "https://catalog.test/root", + mode: "replace", + }); + + expect(state.content).toMatchObject({ + status: "loading", + requestId: 1, + pending: { url: "https://catalog.test/root", mode: "replace" }, + }); + }); + + it("retains the ready feed while refresh is in flight", () => { + const ready = readyState(); + const refreshing = opdsViewReducer(ready, { + type: "loadStarted", + requestId: 2, + url: "https://catalog.test/root", + mode: "refresh", + }); + + expect(refreshing.content.status).toBe("ready"); + expect(refreshing.content).toMatchObject({ refreshing: true, requestId: 2 }); + expect(selectOpdsFeed(refreshing)?.title).toBe("Catalog"); + }); + + it("pushes feed navigation and returns through its history", () => { + const ready = readyState(); + const loadingChild = opdsViewReducer(ready, { + type: "loadStarted", + requestId: 2, + url: "https://catalog.test/fiction", + mode: "push", + }); + const child = opdsViewReducer(loadingChild, { + type: "loadSucceeded", + requestId: 2, + feed: feed({ title: "Fiction" }), + }); + + expect(child.content).toMatchObject({ + status: "ready", + currentUrl: "https://catalog.test/fiction", + history: ["https://catalog.test/root"], + }); + + const loadingRoot = opdsViewReducer(child, { + type: "loadStarted", + requestId: 3, + url: "https://catalog.test/root", + mode: "back", + }); + const root = opdsViewReducer(loadingRoot, { + type: "loadSucceeded", + requestId: 3, + feed: feed({ title: "Catalog again" }), + }); + + expect(root.content).toMatchObject({ + status: "ready", + currentUrl: "https://catalog.test/root", + history: [], + }); + }); + + it("shows search only when the feed advertises it", () => { + expect(canSearchOpds(readyState())).toBe(false); + + const loading = opdsViewReducer(createInitialOpdsViewState(), { + type: "loadStarted", + requestId: 1, + url: "https://catalog.test/root", + mode: "replace", + }); + const searchable = opdsViewReducer(loading, { + type: "loadSucceeded", + requestId: 1, + feed: feed({ + search: { kind: "template", urlTemplate: "https://catalog.test?q={searchTerms}" }, + }), + }); + + expect(canSearchOpds(searchable)).toBe(true); + }); + + it("exposes only advertised previous and next pagination links", () => { + const loading = opdsViewReducer(createInitialOpdsViewState(), { + type: "loadStarted", + requestId: 1, + url: "https://catalog.test/page/2", + mode: "replace", + }); + const ready = opdsViewReducer(loading, { + type: "loadSucceeded", + requestId: 1, + feed: feed({ + previousUrl: "https://catalog.test/page/1", + nextUrl: "https://catalog.test/page/3", + }), + }); + + expect(getOpdsPagination(ready)).toEqual({ + previousUrl: "https://catalog.test/page/1", + nextUrl: "https://catalog.test/page/3", + }); + }); + + it("turns authentication failures into an edit-credentials recovery", () => { + const failed = opdsViewReducer(readyState(), { + type: "loadStarted", + requestId: 2, + url: "https://catalog.test/private", + mode: "push", + }); + const error = opdsViewReducer(failed, { + type: "loadFailed", + requestId: 2, + error: "unauthorized", + }); + + expect(error.content.status).toBe("error"); + expect(shouldEditOpdsCredentials(error)).toBe(true); + }); + + it("retries the failed request with a new request id", () => { + const loading = opdsViewReducer(createInitialOpdsViewState(), { + type: "loadStarted", + requestId: 1, + url: "https://catalog.test/root", + mode: "replace", + }); + const failed = opdsViewReducer(loading, { + type: "loadFailed", + requestId: 1, + error: "unreachable", + }); + const retrying = opdsViewReducer(failed, { type: "retryStarted", requestId: 2 }); + + expect(retrying.content).toMatchObject({ + status: "loading", + requestId: 2, + pending: { url: "https://catalog.test/root", mode: "replace" }, + }); + }); + + it("tracks download progress and imported completion", () => { + let state = opdsViewReducer(readyState(), { + type: "downloadStarted", + requestId: 7, + publicationTitle: "A Book", + }); + state = opdsViewReducer(state, { + type: "downloadProgress", + requestId: 7, + loaded: 40, + total: 100, + }); + expect(state.download).toMatchObject({ status: "downloading", loaded: 40, total: 100 }); + + state = opdsViewReducer(state, { type: "downloadImporting", requestId: 7 }); + expect(state.download).toMatchObject({ status: "importing", publicationTitle: "A Book" }); + expect(opdsViewReducer(state, { type: "downloadCancelled", requestId: 7 }).download).toEqual( + state.download, + ); + + state = opdsViewReducer(state, { + type: "downloadSucceeded", + requestId: 7, + importedCount: 1, + }); + expect(state.download).toEqual({ + status: "success", + requestId: 7, + publicationTitle: "A Book", + importedCount: 1, + }); + }); + + it("resets cancellation and ignores late progress from the cancelled request", () => { + const downloading = opdsViewReducer(readyState(), { + type: "downloadStarted", + requestId: 7, + publicationTitle: "A Book", + }); + const cancelled = opdsViewReducer(downloading, { + type: "downloadCancelled", + requestId: 7, + }); + const staleProgress = opdsViewReducer(cancelled, { + type: "downloadProgress", + requestId: 7, + loaded: 100, + total: 100, + }); + + expect(staleProgress.download).toEqual({ status: "idle" }); + }); + + it("ignores stale feed responses after a newer request starts", () => { + const first = opdsViewReducer(createInitialOpdsViewState(), { + type: "loadStarted", + requestId: 1, + url: "https://catalog.test/old", + mode: "replace", + }); + const latest = opdsViewReducer(first, { + type: "loadStarted", + requestId: 2, + url: "https://catalog.test/latest", + mode: "replace", + }); + const stale = opdsViewReducer(latest, { + type: "loadSucceeded", + requestId: 1, + feed: feed({ title: "Old response" }), + }); + + expect(stale).toBe(latest); + }); + + it("restores the previous ready feed when an in-flight or failed push is cancelled", () => { + const ready = readyState(); + const pushing = opdsViewReducer(ready, { + type: "loadStarted", + requestId: 2, + url: "https://catalog.test/child", + mode: "push", + }); + const restoredInFlight = opdsViewReducer(pushing, { type: "loadCancelled", requestId: 2 }); + expect(restoredInFlight.content).toMatchObject({ + status: "ready", + currentUrl: "https://catalog.test/root", + }); + + const failed = opdsViewReducer(pushing, { + type: "loadFailed", + requestId: 2, + error: "unreachable", + }); + const restoredFailed = opdsViewReducer(failed, { type: "loadCancelled", requestId: 2 }); + expect(restoredFailed.content).toMatchObject({ + status: "ready", + currentUrl: "https://catalog.test/root", + }); + }); + + it("creates serializable route params containing only the catalog id", () => { + const params = createOpdsBrowserRouteParams("catalog-id"); + + expect(params).toEqual({ catalogId: "catalog-id" }); + expect(JSON.parse(JSON.stringify(params))).toEqual(params); + expect(JSON.stringify(params)).not.toContain("password"); + }); +}); diff --git a/packages/app-expo/src/screens/library/opds-view-state.ts b/packages/app-expo/src/screens/library/opds-view-state.ts new file mode 100644 index 000000000..f63853e0c --- /dev/null +++ b/packages/app-expo/src/screens/library/opds-view-state.ts @@ -0,0 +1,18 @@ +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-expo/src/screens/library/useOpdsDownload.test.ts b/packages/app-expo/src/screens/library/useOpdsDownload.test.ts new file mode 100644 index 000000000..971d55677 --- /dev/null +++ b/packages/app-expo/src/screens/library/useOpdsDownload.test.ts @@ -0,0 +1,299 @@ +import type { + ImportBooksResult, + OpdsAcquisition, + OpdsAssetResponse, + OpdsPublication, +} from "@readany/core"; +import { describe, expect, it, vi } from "vitest"; +import type { MobileImportFile } from "../../stores/library-store"; +vi.mock("../../stores/library-store", () => ({ useLibraryStore: vi.fn() })); +import { createOpdsDownloadAdapter, createOpdsDownloadUnmountGuard } from "./useOpdsDownload"; + +const selected: OpdsAcquisition = { + rel: ["http://opds-spec.org/acquisition"], + url: "https://catalog.test/book.epub", + type: "application/epub+zip", + format: "epub", +}; + +const publication: OpdsPublication = { + title: "../../Catalog Book", + authors: ["Catalog Author"], + publisher: "Catalog Press", + language: "en", + identifier: "9781234567897", + published: "2025", + description: "Catalog description", + subjects: ["Subject A", "Subject B"], + images: [], + acquisitions: [selected], + readingOrder: [], +}; + +function asset(bytes = new Uint8Array([1, 2, 3])): OpdsAssetResponse { + const response = new Response(bytes, { + headers: { "Content-Length": String(bytes.byteLength) }, + }); + return Object.assign(response, { + cancel: vi.fn(async (_reason?: unknown) => undefined), + }) as unknown as OpdsAssetResponse; +} + +function dependencies(overrides: Record = {}) { + const platform = { + writeFile: vi.fn(async (_path: string, _data: Uint8Array) => undefined), + deleteFile: vi.fn(async (_path: string) => undefined), + mkdir: vi.fn(async (_path: string) => undefined), + joinPath: vi.fn(async (...parts: string[]) => parts.join("/")), + }; + const importBooks = vi.fn( + async (_files: MobileImportFile[], _options?: { transactional?: boolean }) => + ({ + imported: [{ id: "book-id" }], + skippedDuplicates: [], + failures: [], + }) as unknown as ImportBooksResult, + ); + return { + platform, + client: { + fetchAsset: vi.fn( + async (_url: string, _origin: string, _credentials?: unknown, _signal?: AbortSignal) => + asset(), + ), + }, + importBooks, + getTempDirectory: vi.fn(async () => "file:///cache"), + createId: () => "fixed-id", + ...overrides, + }; +} + +describe("mobile OPDS download adapter", () => { + it("cancels on unmount and suppresses later hook state updates", () => { + const cancel = vi.fn(); + const update = vi.fn(); + const lifecycle = createOpdsDownloadUnmountGuard(cancel); + + lifecycle.runIfMounted(update); + lifecycle.dispose(); + lifecycle.runIfMounted(update); + lifecycle.dispose(); + + expect(update).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("imports through the actual mobile file signature and cleans the temp file once", async () => { + const deps = dependencies(); + const run = createOpdsDownloadAdapter(deps as never); + + const result = await run({ + publication, + acquisition: selected, + catalogOrigin: "https://catalog.test", + }); + + expect(deps.importBooks).toHaveBeenCalledOnce(); + expect(deps.importBooks.mock.calls[0]?.[1]).toEqual({ transactional: true }); + const [[files]] = deps.importBooks.mock.calls; + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + name: "Catalog Book.epub", + metadata: { + title: "../../Catalog Book", + author: "Catalog Author", + publisher: "Catalog Press", + subjects: ["Subject A", "Subject B"], + }, + }); + expect(files[0].metadata).not.toHaveProperty("tags"); + expect(files[0].uri).toMatch(/^file:\/\/\/cache\/readany-opds-import\/opds-/); + expect(files[0].uri).toMatch(/\.epub$/); + expect(deps.platform.deleteFile).toHaveBeenCalledExactlyOnceWith(files[0].uri); + expect(result.cleanupFailed).toBe(false); + }); + + it("uses the React Native-safe ID path without a createId test override", async () => { + const originalCrypto = globalThis.crypto; + vi.stubGlobal("crypto", { + getRandomValues(bytes: Uint8Array) { + bytes.fill(7); + return bytes; + }, + }); + const deps = dependencies(); + const { createId: _createId, ...productionDeps } = deps; + const run = createOpdsDownloadAdapter(productionDeps as never); + + await expect( + run({ publication, catalogOrigin: "https://catalog.test" }), + ).resolves.toBeDefined(); + expect(deps.platform.writeFile.mock.calls[0]?.[0]).toContain( + "07070707-0707-4707-8707-070707070707", + ); + vi.stubGlobal("crypto", originalCrypto); + }); + + it.each(["getTempDirectory", "joinPath", "mkdir"] as const)( + "maps %s setup failures without trying cleanup before a path exists", + async (operation) => { + const deps = dependencies(); + if (operation === "getTempDirectory") { + deps.getTempDirectory.mockRejectedValueOnce(new Error("C:/private/temp detail")); + } else { + deps.platform[operation].mockRejectedValueOnce(new Error("C:/private/temp detail")); + } + const run = createOpdsDownloadAdapter(deps as never); + + const error = await run({ publication, catalogOrigin: "https://catalog.test" }).catch( + (value: unknown) => value, + ); + + expect(error).toMatchObject({ code: "download-failed" }); + expect(String(error)).not.toContain("private"); + expect(deps.platform.deleteFile).not.toHaveBeenCalled(); + }, + ); + + it("maps store failures to import-failed and preserves that error when cleanup also fails", async () => { + const onCleanupError = vi.fn(); + const deps = dependencies({ + importBooks: vi.fn(async () => ({ + imported: [], + skippedDuplicates: [], + failures: [{ name: "book.epub", error: "database secret detail" }], + })), + onCleanupError, + }); + deps.platform.deleteFile.mockRejectedValueOnce(new Error("cleanup detail")); + const run = createOpdsDownloadAdapter(deps as never); + + const error = await run({ + publication, + catalogOrigin: "https://catalog.test", + }).catch((value: unknown) => value); + + expect(error).toMatchObject({ code: "import-failed" }); + expect(String(error)).not.toContain("database secret detail"); + expect(deps.platform.deleteFile).toHaveBeenCalledOnce(); + expect(onCleanupError).toHaveBeenCalledWith(expect.any(Error), error); + }); + + it("never lets a throwing cleanup reporter mask the primary error", async () => { + const deps = dependencies({ + importBooks: vi.fn(async () => { + throw new Error("database detail"); + }), + onCleanupError: vi.fn(() => { + throw new Error("reporter detail"); + }), + }); + deps.platform.deleteFile.mockRejectedValueOnce(new Error("cleanup detail")); + const run = createOpdsDownloadAdapter(deps as never); + + await expect(run({ publication, catalogOrigin: "https://catalog.test" })).rejects.toMatchObject( + { + code: "import-failed", + }, + ); + }); + + it("cleans once on cancellation before import", async () => { + const controller = new AbortController(); + const deps = dependencies(); + deps.platform.writeFile.mockImplementationOnce(async () => { + controller.abort(); + }); + const run = createOpdsDownloadAdapter(deps as never); + + await expect( + run({ + publication, + catalogOrigin: "https://catalog.test", + signal: controller.signal, + }), + ).rejects.toMatchObject({ code: "cancelled" }); + expect(deps.importBooks).not.toHaveBeenCalled(); + expect(deps.platform.deleteFile).toHaveBeenCalledOnce(); + }); + + it("finishes an import atomically when cancellation arrives after import begins", async () => { + const controller = new AbortController(); + let finishImport!: () => void; + const importGate = new Promise((resolve) => { + finishImport = resolve; + }); + const deps = dependencies({ + importBooks: vi.fn(async () => { + controller.abort(); + await importGate; + return { imported: [{ id: "book-id" }], skippedDuplicates: [], failures: [] }; + }), + }); + const run = createOpdsDownloadAdapter(deps as never); + const pending = run({ + publication, + catalogOrigin: "https://catalog.test", + signal: controller.signal, + }); + finishImport(); + + await expect(pending).resolves.toMatchObject({ cleanupFailed: false }); + expect(deps.platform.deleteFile).toHaveBeenCalledOnce(); + }); + + it("announces the noncancellable import boundary synchronously before touching the store", async () => { + const order: string[] = []; + const deps = dependencies({ + importBooks: vi.fn(async () => { + order.push("import"); + return { imported: [{ id: "book-id" }], skippedDuplicates: [], failures: [] }; + }), + }); + const run = createOpdsDownloadAdapter(deps as never); + + await run({ + publication, + catalogOrigin: "https://catalog.test", + onImportStart: () => order.push("boundary"), + }); + + expect(order).toEqual(["boundary", "import"]); + }); + + it("reports a cleanup-only failure without turning a successful managed import into failure", async () => { + const onCleanupError = vi.fn(); + const deps = dependencies({ onCleanupError }); + deps.platform.deleteFile.mockRejectedValueOnce(new Error("cleanup detail")); + const run = createOpdsDownloadAdapter(deps as never); + + const result = await run({ publication, catalogOrigin: "https://catalog.test" }); + + expect(result.cleanupFailed).toBe(true); + expect(onCleanupError).toHaveBeenCalledWith(expect.any(Error), undefined); + }); + + it("uses collision-proof names for concurrent downloads and new names on retry", async () => { + const deps = dependencies(); + const fetchAsset = deps.client.fetchAsset; + fetchAsset.mockRejectedValueOnce(new Error("offline")); + const run = createOpdsDownloadAdapter(deps as never); + + await expect(run({ publication, catalogOrigin: "https://catalog.test" })).rejects.toMatchObject( + { + code: "download-failed", + }, + ); + await Promise.all([ + run({ publication, catalogOrigin: "https://catalog.test" }), + run({ publication, catalogOrigin: "https://catalog.test" }), + ]); + + const paths = deps.platform.writeFile.mock.calls.map(([path]) => path); + expect(new Set(paths).size).toBe(2); + const cleaned = deps.platform.deleteFile.mock.calls.map(([path]) => path); + expect(new Set(cleaned).size).toBe(3); + expect(deps.platform.deleteFile).toHaveBeenCalledTimes(3); + }); +}); diff --git a/packages/app-expo/src/screens/library/useOpdsDownload.ts b/packages/app-expo/src/screens/library/useOpdsDownload.ts new file mode 100644 index 000000000..87d32f63a --- /dev/null +++ b/packages/app-expo/src/screens/library/useOpdsDownload.ts @@ -0,0 +1,174 @@ +import { + type ImportBooksResult, + type OpdsAcquisition, + OpdsClient, + type OpdsCredentials, + type OpdsDownloadProgress, + OpdsError, + type OpdsPublication, + downloadOpdsAcquisition, + listSupportedAcquisitions, + toBookMeta, +} from "@readany/core"; +import { type IPlatformService, getPlatformService } from "@readany/core/services"; +import { generateId } from "@readany/core/utils"; +import { useCallback } from "react"; +import { type MobileImportFile, useLibraryStore } from "../../stores/library-store"; + +type OpdsDownloadPlatform = Pick< + IPlatformService, + "writeFile" | "deleteFile" | "mkdir" | "joinPath" +>; + +export interface OpdsDownloadRequest { + publication: OpdsPublication; + acquisition?: OpdsAcquisition; + catalogOrigin: string; + credentials?: OpdsCredentials; + signal?: AbortSignal; + onProgress?: (progress: OpdsDownloadProgress) => void; + onImportStart?: () => void; +} + +export interface OpdsImportDownloadResult { + importResult: ImportBooksResult; + cleanupFailed: boolean; +} + +export interface OpdsDownloadAdapterDependencies { + platform: OpdsDownloadPlatform; + client: Pick; + importBooks( + files: MobileImportFile[], + options?: { transactional?: boolean }, + ): Promise; + getTempDirectory(): Promise; + createId?(): string; + onCleanupError?(cleanupError: unknown, primaryError: unknown): void; +} + +let temporaryFileSequence = 0; + +export function createOpdsDownloadUnmountGuard(cancel: () => void) { + let mounted = true; + let disposed = false; + return { + runIfMounted(update: () => void): void { + if (mounted) update(); + }, + dispose(): void { + if (disposed) return; + disposed = true; + mounted = false; + cancel(); + }, + }; +} + +function nextTemporaryName(format: string, createId?: () => string): string { + temporaryFileSequence += 1; + const id = createId?.() ?? generateId(); + return `opds-${Date.now()}-${temporaryFileSequence}-${id}.${format}`; +} + +function selectedFormat(request: OpdsDownloadRequest) { + const supported = listSupportedAcquisitions(request.publication); + if (!request.acquisition) { + if (supported.length === 1) return supported[0]; + throw new OpdsError("unsupported-acquisition"); + } + const selected = supported.find( + (choice) => + choice.url === request.acquisition?.url && + choice.type === request.acquisition.type && + choice.rel.join("\u0000") === request.acquisition.rel.join("\u0000"), + ); + if (!selected) throw new OpdsError("unsupported-acquisition"); + return selected; +} + +export function createOpdsDownloadAdapter(dependencies: OpdsDownloadAdapterDependencies) { + return async (request: OpdsDownloadRequest): Promise => { + const choice = selectedFormat(request); + let temporaryPath: string; + try { + const tempRoot = await dependencies.getTempDirectory(); + const workspace = await dependencies.platform.joinPath(tempRoot, "readany-opds-import"); + await dependencies.platform.mkdir(workspace); + temporaryPath = await dependencies.platform.joinPath( + workspace, + nextTemporaryName(choice.format, dependencies.createId), + ); + } catch { + throw new OpdsError("download-failed"); + } + + let primaryError: unknown; + let importResult: ImportBooksResult | undefined; + let cleanupFailed = false; + try { + const downloaded = await downloadOpdsAcquisition({ + ...request, + acquisition: request.acquisition, + client: dependencies.client, + platform: dependencies.platform, + destinationPath: temporaryPath, + }); + if (request.signal?.aborted) throw new OpdsError("cancelled"); + request.onImportStart?.(); + try { + importResult = await dependencies.importBooks( + [ + { + uri: temporaryPath, + name: downloaded.suggestedFileName, + metadata: toBookMeta(request.publication), + }, + ], + { transactional: true }, + ); + } catch { + throw new OpdsError("import-failed"); + } + if (importResult.failures.length > 0) throw new OpdsError("import-failed"); + } catch (error) { + primaryError = error; + } finally { + try { + await dependencies.platform.deleteFile(temporaryPath); + } catch (cleanupError) { + cleanupFailed = true; + try { + dependencies.onCleanupError?.(cleanupError, primaryError); + } catch { + // Cleanup reporting is best effort and must never replace the operation result. + } + } + } + + if (primaryError) throw primaryError; + if (!importResult) throw new OpdsError("import-failed"); + return { importResult, cleanupFailed }; + }; +} + +export function useOpdsDownload() { + const importBooks = useLibraryStore((state) => state.importBooks); + const download = useCallback( + async (request: OpdsDownloadRequest) => { + const platform = getPlatformService(); + return createOpdsDownloadAdapter({ + platform, + client: new OpdsClient(platform), + importBooks, + getTempDirectory: async () => (await import("expo-file-system")).Paths.cache.uri, + onCleanupError: () => { + console.warn("[OPDS] Temporary download cleanup failed."); + }, + })(request); + }, + [importBooks], + ); + + return { download }; +} diff --git a/packages/app-expo/src/stores/library-store.opds.test.ts b/packages/app-expo/src/stores/library-store.opds.test.ts new file mode 100644 index 000000000..e34c803df --- /dev/null +++ b/packages/app-expo/src/stores/library-store.opds.test.ts @@ -0,0 +1,261 @@ +import type { Book } from "@readany/core/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const existingPaths = new Set(); + const platform = { + getAppDataDir: vi.fn(async () => "/app"), + joinPath: vi.fn(async (...parts: string[]) => parts.join("/")), + mkdir: vi.fn(async () => undefined), + readFile: vi.fn(async () => new Uint8Array([1, 2, 3])), + writeFile: vi.fn(async () => undefined), + deleteFile: vi.fn(async (_path: string) => undefined), + exists: vi.fn(async (path: string) => existingPaths.has(path)), + }; + const db = { + initDatabase: vi.fn(async () => undefined), + insertBook: vi.fn(async () => undefined), + updateBook: vi.fn(async () => undefined), + getDeletedBookByFileHash: vi.fn(async () => null as Book | null), + getDeletedBookByTitle: vi.fn(async () => null as Book | null), + }; + return { + platform, + db, + getInfoAsync: vi.fn(async () => ({ + exists: true, + isDirectory: false, + size: 3, + md5: "same-hash", + })), + saveCover: vi.fn(async (bookId: string) => `covers/${bookId}.jpg`), + copied: [] as string[], + existingPaths, + }; +}); + +vi.mock("@/lib/book/cover-storage", () => ({ + getCoverFileExtension: () => "jpg", + saveCoverBytesToAppData: mocks.saveCover, +})); +vi.mock("@/lib/book/imported-book-meta", () => ({ + shouldPersistEmbeddedCover: () => true, + buildImportedBookMeta: ({ + existing, + opds, + embedded, + fallbackTitle, + }: { + existing?: Partial; + opds?: Partial; + embedded?: Partial; + fallbackTitle: string; + }) => ({ + ...embedded, + ...opds, + ...existing, + title: existing?.title ?? opds?.title ?? embedded?.title ?? fallbackTitle, + author: existing?.author ?? opds?.author ?? embedded?.author ?? "", + }), +})); +vi.mock("@/lib/book/metadata-extractor", () => ({ + createRangeReadableFile: vi.fn(), + extractBookMetadata: vi.fn(async () => ({ + title: "Embedded title", + author: "Embedded author", + coverBytes: new Uint8Array([9]), + coverMimeType: "image/jpeg", + })), + extractBookMetadataFromFile: vi.fn(), +})); +vi.mock("@/lib/rag/auto-vectorize-service", () => ({ queueBook: vi.fn() })); +vi.mock("@readany/core/db/database", () => mocks.db); +vi.mock("@readany/core/db/write-retry", () => ({ + runWithDbRetry: (operation: () => Promise) => operation(), +})); +vi.mock("@readany/core/services", () => ({ getPlatformService: () => mocks.platform })); +vi.mock("./persist", () => ({ debouncedSave: vi.fn(), loadFromFS: vi.fn() })); +vi.mock("./vector-model-store", () => ({ + useVectorModelStore: { + getState: () => ({ + autoVectorizeOnImport: false, + vectorModelEnabled: false, + hasVectorCapability: () => false, + }), + }, +})); +vi.mock("expo-file-system/legacy", () => ({ getInfoAsync: mocks.getInfoAsync })); +vi.mock("expo-file-system", () => ({ + File: class { + exists: boolean; + constructor(private readonly path: string) { + this.exists = mocks.existingPaths.has(path); + } + copy(destination: { path: string }) { + mocks.copied.push(destination.path); + } + delete() {} + }, +})); + +import { useLibraryStore } from "./library-store"; + +function book(overrides: Partial = {}): Book { + return { + id: "existing-id", + filePath: "books/existing-id.epub", + format: "epub", + meta: { title: "Saved title", author: "Saved author" }, + progress: 0.5, + isVectorized: false, + vectorizeProgress: 0, + tags: ["user-tag"], + fileHash: "same-hash", + syncStatus: "local", + addedAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +describe("mobile transactional OPDS imports", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.copied.length = 0; + mocks.existingPaths.clear(); + mocks.db.getDeletedBookByFileHash.mockResolvedValue(null); + mocks.db.insertBook.mockResolvedValue(undefined); + mocks.db.updateBook.mockResolvedValue(undefined); + useLibraryStore.setState({ books: [], isImporting: false }); + }); + + it("uses the real mobile MD5 to skip an existing duplicate", async () => { + useLibraryStore.setState({ books: [book()] }); + + const result = await useLibraryStore + .getState() + .importBooks([{ uri: "file:///cache/book.epub", name: "Catalog.epub" }], { + transactional: true, + }); + + expect(mocks.getInfoAsync).toHaveBeenCalledWith("file:///cache/book.epub", { md5: true }); + expect(result.skippedDuplicates).toHaveLength(1); + expect(result.imported).toHaveLength(0); + expect(mocks.copied).toHaveLength(0); + }); + + it("rolls back the managed book and cover when durable insertion fails", async () => { + mocks.db.insertBook.mockRejectedValueOnce(new Error("insert failed")); + mocks.getInfoAsync.mockResolvedValueOnce({ + exists: true, + isDirectory: false, + size: 3, + md5: "new-hash", + }); + + const result = await useLibraryStore.getState().importBooks( + [ + { + uri: "file:///cache/book.epub", + name: "Catalog.epub", + metadata: { title: "Catalog title", author: "Catalog author" }, + }, + ], + { transactional: true }, + ); + + expect(result.failures).toHaveLength(1); + expect(result.imported).toHaveLength(0); + expect(useLibraryStore.getState().books).toHaveLength(0); + expect(mocks.platform.deleteFile).toHaveBeenCalledTimes(2); + expect(mocks.platform.deleteFile.mock.calls.map(([path]) => path)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^\/app\/books\/.*\.epub$/), + expect.stringMatching(/^\/app\/covers\/.*\.jpg$/), + ]), + ); + }); + + it("preserves existing managed files and state when a strict restore update fails", async () => { + const deleted = book({ deletedAt: 10 }); + const priorBooks = [book({ id: "visible-id", fileHash: "visible-hash" })]; + useLibraryStore.setState({ books: priorBooks }); + mocks.existingPaths.add("/app/books/existing-id.epub"); + mocks.existingPaths.add("/app/covers/existing-id.jpg"); + mocks.db.getDeletedBookByFileHash.mockResolvedValueOnce(deleted); + mocks.db.updateBook.mockRejectedValueOnce(new Error("update failed")); + + const result = await useLibraryStore.getState().importBooks( + [ + { + uri: "file:///cache/book.epub", + name: "Catalog.epub", + metadata: { title: "Catalog title", author: "Catalog author" }, + }, + ], + { transactional: true }, + ); + + expect(result.failures).toHaveLength(1); + expect(useLibraryStore.getState().books).toEqual(priorBooks); + expect(mocks.copied[0]).toMatch(/^\/app\/books\/existing-id-.+\.epub$/); + expect(mocks.saveCover.mock.calls[0]?.[0]).toMatch(/^existing-id-.+/); + expect(mocks.platform.deleteFile.mock.calls.map(([path]) => path)).toEqual([ + mocks.copied[0], + expect.stringMatching(/^\/app\/covers\/existing-id-.+\.jpg$/), + ]); + expect(mocks.platform.deleteFile).not.toHaveBeenCalledWith("/app/books/existing-id.epub"); + expect(mocks.platform.deleteFile).not.toHaveBeenCalledWith("/app/covers/existing-id.jpg"); + }); + + it("restores to new managed paths while retaining pre-existing files", async () => { + const deleted = book({ deletedAt: 10 }); + mocks.existingPaths.add("/app/books/existing-id.epub"); + mocks.existingPaths.add("/app/covers/existing-id.jpg"); + mocks.db.getDeletedBookByFileHash.mockResolvedValueOnce(deleted); + + const result = await useLibraryStore.getState().importBooks( + [ + { + uri: "file:///cache/book.epub", + name: "Catalog.epub", + metadata: { title: "Catalog title", author: "Catalog author" }, + }, + ], + { transactional: true }, + ); + + expect(result.imported).toHaveLength(1); + expect(mocks.db.updateBook).toHaveBeenCalledTimes(1); + expect(useLibraryStore.getState().books[0]).toMatchObject({ + id: "existing-id", + filePath: expect.stringMatching(/^books\/existing-id-.+\.epub$/), + tags: ["user-tag"], + meta: { + title: "Saved title", + author: "Saved author", + coverUrl: expect.stringMatching(/^covers\/existing-id-.+\.jpg$/), + }, + }); + expect(mocks.platform.deleteFile).not.toHaveBeenCalled(); + }); + + it("does not title-match a deleted book after a conclusive hash miss", async () => { + mocks.getInfoAsync.mockResolvedValueOnce({ + exists: true, + isDirectory: false, + size: 3, + md5: "different-valid-hash", + }); + + const result = await useLibraryStore + .getState() + .importBooks([{ uri: "file:///cache/book.epub", name: "Saved title.epub" }], { + transactional: true, + }); + + expect(mocks.db.getDeletedBookByFileHash).toHaveBeenCalledWith("different-valid-hash"); + expect(mocks.db.getDeletedBookByTitle).not.toHaveBeenCalled(); + expect(result.imported[0]).not.toMatchObject({ id: "existing-id" }); + }); +}); diff --git a/packages/app-expo/src/stores/library-store.ts b/packages/app-expo/src/stores/library-store.ts index d9014c14c..ef302f693 100644 --- a/packages/app-expo/src/stores/library-store.ts +++ b/packages/app-expo/src/stores/library-store.ts @@ -1,4 +1,7 @@ +import { getCoverFileExtension, saveCoverBytesToAppData } from "@/lib/book/cover-storage"; +import { buildImportedBookMeta, shouldPersistEmbeddedCover } from "@/lib/book/imported-book-meta"; import { + type ExtractedMeta, createRangeReadableFile, extractBookMetadata, extractBookMetadataFromFile, @@ -13,7 +16,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, 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 +60,16 @@ export interface RemoveBookOptions { preserveData?: boolean; } +export interface MobileImportFile { + uri: string; + name?: string; + metadata?: Partial; +} + +export interface ImportBooksOptions { + transactional?: boolean; +} + function keepActiveGroupId(activeGroupId: string, groups: BookGroup[]): string { if (!activeGroupId) return ""; return groups.some((group) => group.id === activeGroupId) ? activeGroupId : ""; @@ -79,7 +99,10 @@ 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[], + options?: ImportBooksOptions, + ) => Promise; inspectDeletedBookCandidate: ( bookId: string, file: { uri: string; name?: string }, @@ -132,15 +155,44 @@ async function ensureAppSubDir(subDir: string): Promise { } } -async function saveCoverToAppData(bookId: string, coverBlob: Blob): Promise { +async function getMobileManagedDestination( + directory: "books" | "covers", + bookId: string, + extension: string, + avoidExisting: boolean, +): Promise<{ relativePath: string; absPath: string; storageId: string; created: boolean }> { const platform = getPlatformService(); - await ensureAppSubDir("covers"); - const ext = coverBlob.type.includes("png") ? "png" : "jpg"; - const relativePath = `covers/${bookId}.${ext}`; - const absPath = await resolveAppPath(relativePath); - const arrayBuffer = await coverBlob.arrayBuffer(); - await platform.writeFile(absPath, new Uint8Array(arrayBuffer)); - return relativePath; + let storageId = bookId; + let relativePath = `${directory}/${storageId}.${extension}`; + let absPath = await resolveAppPath(relativePath); + let exists = await platform.exists(absPath); + + while (avoidExisting && exists) { + storageId = `${bookId}-${generateId()}`; + relativePath = `${directory}/${storageId}.${extension}`; + absPath = await resolveAppPath(relativePath); + exists = await platform.exists(absPath); + } + + return { relativePath, absPath, storageId, created: !exists }; +} + +async function saveImportedMobileCover(input: { + bookId: string; + bytes: Uint8Array; + mimeType?: string | null; + avoidExisting: boolean; + createdManagedPaths: Set; +}): Promise { + const extension = getCoverFileExtension(input.bytes, input.mimeType); + const destination = await getMobileManagedDestination( + "covers", + input.bookId, + extension, + input.avoidExisting, + ); + if (destination.created) input.createdManagedPaths.add(destination.absPath); + return saveCoverBytesToAppData(destination.storageId, input.bytes, input.mimeType); } function bytesToBase64(bytes: Uint8Array): string { @@ -159,10 +211,10 @@ const MOBILE_IMPORT_METADATA_MAX_BYTES = 32 * 1024 * 1024; async function getMobileFileStat(path: string): Promise<{ size: number; md5?: string }> { const LegacyFileSystem = await import("expo-file-system/legacy"); - const info = await LegacyFileSystem.getInfoAsync(path); + const info = await LegacyFileSystem.getInfoAsync(path, { md5: true }); return { size: info.exists && !info.isDirectory ? (info.size ?? 0) : 0, - md5: undefined, + md5: info.exists && !info.isDirectory ? info.md5 : undefined, }; } @@ -302,11 +354,13 @@ async function copyBookToAppData( ext: string, srcPath: string, sourceBytes?: Uint8Array, -): Promise<{ relativePath: string; absPath: string }> { + avoidExisting = false, +): Promise<{ relativePath: string; absPath: string; created: boolean }> { const platform = getPlatformService(); await ensureAppSubDir("books"); - const relativePath = `books/${bookId}.${ext}`; - const absPath = await resolveAppPath(relativePath); + const destination = await getMobileManagedDestination("books", bookId, ext, avoidExisting); + const { relativePath, absPath } = destination; + let { created } = destination; if (sourceBytes) { // If bytes are already in memory (e.g. from hash calculation), just write them @@ -317,11 +371,12 @@ async function copyBookToAppData( const srcFile = new ExpoFS.File(srcPath); const destFile = new ExpoFS.File(absPath); if (destFile.exists) { + created = false; destFile.delete(); } srcFile.copy(destFile); } - return { relativePath, absPath }; + return { relativePath, absPath, created }; } async function persistBookUpdate(bookId: string, updates: Partial): Promise { @@ -397,12 +452,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", @@ -441,12 +495,9 @@ async function restoreDeletedMobileBook( await platform.writeFile(await resolveAppPath(relativePath), conversion.epubBytes); let coverUrl = originalBook.meta.coverUrl; - if (conversion.coverBytes && conversion.coverBytes.length > 0) { + if (!coverUrl?.trim() && conversion.coverBytes && conversion.coverBytes.length > 0) { try { - await ensureAppSubDir("covers"); - const coverRelPath = `covers/${bookId}.jpg`; - await platform.writeFile(await resolveAppPath(coverRelPath), conversion.coverBytes); - coverUrl = coverRelPath; + coverUrl = await saveCoverBytesToAppData(bookId, conversion.coverBytes); } catch (coverErr) { console.warn(`[restoreDeletedMobileBook] UMD cover save failed: ${coverErr}`); } @@ -456,12 +507,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 +524,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 +534,14 @@ 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; + if (!coverUrl?.trim() && meta.coverBytes && meta.coverBytes.length > 0) { + try { + coverUrl = await saveCoverBytesToAppData(bookId, meta.coverBytes, meta.coverMimeType); + } 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 +550,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", @@ -835,7 +880,7 @@ export const useLibraryStore = create((set, get) => ({ setSortField: (field) => set((state) => ({ filter: { ...state.filter, sortField: field } })), setSortOrder: (order) => set((state) => ({ filter: { ...state.filter, sortOrder: order } })), - importBooks: async (files) => { + importBooks: async (files, options = {}) => { set({ isImporting: true }); const result = createEmptyImportBooksResult(); const duplicateIndex = createImportDuplicateIndex(get().books); @@ -843,6 +888,7 @@ export const useLibraryStore = create((set, get) => ({ await db.initDatabase(); for (const fileInfo of files) { const filePath = fileInfo.uri; + const createdManagedPaths = new Set(); const originalName = fileInfo.name ? decodeURIComponent(fileInfo.name) : decodeURIComponent(filePath.split("/").pop() || "book"); @@ -882,6 +928,45 @@ export const useLibraryStore = create((set, get) => ({ }) : null; const bookId = deletedMatch?.id ?? generateId(); + const persistEmbeddedCover = shouldPersistEmbeddedCover( + deletedMatch?.meta, + fileInfo.metadata, + ); + const persistImport = async (book: Book): Promise => { + const restoreUpdates = { + filePath: book.filePath, + format: book.format, + meta: book.meta, + deletedAt: undefined, + progress: book.progress, + currentCfi: book.currentCfi, + isVectorized: false, + vectorizeProgress: 0, + tags: book.tags, + fileHash: book.fileHash, + syncStatus: "local" as const, + lastOpenedAt: Date.now(), + }; + + if (options.transactional) { + if (deletedMatch) { + await db.updateBook(book.id, restoreUpdates); + } else { + await db.insertBook(book); + } + set((state) => ({ books: [...state.books, book] })); + debouncedSave("library-books", get().books); + return; + } + + if (deletedMatch) { + set((state) => ({ books: [...state.books, book] })); + await db.updateBook(book.id, restoreUpdates); + debouncedSave("library-books", get().books); + } else { + await get().addBook(book); + } + }; console.log( `[importBooks] Importing: name=${fileName}, format=${format}, uri=${filePath}`, @@ -935,23 +1020,29 @@ export const useLibraryStore = create((set, get) => ({ // Write EPUB bytes directly to final app data location await ensureAppSubDir("books"); - const relativePath = `books/${bookId}.epub`; - const absPath = await resolveAppPath(relativePath); + const destination = await getMobileManagedDestination( + "books", + bookId, + "epub", + options.transactional === true, + ); + const { relativePath, absPath } = destination; + if (destination.created) createdManagedPaths.add(absPath); await platform.writeFile(absPath, conversion.epubBytes); // 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, @@ -965,31 +1056,12 @@ export const useLibraryStore = create((set, get) => ({ lastOpenedAt: deletedMatch?.lastOpenedAt ?? Date.now(), }; - if (deletedMatch) { - set((state) => ({ books: [...state.books, book] })); - await db.updateBook(book.id, { - filePath: book.filePath, - format: book.format, - meta: book.meta, - deletedAt: undefined, - progress: book.progress, - currentCfi: book.currentCfi, - isVectorized: false, - vectorizeProgress: 0, - tags: book.tags, - fileHash: book.fileHash, - syncStatus: "local", - lastOpenedAt: Date.now(), - }); - debouncedSave("library-books", get().books); - } else { - await get().addBook(book); - } + await persistImport(book); result.imported.push(book); 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. @@ -1046,35 +1118,45 @@ export const useLibraryStore = create((set, get) => ({ const conversion = await converter.convertToBytes({ file: umdFile }); await ensureAppSubDir("books"); - const relativePath = `books/${bookId}.epub`; - const absPath = await resolveAppPath(relativePath); + const destination = await getMobileManagedDestination( + "books", + bookId, + "epub", + options.transactional === true, + ); + const { relativePath, absPath } = destination; + if (destination.created) createdManagedPaths.add(absPath); await platform.writeFile(absPath, conversion.epubBytes); - let coverUrl: string | undefined; - if (conversion.coverBytes && conversion.coverBytes.length > 0) { + let coverUrl = deletedMatch?.meta.coverUrl; + if ( + persistEmbeddedCover && + conversion.coverBytes && + conversion.coverBytes.length > 0 + ) { try { - await ensureAppSubDir("covers"); - const coverRelPath = `covers/${bookId}.jpg`; - const coverAbsPath = await resolveAppPath(coverRelPath); - await platform.writeFile(coverAbsPath, conversion.coverBytes); - coverUrl = coverRelPath; + coverUrl = await saveImportedMobileCover({ + bookId, + bytes: conversion.coverBytes, + avoidExisting: options.transactional === true, + createdManagedPaths, + }); } catch (coverErr) { console.warn(`[importBooks] Failed to save UMD cover for ${fileName}:`, coverErr); } } - 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, @@ -1088,31 +1170,12 @@ export const useLibraryStore = create((set, get) => ({ lastOpenedAt: deletedMatch?.lastOpenedAt ?? Date.now(), }; - if (deletedMatch) { - set((state) => ({ books: [...state.books, book] })); - await db.updateBook(book.id, { - filePath: book.filePath, - format: book.format, - meta: book.meta, - deletedAt: undefined, - progress: book.progress, - currentCfi: book.currentCfi, - isVectorized: false, - vectorizeProgress: 0, - tags: book.tags, - fileHash: book.fileHash, - syncStatus: "local", - lastOpenedAt: Date.now(), - }); - debouncedSave("library-books", get().books); - } else { - await get().addBook(book); - } + await persistImport(book); result.imported.push(book); 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(); @@ -1138,13 +1201,20 @@ export const useLibraryStore = create((set, get) => ({ } } - const { relativePath } = await copyBookToAppData(bookId, ext || "epub", filePath); + const copiedBook = await copyBookToAppData( + bookId, + ext || "epub", + filePath, + undefined, + options.transactional === true, + ); + const { relativePath } = copiedBook; + if (copiedBook.created) createdManagedPaths.add(copiedBook.absPath); console.log(`[importBooks] File copied. relativePath: ${relativePath}`); - // Extract metadata (title, author, cover) from book content - let title = fileName.replace(/\.\w+$/i, "") || "Untitled"; - let author = ""; - let coverUrl: string | undefined; + // Extract metadata and cover from book content. + let coverUrl = deletedMatch?.meta.coverUrl; + let embeddedMeta: (ExtractedMeta & { coverUrl?: string }) | undefined; try { console.log(`[importBooks] Extracting metadata for format=${format}...`); @@ -1157,43 +1227,41 @@ 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) { + if (persistEmbeddedCover && meta.coverBytes && meta.coverBytes.length > 0) { try { - const mimeType = meta.coverMimeType || "image/jpeg"; - const coverExt = mimeType.includes("png") ? "png" : "jpg"; - await ensureAppSubDir("covers"); - const coverRelPath = `covers/${bookId}.${coverExt}`; - const coverAbsPath = await resolveAppPath(coverRelPath); - console.log(`[importBooks] Saving cover to: ${coverAbsPath}`); - const platform = getPlatformService(); - await platform.writeFile(coverAbsPath, meta.coverBytes); - coverUrl = coverRelPath; + coverUrl = await saveImportedMobileCover({ + bookId, + bytes: meta.coverBytes, + mimeType: meta.coverMimeType, + avoidExisting: options.transactional === true, + createdManagedPaths, + }); + console.log(`[importBooks] Saving cover to: ${coverUrl}`); console.log(`[importBooks] Cover saved. coverUrl=${coverUrl}`); } catch (coverErr) { 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, @@ -1206,26 +1274,7 @@ export const useLibraryStore = create((set, get) => ({ updatedAt: Date.now(), lastOpenedAt: deletedMatch?.lastOpenedAt ?? Date.now(), }; - if (deletedMatch) { - set((state) => ({ books: [...state.books, book] })); - await db.updateBook(book.id, { - filePath: book.filePath, - format: book.format, - meta: book.meta, - deletedAt: undefined, - progress: book.progress, - currentCfi: book.currentCfi, - isVectorized: false, - vectorizeProgress: 0, - tags: book.tags, - fileHash: book.fileHash, - syncStatus: "local", - lastOpenedAt: Date.now(), - }); - debouncedSave("library-books", get().books); - } else { - await get().addBook(book); - } + await persistImport(book); result.imported.push(book); if (fileHash) { duplicateIndex.byHash.set(fileHash, book); @@ -1269,6 +1318,12 @@ export const useLibraryStore = create((set, get) => ({ ); } } catch (err) { + if (options.transactional) { + const platform = getPlatformService(); + for (const path of createdManagedPaths) { + await platform.deleteFile(path).catch(() => undefined); + } + } console.error(`Failed to import ${fileInfo.uri}:`, err); result.failures.push({ name: originalName, 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-tauri/Cargo.lock b/packages/app/src-tauri/Cargo.lock index 40dd98cb1..e53c659d0 100644 --- a/packages/app/src-tauri/Cargo.lock +++ b/packages/app/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -73,6 +84,7 @@ dependencies = [ "axum", "base64 0.22.1", "dashmap", + "keyring", "local-ip-address", "rusqlite", "serde", @@ -375,6 +387,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -512,6 +533,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -573,6 +603,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "combine" version = "4.6.7" @@ -901,6 +941,35 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" @@ -2213,6 +2282,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2342,6 +2421,23 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2399,6 +2495,15 @@ version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2437,6 +2542,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2680,6 +2795,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -2696,6 +2824,30 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2712,6 +2864,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2738,6 +2899,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3977,7 +4149,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -4005,7 +4177,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -4115,6 +4287,38 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.5", + "serde", + "sha2", + "zbus 4.4.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4732,6 +4936,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -5150,7 +5360,7 @@ dependencies = [ "thiserror 2.0.18", "url", "windows", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -5175,7 +5385,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus", + "zbus 5.14.0", ] [[package]] @@ -6993,6 +7203,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "yoke" version = "0.8.1" @@ -7016,6 +7236,38 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.14.0" @@ -7046,9 +7298,22 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 0.7.14", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 5.14.0", + "zbus_names 4.3.1", + "zvariant 5.10.0", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7061,9 +7326,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", + "zbus_names 4.3.1", + "zvariant 5.10.0", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", ] [[package]] @@ -7074,7 +7350,7 @@ checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", "winnow 0.7.14", - "zvariant", + "zvariant 5.10.0", ] [[package]] @@ -7123,6 +7399,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -7175,6 +7465,19 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.10.0" @@ -7185,8 +7488,21 @@ dependencies = [ "enumflags2", "serde", "winnow 0.7.14", - "zvariant_derive", - "zvariant_utils", + "zvariant_derive 5.10.0", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils 2.1.0", ] [[package]] @@ -7199,7 +7515,18 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils", + "zvariant_utils 3.3.0", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/packages/app/src-tauri/Cargo.toml b/packages/app/src-tauri/Cargo.toml index 77691d175..5654a4e5b 100644 --- a/packages/app/src-tauri/Cargo.toml +++ b/packages/app/src-tauri/Cargo.toml @@ -37,4 +37,5 @@ tower-http = { version = "0.6.8", features = ["cors"] } dashmap = "6.1.0" uuid = { version = "1.22.0", features = ["v4"] } base64 = "0.22.1" +keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] } tauri-plugin-window-state = "2.4.1" diff --git a/packages/app/src-tauri/src/lib.rs b/packages/app/src-tauri/src/lib.rs index 382c6a415..df63026c3 100644 --- a/packages/app/src-tauri/src/lib.rs +++ b/packages/app/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod db; mod readany_cli; +mod secrets; mod storage; mod sync; mod vector; @@ -45,6 +46,9 @@ pub fn run() { vector::vector_reinit, vector::vector_shutdown, readany_cli::readany_cli_run, + secrets::secret_get, + secrets::secret_set, + secrets::secret_remove, ]) .setup(|app| { let app_handle = app.handle().clone(); diff --git a/packages/app/src-tauri/src/secrets.rs b/packages/app/src-tauri/src/secrets.rs new file mode 100644 index 000000000..c5943d742 --- /dev/null +++ b/packages/app/src-tauri/src/secrets.rs @@ -0,0 +1,140 @@ +const SERVICE: &str = "ReadAny"; +const ACCOUNT_PREFIX: &str = "opds.catalog."; +const ACCOUNT_SUFFIX: &str = ".password"; + +#[derive(Clone, Copy)] +enum SecretOperation { + Get, + Set, + Remove, +} + +fn redacted_error(operation: SecretOperation) -> String { + match operation { + SecretOperation::Get => "Failed to read secret", + SecretOperation::Set => "Failed to store secret", + SecretOperation::Remove => "Failed to remove secret", + } + .to_string() +} + +fn credential_account(key: &str) -> Result { + let catalog_id = key + .strip_prefix(ACCOUNT_PREFIX) + .and_then(|value| value.strip_suffix(ACCOUNT_SUFFIX)) + .ok_or_else(|| "Invalid secret key".to_string())?; + let id = uuid::Uuid::parse_str(catalog_id).map_err(|_| "Invalid secret key".to_string())?; + if id.get_version_num() != 4 || id.to_string() != catalog_id { + return Err("Invalid secret key".to_string()); + } + Ok(format!("{ACCOUNT_PREFIX}{catalog_id}{ACCOUNT_SUFFIX}")) +} + +#[tauri::command] +pub fn secret_set(key: String, value: String) -> Result<(), String> { + let account = credential_account(&key)?; + let entry = + keyring::Entry::new(SERVICE, &account).map_err(|_| redacted_error(SecretOperation::Set))?; + entry + .set_password(&value) + .map_err(|_| redacted_error(SecretOperation::Set)) +} + +#[tauri::command] +pub fn secret_get(key: String) -> Result, String> { + let account = credential_account(&key)?; + let entry = + keyring::Entry::new(SERVICE, &account).map_err(|_| redacted_error(SecretOperation::Get))?; + match entry.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => Err(redacted_error(SecretOperation::Get)), + } +} + +fn map_remove_result(result: Result<(), keyring::Error>) -> Result<(), String> { + match result { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => Err(redacted_error(SecretOperation::Remove)), + } +} + +#[tauri::command] +pub fn secret_remove(key: String) -> Result<(), String> { + let account = credential_account(&key)?; + let entry = keyring::Entry::new(SERVICE, &account) + .map_err(|_| redacted_error(SecretOperation::Remove))?; + map_remove_result(entry.delete_credential()) +} + +#[cfg(test)] +mod tests { + use super::{credential_account, map_remove_result, redacted_error, SecretOperation, SERVICE}; + + const FIRST_ID: &str = "11111111-1111-4111-8111-111111111111"; + const SECOND_ID: &str = "22222222-2222-4222-8222-222222222222"; + + #[test] + fn uses_fixed_service_and_collision_free_catalog_accounts() { + assert_eq!(SERVICE, "ReadAny"); + let first = format!("opds.catalog.{FIRST_ID}.password"); + let second = format!("opds.catalog.{SECOND_ID}.password"); + assert_eq!(credential_account(&first).unwrap(), first); + assert_eq!(credential_account(&second).unwrap(), second); + assert_ne!(credential_account(&first), credential_account(&second)); + } + + #[test] + fn rejects_accounts_not_derived_from_a_custom_catalog_id() { + for key in [ + "opds.catalog.__proto__.password", + "opds.catalog.gutenberg.password", + "opds.catalog.11111111-1111-4111-8111-111111111111.password.extra", + "sync_webdav_password", + ] { + assert_eq!( + credential_account(key), + Err("Invalid secret key".to_string()) + ); + } + } + + #[test] + fn redacts_backend_details_with_fixed_operation_errors() { + assert_eq!( + redacted_error(SecretOperation::Get), + "Failed to read secret" + ); + assert_eq!( + redacted_error(SecretOperation::Set), + "Failed to store secret" + ); + assert_eq!( + redacted_error(SecretOperation::Remove), + "Failed to remove secret" + ); + for operation in [ + SecretOperation::Get, + SecretOperation::Set, + SecretOperation::Remove, + ] { + let error = redacted_error(operation); + assert!(!error.contains("secret-password")); + assert!(!error.contains("backend")); + } + } + + #[test] + fn removing_a_missing_entry_is_idempotent() { + assert_eq!(map_remove_result(Err(keyring::Error::NoEntry)), Ok(())); + } + + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + #[test] + fn selected_desktop_backend_persists_until_delete() { + assert!(matches!( + keyring::default::default_credential_builder().persistence(), + keyring::credential::CredentialPersistence::UntilDelete + )); + } +} diff --git a/packages/app/src/components/home/BookDetailsDialog.tsx b/packages/app/src/components/home/BookDetailsDialog.tsx index 38041ff0a..5019689a6 100644 --- a/packages/app/src/components/home/BookDetailsDialog.tsx +++ b/packages/app/src/components/home/BookDetailsDialog.tsx @@ -25,12 +25,13 @@ 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 { commitCustomCover, saveExtractedCoverIfStillMissing } from "@/lib/book/cover-storage"; import { useAppStore } from "@/stores/app-store"; import { useLibraryStore } from "@/stores/library-store"; import type { Book, BookReview } from "@readany/core/types"; import { type BookMetadataFormValues, + applyBookMetadataFormUpdate, buildBookMetadataUpdate, cn, createBookMetadataFormValues, @@ -40,6 +41,7 @@ import { mergeMissingBookMetadataValues, splitEditableList, } from "@readany/core/utils"; +import { invoke } from "@tauri-apps/api/core"; import type { TFunction } from "i18next"; import { BookOpen, @@ -58,7 +60,7 @@ import { Wand2, } from "lucide-react"; import type { ReactNode } from "react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; @@ -282,12 +284,22 @@ 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); + const commitValues = useCallback( + ( + update: + | BookMetadataFormValues + | ((current: BookMetadataFormValues) => BookMetadataFormValues), + ) => applyBookMetadataFormUpdate(latestValuesRef, setValues, update), + [], + ); useEffect(() => { if (!open) { hydratedBookIdRef.current = null; autoFilledBookIdRef.current = null; + latestValuesRef.current = null; setEditingBasics(false); setEditingTitleField(null); setEditingReviewId(null); @@ -299,14 +311,15 @@ 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); + commitValues(nextValues); setEditingBasics(false); setEditingTitleField(null); setEditingReviewId(null); setActiveTab("basic"); setDraftActionBusy(false); setDraftActionResult(null); - }, [book, open]); + }, [book, commitValues, open]); useEffect(() => { if (!open || !book || !values) return; @@ -315,18 +328,34 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo autoFilledBookIdRef.current = book.id; let cancelled = false; - void extractLocalBookMetadata(book).then((metadata) => { + void extractLocalBookMetadata(book).then(async (metadata) => { if (cancelled || !metadata) return; - setValues((current) => { - if (!current) return current; - return mergeMissingBookMetadataValues(current, metadata) ?? current; - }); + let extracted = metadata; + if (metadata.coverBlob) { + try { + const coverUrl = await saveExtractedCoverIfStillMissing( + book.id, + metadata.coverBlob, + () => (cancelled ? "__cancelled__" : latestValuesRef.current?.coverUrl), + ); + if (coverUrl) extracted = { ...metadata, coverUrl }; + } catch (error) { + console.warn("[BookMetadata] Failed to persist extracted desktop cover:", error); + } + } + if (cancelled) return; + const nextValues = latestValuesRef.current + ? mergeMissingBookMetadataValues(latestValuesRef.current, extracted) + : null; + if (!nextValues) return; + commitValues(nextValues); + updateBook(book.id, buildBookMetadataUpdate(book, nextValues)); }); return () => { cancelled = true; }; - }, [book, open, values]); + }, [book, commitValues, open, updateBook, values]); const groupName = useMemo(() => { const groupId = values?.groupId ?? book?.groupId; @@ -367,7 +396,7 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo field: K, value: BookMetadataFormValues[K], ) => { - setValues((current) => (current ? { ...current, [field]: value } : current)); + commitValues((current) => ({ ...current, [field]: value })); }; const persistCoverUrl = async (coverUrl: string) => { @@ -509,7 +538,7 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo await mkdir(coversDir, { recursive: true }); const relativePath = `covers/${book.id}-custom-${Date.now()}.${safeExt}`; await copyFile(selected, await join(libraryRoot, relativePath)); - await persistCoverUrl(relativePath); + await commitCustomCover(book.id, relativePath, persistCoverUrl); toast.success(t("library.detailsCoverSaved", "Cover saved")); } catch (err) { console.warn("[BookDetailsDialog] Failed to change cover:", err); @@ -936,7 +965,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) => ( + + ))} +
+
+ ))} + + {windowedFeed?.publications.length ? ( +
+ {t("library.opds.books")} +
+ {windowedFeed.publications.map((publication) => + renderPublication(publication, "publication"), + )} +
+
+ ) : null} + + {windowedFeed?.groups.map((group, groupIndex) => ( +
+ {group.title} + {group.navigation.length ? ( +
+ {group.navigation.map((item) => ( + + ))} +
+ ) : null} +
+ {group.publications.map((publication) => + renderPublication(publication, `group-${groupIndex}`), + )} +
+
+ ))} + + {windowedFeed?.hasMore ? ( +
+ +
+ ) : null} + + {!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.test.tsx b/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx new file mode 100644 index 000000000..fd5cf9b58 --- /dev/null +++ b/packages/app/src/components/home/OpdsCatalogFormDialog.test.tsx @@ -0,0 +1,324 @@ +// @vitest-environment jsdom + +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"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await 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 = ""; + }); + + 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(); + }); + + 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); + renderControlledForm({ store: { addCatalog } }); + const user = userEvent.setup(); + + 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" }); + 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(); + 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(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); + 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); + await user.type(reopenedName, "Second update"); + 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(); + + 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 new file mode 100644 index 000000000..24d4f814a --- /dev/null +++ b/packages/app/src/components/home/OpdsCatalogFormDialog.tsx @@ -0,0 +1,381 @@ +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, + canPreserveOpdsCatalogPassword, + classifyOpdsUrl, +} from "@readany/core"; +import { Loader2, ShieldAlert } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +interface OpdsCatalogFormDialogProps { + open: boolean; + catalog?: OpdsCatalog; + store: OpdsCatalogStore; + onOpenChange(open: boolean): void; + onSaved(): void; + onBackgroundSaved?(): void; +} + +export function OpdsCatalogFormDialog({ + open, + catalog, + store, + onOpenChange, + onSaved, + onBackgroundSaved, +}: 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 [renderedOpenGeneration, setRenderedOpenGeneration] = useState(0); + 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; + setRenderedOpenGeneration(openGeneration.current); + setName(catalog?.name ?? ""); + setUrl(catalog?.url ?? ""); + setAuth(catalog?.auth ?? "anonymous"); + setUsername(catalog?.username ?? ""); + setPassword(""); + setEnabled(catalog?.enabled ?? true); + setConfirmingLocalHttp(false); + setError(undefined); + }, [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 || preservesPassword))) && + !submitting; + const savingCurrentOpen = + submitting && activeSave.current?.openGeneration === renderedOpenGeneration; + + const persist = async () => { + 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 { + 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); + if (!isCurrentOpenAttempt()) { + onBackgroundSaved?.(); + return; + } + setPassword(""); + onSaved(); + } catch { + if (isCurrentOpenAttempt()) { + setError(t("library.opds.form.saveFailed")); + } + } finally { + if (activeSave.current?.saveGeneration === saveId) { + activeSave.current = undefined; + 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 ( + { + if (!nextOpen && savingCurrentOpen) return; + onOpenChange(nextOpen); + }} + > + { + if (savingCurrentOpen) event.preventDefault(); + }} + onPointerDownOutside={(event) => { + if (savingCurrentOpen) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (savingCurrentOpen) event.preventDefault(); + }} + className="max-h-[calc(100vh-32px)] w-[min(92vw,620px)] max-w-none overflow-y-auto" + > + + + {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.test.tsx b/packages/app/src/components/home/OpdsCatalogsDialog.test.tsx new file mode 100644 index 000000000..062f61399 --- /dev/null +++ b/packages/app/src/components/home/OpdsCatalogsDialog.test.tsx @@ -0,0 +1,200 @@ +// @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 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, 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), + }; + 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("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); + 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 new file mode 100644 index 000000000..9d56440d4 --- /dev/null +++ b/packages/app/src/components/home/OpdsCatalogsDialog.tsx @@ -0,0 +1,426 @@ +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 dialogOrigin = useRef(undefined); + const returnFocusCatalogId = useRef(undefined); + const returnFocusElement = useRef(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]); + + useEffect(() => { + if (open && !selected && returnFocusCatalogId.current) { + requestAnimationFrame(() => returnFocusElement.current?.focus()); + } + }, [open, selected]); + + 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 (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; + event.preventDefault(); + backHandler.current?.(); + }} + > + {selected ? ( + <> + {selected.name} + + {t("library.opds.catalogsSubtitle")} + + 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(); + }} + onBackgroundSaved={syncCatalogs} + /> + + !next && setDeleting(undefined)}> + + + {t("library.opds.deleteTitle")} + {t("library.opds.deleteDescription")} + + + + + + + + + ); +} diff --git a/packages/app/src/components/home/OpdsDescription.test.tsx b/packages/app/src/components/home/OpdsDescription.test.tsx new file mode 100644 index 000000000..9ca4000d8 --- /dev/null +++ b/packages/app/src/components/home/OpdsDescription.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { OpdsDescription } from "./OpdsDescription"; + +describe("OPDS description links", () => { + it("prevents app navigation and opens a revalidated HTTP link externally", async () => { + const openExternal = vi.fn(async () => undefined); + render( + Author site

'} + documentUrl="https://catalog.test/books/1" + openExternal={openExternal} + />, + ); + const link = screen.getByRole("link", { name: "Author site" }); + + expect(link.getAttribute("target")).toBe("_blank"); + expect(link.getAttribute("rel")).toBe("noopener noreferrer"); + await userEvent.click(link); + + expect(openExternal).toHaveBeenCalledExactlyOnceWith("https://catalog.test/author"); + expect(window.location.href).toBe("http://localhost:3000/"); + }); + + it("keeps unsafe catalog links inert", async () => { + const openExternal = vi.fn(async () => undefined); + const { container } = render( + Unsafe'} + documentUrl="https://catalog.test/feed" + openExternal={openExternal} + />, + ); + + await userEvent.click(screen.getByText("Unsafe")); + + expect(container.querySelector("a")?.hasAttribute("href")).toBe(false); + expect(openExternal).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/home/OpdsDescription.tsx b/packages/app/src/components/home/OpdsDescription.tsx new file mode 100644 index 000000000..6bdadd77e --- /dev/null +++ b/packages/app/src/components/home/OpdsDescription.tsx @@ -0,0 +1,42 @@ +import { sanitizeOpdsDescription } from "@readany/core"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import type { MouseEvent } from "react"; + +interface OpdsDescriptionProps { + description: string; + documentUrl: string; + openExternal?(url: string): Promise; +} + +export function OpdsDescription({ + description, + documentUrl, + openExternal = openUrl, +}: OpdsDescriptionProps) { + const sanitized = sanitizeOpdsDescription(description, documentUrl); + + const handleLink = (event: MouseEvent) => { + const target = event.target instanceof Element ? event.target.closest("a") : null; + if (!target || !event.currentTarget.contains(target)) return; + event.preventDefault(); + const href = target.getAttribute("href"); + if (!href) return; + try { + const url = new URL(href); + if (url.protocol !== "http:" && url.protocol !== "https:") return; + void openExternal(url.href); + } catch { + // Sanitized links can still be mutated by the DOM; invalid final URLs stay inert. + } + }; + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: Nested native anchors already synthesize click events for keyboard activation. +
+ ); +} diff --git a/packages/app/src/components/home/opds-component-test-setup.ts b/packages/app/src/components/home/opds-component-test-setup.ts new file mode 100644 index 000000000..83d50ee53 --- /dev/null +++ b/packages/app/src/components/home/opds-component-test-setup.ts @@ -0,0 +1,10 @@ +class TestResizeObserver implements ResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: TestResizeObserver, +}); diff --git a/packages/app/src/components/home/opds-desktop-browser-state.test.ts b/packages/app/src/components/home/opds-desktop-browser-state.test.ts new file mode 100644 index 000000000..41fccc59e --- /dev/null +++ b/packages/app/src/components/home/opds-desktop-browser-state.test.ts @@ -0,0 +1,132 @@ +import { + type OpdsFeed, + type OpdsViewState, + createInitialOpdsViewState, + createOpdsBackController, + opdsViewReducer, +} from "@readany/core"; +import { describe, expect, it, vi } from "vitest"; + +const feed = (title: string): OpdsFeed => ({ + title, + navigation: [], + publications: [], + groups: [], + facets: [], +}); + +function ready(url = "root", title = "Root"): OpdsViewState { + const loading = opdsViewReducer(createInitialOpdsViewState(), { + type: "loadStarted", + requestId: 1, + url, + mode: "replace", + }); + return opdsViewReducer(loading, { type: "loadSucceeded", requestId: 1, feed: feed(title) }); +} + +function push(state: OpdsViewState, requestId: number, url: string, title?: string) { + const loading = opdsViewReducer(state, { type: "loadStarted", requestId, url, mode: "push" }); + return title + ? opdsViewReducer(loading, { type: "loadSucceeded", requestId, feed: feed(title) }) + : loading; +} + +function setup(initial: OpdsViewState) { + let state = initial; + const cancelRequest = vi.fn(); + const startBack = vi.fn(); + const exit = vi.fn(); + const controller = createOpdsBackController({ + getState: () => state, + cancelRequest, + dispatch: (action) => { + state = opdsViewReducer(state, action); + }, + startBack, + exit, + }); + return { controller, cancelRequest, startBack, exit, getState: () => state }; +} + +describe("desktop OPDS browser state", () => { + it.each([ + ["root", ready()], + ["non-root", push(ready(), 2, "child", "Child")], + ])("dismisses a failed push at %s without consuming history", (_label, previous) => { + const pushing = push(previous, 3, "failed-target"); + const failed = opdsViewReducer(pushing, { + type: "loadFailed", + requestId: 3, + error: "unreachable", + }); + const harness = setup(failed); + + harness.controller.handleHeaderBack(); + + expect(harness.getState().content).toMatchObject({ + status: "ready", + currentUrl: previous.content.status === "ready" ? previous.content.currentUrl : undefined, + }); + expect(harness.startBack).not.toHaveBeenCalled(); + expect(harness.exit).not.toHaveBeenCalled(); + }); + + it("keeps refresh failure distinct and follows existing history on Back", () => { + const child = push(ready(), 2, "child", "Child"); + const refreshing = opdsViewReducer(child, { + type: "loadStarted", + requestId: 3, + url: "child", + mode: "refresh", + }); + const failed = opdsViewReducer(refreshing, { + type: "loadFailed", + requestId: 3, + error: "unreachable", + }); + const harness = setup(failed); + + harness.controller.handleHeaderBack(); + + expect(harness.startBack).toHaveBeenCalledWith("root"); + }); + + it("cancels an in-flight push and restores the retained feed", () => { + const pushing = push(ready(), 2, "child"); + const harness = setup(pushing); + + harness.controller.handleHeaderBack(); + + expect(harness.cancelRequest).toHaveBeenCalledOnce(); + expect(harness.getState().content).toMatchObject({ status: "ready", currentUrl: "root" }); + }); + + it("retains a failed push target so retry can complete the requested child", () => { + const previous = push(ready(), 2, "child", "Child"); + const pushing = push(previous, 3, "grandchild"); + const failed = opdsViewReducer(pushing, { + type: "loadFailed", + requestId: 3, + error: "unreachable", + }); + + const retrying = opdsViewReducer(failed, { type: "retryStarted", requestId: 4 }); + expect(retrying.content).toMatchObject({ + status: "loading", + pending: { mode: "push", url: "grandchild" }, + previous: { currentUrl: "child", history: ["root"] }, + }); + + const completed = opdsViewReducer(retrying, { + type: "loadSucceeded", + requestId: 4, + feed: feed("Grandchild"), + }); + expect(completed.content).toMatchObject({ + status: "ready", + currentUrl: "grandchild", + history: ["root", "child"], + }); + }); +}); diff --git a/packages/app/src/components/home/opds-desktop-download-controller.test.ts b/packages/app/src/components/home/opds-desktop-download-controller.test.ts new file mode 100644 index 000000000..b35dae387 --- /dev/null +++ b/packages/app/src/components/home/opds-desktop-download-controller.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { createOpdsDesktopDownloadController } from "./opds-desktop-download-controller"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("desktop OPDS download controller", () => { + it("cancels before deferred credentials resolve", async () => { + const credentials = deferred(); + const operation = vi.fn(async () => "downloaded"); + const controller = createOpdsDesktopDownloadController({ + prepare: () => credentials.promise, + }); + + const pending = controller.run(operation); + expect(controller.cancel()).toBe(true); + credentials.resolve("secret"); + + await expect(pending).resolves.toBeUndefined(); + expect(operation).not.toHaveBeenCalled(); + }); + + it("disposes before deferred credentials resolve without starting import", async () => { + const credentials = deferred(); + const operation = vi.fn(async () => "downloaded"); + const controller = createOpdsDesktopDownloadController({ + prepare: () => credentials.promise, + }); + + const pending = controller.run(operation); + controller.dispose(); + credentials.resolve("secret"); + + await expect(pending).resolves.toBeUndefined(); + expect(operation).not.toHaveBeenCalled(); + }); + + it("suppresses a cancelled attempt when a retry completes first", async () => { + const firstCredentials = deferred(); + let attempts = 0; + const controller = createOpdsDesktopDownloadController({ + prepare: () => (++attempts === 1 ? firstCredentials.promise : Promise.resolve("new")), + }); + const firstOperation = vi.fn(async () => "stale"); + + const first = controller.run(firstOperation); + controller.cancel(); + await expect(controller.run(async () => "fresh")).resolves.toBe("fresh"); + firstCredentials.resolve("old"); + + await expect(first).resolves.toBeUndefined(); + expect(firstOperation).not.toHaveBeenCalled(); + }); + + it("rejects concurrent starts and stops accepting cancellation at import", async () => { + const downloading = deferred(); + const controller = createOpdsDesktopDownloadController({ + prepare: async () => "credentials", + }); + let operationSignal: AbortSignal | undefined; + const pending = controller.run(async (_credentials, ownership) => { + operationSignal = ownership.signal; + ownership.markImportStarted(); + return downloading.promise; + }); + await Promise.resolve(); + + await expect(controller.run(async () => "second")).rejects.toMatchObject({ + code: "download-in-progress", + }); + expect(controller.cancel()).toBe(false); + expect(operationSignal?.aborted).toBe(false); + downloading.resolve("imported"); + await expect(pending).resolves.toBe("imported"); + }); + + it("lets an atomic import finish after disposal while suppressing its stale result", async () => { + const importing = deferred(); + const controller = createOpdsDesktopDownloadController({ + prepare: async () => "credentials", + }); + let operationSignal: AbortSignal | undefined; + const pending = controller.run(async (_credentials, ownership) => { + operationSignal = ownership.signal; + ownership.markImportStarted(); + return importing.promise; + }); + await Promise.resolve(); + + expect(controller.dispose()).toBe(false); + expect(operationSignal?.aborted).toBe(false); + importing.resolve("imported"); + + await expect(pending).resolves.toBeUndefined(); + }); +}); diff --git a/packages/app/src/components/home/opds-desktop-download-controller.ts b/packages/app/src/components/home/opds-desktop-download-controller.ts new file mode 100644 index 000000000..1989d9272 --- /dev/null +++ b/packages/app/src/components/home/opds-desktop-download-controller.ts @@ -0,0 +1,69 @@ +import { OpdsError } from "@readany/core"; + +interface DesktopDownloadControllerOptions { + prepare(signal: AbortSignal): Promise; +} + +interface DesktopDownloadOwnership { + signal: AbortSignal; + markImportStarted(): void; +} + +export function createOpdsDesktopDownloadController({ + prepare, +}: DesktopDownloadControllerOptions) { + let sequence = 0; + let disposed = false; + let active: { id: number; controller: AbortController; importStarted: boolean } | undefined; + + const isCurrent = (id: number, controller: AbortController) => + active?.id === id && sequence === id && !controller.signal.aborted; + + const cancel = (): boolean => { + if (!active || active.importStarted) return false; + sequence += 1; + active.controller.abort(); + active = undefined; + return true; + }; + + return { + async run( + operation: ( + credentials: TCredentials, + ownership: DesktopDownloadOwnership, + ) => Promise, + ): Promise { + if (disposed) return undefined; + if (active) throw new OpdsError("download-in-progress"); + const id = ++sequence; + const controller = new AbortController(); + active = { id, controller, importStarted: false }; + try { + const credentials = await prepare(controller.signal); + if (!isCurrent(id, controller) || disposed) return undefined; + const result = await operation(credentials, { + signal: controller.signal, + markImportStarted() { + if (!isCurrent(id, controller)) throw new OpdsError("cancelled"); + if (active?.id === id) active.importStarted = true; + }, + }); + return isCurrent(id, controller) && !disposed ? result : undefined; + } catch (error) { + if (!isCurrent(id, controller) || disposed) return undefined; + throw error; + } finally { + if (active?.id === id) active = undefined; + } + }, + cancel, + dispose(): boolean { + disposed = true; + return cancel(); + }, + isActive(): boolean { + return active !== undefined; + }, + }; +} diff --git a/packages/app/src/components/home/opds-desktop-feed-window.test.ts b/packages/app/src/components/home/opds-desktop-feed-window.test.ts new file mode 100644 index 000000000..6d467c616 --- /dev/null +++ b/packages/app/src/components/home/opds-desktop-feed-window.test.ts @@ -0,0 +1,53 @@ +import type { OpdsFeed, OpdsPublication } from "@readany/core"; +import { describe, expect, it } from "vitest"; +import { windowOpdsFeedPublications } from "./opds-desktop-feed-window"; + +function publication(index: number): OpdsPublication { + return { + id: `book-${index}`, + title: `Book ${index}`, + authors: [], + subjects: [], + images: [{ rel: ["http://opds-spec.org/image"], url: `https://catalog.test/${index}.jpg` }], + acquisitions: [], + readingOrder: [], + }; +} + +function denseFeed(): OpdsFeed { + return { + title: "Dense", + navigation: [], + publications: Array.from({ length: 30 }, (_, index) => publication(index)), + groups: [ + { + title: "More", + navigation: [], + publications: Array.from({ length: 30 }, (_, index) => publication(index + 30)), + groups: [], + facets: [], + }, + ], + facets: [], + }; +} + +describe("desktop OPDS feed window", () => { + it("keeps a dense feed inside one global rendered publication budget", () => { + const windowed = windowOpdsFeedPublications(denseFeed(), 18); + + expect( + windowed.publications.length + + windowed.groups.reduce((total, group) => total + group.publications.length, 0), + ).toBe(18); + expect(windowed.total).toBe(60); + expect(windowed.hasMore).toBe(true); + }); + + it("continues into grouped publications as the window grows", () => { + const windowed = windowOpdsFeedPublications(denseFeed(), 35); + + expect(windowed.publications).toHaveLength(30); + expect(windowed.groups[0]?.publications).toHaveLength(5); + }); +}); diff --git a/packages/app/src/components/home/opds-desktop-feed-window.ts b/packages/app/src/components/home/opds-desktop-feed-window.ts new file mode 100644 index 000000000..1f600e850 --- /dev/null +++ b/packages/app/src/components/home/opds-desktop-feed-window.ts @@ -0,0 +1,27 @@ +import type { OpdsFeed } from "@readany/core"; + +export interface WindowedOpdsFeed { + publications: OpdsFeed["publications"]; + groups: OpdsFeed["groups"]; + total: number; + hasMore: boolean; +} + +export function windowOpdsFeedPublications( + feed: OpdsFeed, + requestedLimit: number, +): WindowedOpdsFeed { + const limit = Math.max(0, Math.floor(requestedLimit)); + let remaining = limit; + const publications = feed.publications.slice(0, remaining); + remaining -= publications.length; + const groups = feed.groups.map((group) => { + const visible = group.publications.slice(0, remaining); + remaining -= visible.length; + return { ...group, publications: visible }; + }); + const total = + feed.publications.length + + feed.groups.reduce((count, group) => count + group.publications.length, 0); + return { publications, groups, total, hasMore: total > limit }; +} 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 new file mode 100644 index 000000000..f704c7154 --- /dev/null +++ b/packages/app/src/components/home/useOpdsDownload.test.ts @@ -0,0 +1,170 @@ +import type { + ImportBooksResult, + OpdsAcquisition, + OpdsAssetResponse, + OpdsPublication, +} from "@readany/core"; +import { describe, expect, it, vi } from "vitest"; +import type { DesktopImportFile } from "../../lib/book/imported-book-meta"; +vi.mock("../../stores/library-store", () => ({ useLibraryStore: vi.fn() })); +import { createOpdsDownloadAdapter } from "./useOpdsDownload"; + +const selected: OpdsAcquisition = { + rel: ["http://opds-spec.org/acquisition"], + url: "https://catalog.test/book.pdf", + type: "application/pdf", + format: "pdf", +}; + +const publication: OpdsPublication = { + title: "Desktop Book", + authors: ["Author"], + subjects: ["Subject"], + images: [], + acquisitions: [selected], + readingOrder: [], +}; + +function asset(): OpdsAssetResponse { + return Object.assign(new Response(new Uint8Array([1])), { + cancel: vi.fn(async (_reason?: unknown) => undefined), + }) as unknown as OpdsAssetResponse; +} + +function dependencies() { + return { + platform: { + writeFile: vi.fn(async (_path: string, _data: Uint8Array) => undefined), + deleteFile: vi.fn(async (_path: string) => undefined), + mkdir: vi.fn(async (_path: string) => undefined), + joinPath: vi.fn(async (...parts: string[]) => parts.join("\\")), + }, + client: { + fetchAsset: vi.fn( + async (_url: string, _origin: string, _credentials?: unknown, _signal?: AbortSignal) => + asset(), + ), + }, + importBooks: vi.fn( + async (_files: DesktopImportFile[], _options?: { transactional?: boolean }) => + ({ + imported: [{ id: "desktop-book" }], + skippedDuplicates: [], + failures: [], + }) as unknown as ImportBooksResult, + ), + getTempDirectory: vi.fn(async () => "C:\\Temp"), + createId: () => "fixed-id", + }; +} + +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); + + await run({ + publication, + acquisition: selected, + catalogOrigin: "https://catalog.test", + }); + + const [[files]] = deps.importBooks.mock.calls; + expect(deps.importBooks.mock.calls[0]?.[1]).toEqual({ transactional: true }); + expect(files).toEqual([ + { + path: expect.stringMatching(/^C:\\Temp\\readany-opds-import\\opds-.*\.pdf$/), + name: "Desktop Book.pdf", + metadata: { + title: "Desktop Book", + author: "Author", + subjects: ["Subject"], + }, + }, + ]); + const importedFile = files[0]; + expect(typeof importedFile).toBe("object"); + if (typeof importedFile === "string") throw new Error("Expected desktop import context"); + expect(importedFile.metadata).not.toHaveProperty("tags"); + expect(deps.platform.deleteFile).toHaveBeenCalledExactlyOnceWith(importedFile.path); + }); + + it("maps temporary setup failure to download-failed without cleanup", async () => { + const deps = dependencies(); + deps.platform.mkdir.mockRejectedValueOnce(new Error("C:\\private\\backend detail")); + const run = createOpdsDownloadAdapter(deps as never); + + const error = await run({ publication, catalogOrigin: "https://catalog.test" }).catch( + (value: unknown) => value, + ); + + expect(error).toMatchObject({ code: "download-failed" }); + expect(String(error)).not.toContain("private"); + expect(deps.platform.deleteFile).not.toHaveBeenCalled(); + }); + + it("cleans exactly once when the desktop store throws and returns a stable error", async () => { + const deps = dependencies(); + deps.importBooks.mockRejectedValueOnce(new Error("sqlite private detail")); + const run = createOpdsDownloadAdapter(deps as never); + + const error = await run({ publication, catalogOrigin: "https://catalog.test" }).catch( + (value: unknown) => value, + ); + + expect(error).toMatchObject({ code: "import-failed" }); + expect(String(error)).not.toContain("sqlite private detail"); + expect(deps.platform.deleteFile).toHaveBeenCalledOnce(); + }); + + it("does not auto-pick when multiple formats are present", async () => { + const deps = dependencies(); + const run = createOpdsDownloadAdapter(deps as never); + + await expect( + run({ + publication: { + ...publication, + acquisitions: [ + selected, + { + rel: ["http://opds-spec.org/acquisition"], + url: "https://catalog.test/book.epub", + type: "application/epub+zip", + format: "epub", + }, + ], + }, + catalogOrigin: "https://catalog.test", + }), + ).rejects.toMatchObject({ code: "unsupported-acquisition" }); + expect(deps.importBooks).not.toHaveBeenCalled(); + expect(deps.platform.deleteFile).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/components/home/useOpdsDownload.ts b/packages/app/src/components/home/useOpdsDownload.ts new file mode 100644 index 000000000..bef25ac92 --- /dev/null +++ b/packages/app/src/components/home/useOpdsDownload.ts @@ -0,0 +1,187 @@ +import { + type ImportBooksResult, + type OpdsAcquisition, + OpdsClient, + type OpdsCredentials, + type OpdsDownloadProgress, + OpdsError, + type OpdsPublication, + createExclusiveOpdsDownloadRunner, + downloadOpdsAcquisition, + listSupportedAcquisitions, + toBookMeta, +} from "@readany/core"; +import { type IPlatformService, getPlatformService } from "@readany/core/services"; +import { generateId } from "@readany/core/utils"; +import { useMemo, useState } from "react"; +import type { DesktopImportFile } from "../../lib/book/imported-book-meta"; +import { useLibraryStore } from "../../stores/library-store"; + +type OpdsDownloadPlatform = Pick< + IPlatformService, + "writeFile" | "deleteFile" | "mkdir" | "joinPath" +>; + +export interface OpdsDownloadRequest { + publication: OpdsPublication; + acquisition?: OpdsAcquisition; + catalogOrigin: string; + credentials?: OpdsCredentials; + signal?: AbortSignal; + onProgress?: (progress: OpdsDownloadProgress) => void; + onImportStart?: () => void; +} + +export interface OpdsImportDownloadResult { + importResult: ImportBooksResult; + cleanupFailed: boolean; +} + +export interface OpdsDownloadAdapterDependencies { + platform: OpdsDownloadPlatform; + client: Pick; + importBooks( + files: DesktopImportFile[], + options?: { transactional?: boolean }, + ): Promise; + getTempDirectory(): Promise; + createId?(): string; + onCleanupError?(cleanupError: unknown, primaryError: unknown): void; +} + +let temporaryFileSequence = 0; + +function nextTemporaryName(format: string, createId?: () => string): string { + temporaryFileSequence += 1; + const id = createId?.() ?? generateId(); + return `opds-${Date.now()}-${temporaryFileSequence}-${id}.${format}`; +} + +function selectedFormat(request: OpdsDownloadRequest) { + const supported = listSupportedAcquisitions(request.publication); + if (!request.acquisition) { + if (supported.length === 1) return supported[0]; + throw new OpdsError("unsupported-acquisition"); + } + const selected = supported.find( + (choice) => + choice.url === request.acquisition?.url && + choice.type === request.acquisition.type && + choice.rel.join("\u0000") === request.acquisition.rel.join("\u0000"), + ); + if (!selected) throw new OpdsError("unsupported-acquisition"); + return selected; +} + +export function createOpdsDownloadAdapter(dependencies: OpdsDownloadAdapterDependencies) { + return async (request: OpdsDownloadRequest): Promise => { + const choice = selectedFormat(request); + let temporaryPath: string; + try { + const tempRoot = await dependencies.getTempDirectory(); + const workspace = await dependencies.platform.joinPath(tempRoot, "readany-opds-import"); + await dependencies.platform.mkdir(workspace); + temporaryPath = await dependencies.platform.joinPath( + workspace, + nextTemporaryName(choice.format, dependencies.createId), + ); + } catch { + throw new OpdsError("download-failed"); + } + + let primaryError: unknown; + let importResult: ImportBooksResult | undefined; + let cleanupFailed = false; + try { + const downloaded = await downloadOpdsAcquisition({ + ...request, + acquisition: request.acquisition, + client: dependencies.client, + platform: dependencies.platform, + destinationPath: temporaryPath, + }); + request.onImportStart?.(); + try { + importResult = await dependencies.importBooks( + [ + { + path: temporaryPath, + name: downloaded.suggestedFileName, + metadata: toBookMeta(request.publication), + }, + ], + { transactional: true }, + ); + } catch { + throw new OpdsError("import-failed"); + } + if (importResult.failures.length > 0) throw new OpdsError("import-failed"); + } catch (error) { + primaryError = error; + } finally { + try { + await dependencies.platform.deleteFile(temporaryPath); + } catch (cleanupError) { + cleanupFailed = true; + try { + dependencies.onCleanupError?.(cleanupError, primaryError); + } catch { + // Cleanup reporting is best effort and must never replace the operation result. + } + } + } + + if (primaryError) throw primaryError; + if (!importResult) throw new OpdsError("import-failed"); + return { importResult, cleanupFailed }; + }; +} + +export function useOpdsDownload() { + const importBooks = useLibraryStore((state) => state.importBooks); + const [progress, setProgress] = useState(null); + const [isDownloading, setIsDownloading] = useState(false); + const runner = useMemo( + () => + createExclusiveOpdsDownloadRunner< + OpdsDownloadRequest & { + signal: AbortSignal; + onProgress: (progress: OpdsDownloadProgress) => void; + }, + OpdsImportDownloadResult, + OpdsDownloadProgress + >( + async ( + request: OpdsDownloadRequest & { + signal: AbortSignal; + onProgress: (progress: OpdsDownloadProgress) => void; + }, + ) => { + const platform = getPlatformService(); + const adapter = createOpdsDownloadAdapter({ + platform, + client: new OpdsClient(platform), + importBooks, + getTempDirectory: async () => (await import("@tauri-apps/api/path")).tempDir(), + onCleanupError: () => { + console.warn("[OPDS] Temporary download cleanup failed."); + }, + }); + return adapter({ + ...request, + }); + }, + { + onStart: () => { + setIsDownloading(true); + setProgress(null); + }, + onProgress: setProgress, + onFinish: () => setIsDownloading(false), + }, + ), + [importBooks], + ); + + return { download: runner.download, cancel: runner.cancel, progress, isDownloading }; +} diff --git a/packages/app/src/components/ui/accessibility-controls.test.tsx b/packages/app/src/components/ui/accessibility-controls.test.tsx new file mode 100644 index 000000000..df8d8f45f --- /dev/null +++ b/packages/app/src/components/ui/accessibility-controls.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment jsdom + +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { Dialog, DialogContent, DialogTitle } from "./dialog"; +import { PasswordInput } from "./password-input"; + +describe("shared secure dialog controls", () => { + it("gives the password reveal control localized keyboard-accessible labels", async () => { + render( + , + ); + const reveal = screen.getByRole("button", { name: "Show saved secret" }); + + reveal.focus(); + expect(document.activeElement).toBe(reveal); + await userEvent.keyboard("{Enter}"); + + expect(screen.getByLabelText("Password").getAttribute("type")).toBe("text"); + expect(screen.getByRole("button", { name: "Hide saved secret" })).toBe(reveal); + }); + + it("uses the caller-provided localized dialog close label", () => { + render( + + + Catalogue + + , + ); + + expect(screen.getByRole("button", { name: "Fermer" })).toBeTruthy(); + }); +}); diff --git a/packages/app/src/components/ui/dialog.tsx b/packages/app/src/components/ui/dialog.tsx index a38234aa9..5037f9948 100644 --- a/packages/app/src/components/ui/dialog.tsx +++ b/packages/app/src/components/ui/dialog.tsx @@ -26,8 +26,13 @@ function DialogOverlay({ function DialogContent({ className, children, + closeLabel = "Close", + closeDisabled = false, ...props -}: ComponentPropsWithoutRef) { +}: ComponentPropsWithoutRef & { + closeLabel?: string; + closeDisabled?: boolean; +}) { return ( @@ -39,9 +44,12 @@ function DialogContent({ {...props} > {children} - + - Close + {closeLabel} diff --git a/packages/app/src/components/ui/password-input.tsx b/packages/app/src/components/ui/password-input.tsx index cd85ec406..d64cbe1f4 100644 --- a/packages/app/src/components/ui/password-input.tsx +++ b/packages/app/src/components/ui/password-input.tsx @@ -2,16 +2,25 @@ import { cn } from "@readany/core/utils"; import { Eye, EyeOff } from "lucide-react"; import { type InputHTMLAttributes, useState } from "react"; +interface PasswordInputProps extends Omit, "type"> { + showPasswordLabel?: string; + hidePasswordLabel?: string; +} + export function PasswordInput({ className, + showPasswordLabel = "Show password", + hidePasswordLabel = "Hide password", + disabled, ...props -}: Omit, "type">) { +}: PasswordInputProps) { const [visible, setVisible] = useState(false); return (
diff --git a/packages/app/src/lib/book/auto-metadata.test.ts b/packages/app/src/lib/book/auto-metadata.test.ts new file mode 100644 index 000000000..3ccdee98d --- /dev/null +++ b/packages/app/src/lib/book/auto-metadata.test.ts @@ -0,0 +1,90 @@ +import type { Book } from "@readany/core/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { extractLocalBookMetadata } from "./auto-metadata"; + +const fsState = vi.hoisted(() => ({ exists: true })); +const readFile = vi.hoisted(() => vi.fn(async () => new Uint8Array([1, 2, 3]))); +const getCover = vi.hoisted(() => + vi.fn(async () => new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" })), +); +const openDocument = vi.hoisted(() => + vi.fn(async () => ({ + book: { + metadata: { + title: "Embedded title", + author: { name: "Embedded author" }, + publisher: "Embedded press", + subject: ["History"], + }, + getCover, + }, + })), +); + +vi.mock("@/lib/storage/desktop-library-root", () => ({ + resolveDesktopDataPath: vi.fn(async (path: string) => `C:/library/${path}`), +})); +vi.mock("@/lib/reader/document-loader", () => ({ + DocumentLoader: class { + open = openDocument; + }, +})); +vi.mock("@tauri-apps/plugin-fs", () => ({ + exists: vi.fn(async () => fsState.exists), + readFile, +})); + +describe("desktop local book metadata repair", () => { + beforeEach(() => { + fsState.exists = true; + vi.clearAllMocks(); + getCover.mockResolvedValue(new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" })); + }); + + it.each(["epub", "mobi", "azw", "azw3"])( + "extracts metadata and cover from a local %s file", + async (format) => { + await expect( + extractLocalBookMetadata(createBook(format as Book["format"])), + ).resolves.toMatchObject({ + title: "Embedded title", + author: "Embedded author", + publisher: "Embedded press", + subjects: ["History"], + coverBlob: expect.any(Blob), + }); + expect(openDocument).toHaveBeenCalledOnce(); + expect(getCover).toHaveBeenCalledOnce(); + }, + ); + + it("keeps document metadata when cover extraction fails", async () => { + getCover.mockRejectedValueOnce(new Error("bad cover")); + + await expect(extractLocalBookMetadata(createBook("mobi"))).resolves.toMatchObject({ + title: "Embedded title", + publisher: "Embedded press", + subjects: ["History"], + }); + expect(getCover).toHaveBeenCalledOnce(); + }); + + it("does not read a missing local file", async () => { + fsState.exists = false; + + await expect(extractLocalBookMetadata(createBook("mobi"))).resolves.toBeNull(); + expect(readFile).not.toHaveBeenCalled(); + }); +}); + +function createBook(format: Book["format"]): Book { + return { + id: `legacy-${format}`, + filePath: `books/legacy.${format}`, + format, + syncStatus: "local", + meta: { title: "Saved title", author: "Saved author" }, + progress: 0, + addedAt: 1, + } as Book; +} diff --git a/packages/app/src/lib/book/auto-metadata.ts b/packages/app/src/lib/book/auto-metadata.ts index 7e524f4e5..431d06cf9 100644 --- a/packages/app/src/lib/book/auto-metadata.ts +++ b/packages/app/src/lib/book/auto-metadata.ts @@ -1,110 +1,43 @@ import { resolveDesktopDataPath } from "@/lib/storage/desktop-library-root"; import type { Book } from "@readany/core/types"; import type { ExtractedBookMetadata } from "@readany/core/utils"; +import { type FoliateDocumentMetadata, fromDocumentMetadata } from "./imported-book-meta"; -export async function extractLocalBookMetadata(book: Book): Promise { - if (book.syncStatus === "remote" || book.format !== "epub" || !book.filePath) return null; +export type DesktopExtractedBookMetadata = ExtractedBookMetadata & { coverBlob?: Blob | null }; + +export async function extractLocalBookMetadata( + book: Book, +): Promise { + if (book.syncStatus === "remote" || !isRepairableFormat(book.format) || !book.filePath) { + return null; + } try { const filePath = await resolveDesktopDataPath(book.filePath); const { exists, readFile } = await import("@tauri-apps/plugin-fs"); if (!(await exists(filePath))) return null; - return extractEpubOpfMetadata(await readFile(filePath)); + const bytes = await readFile(filePath); + const fileName = book.filePath.split(/[\\/]/).pop() || `${book.id}.${book.format}`; + const file = new File([bytes], fileName, { type: "application/octet-stream" }); + const { DocumentLoader } = await import("@/lib/reader/document-loader"); + const { book: document } = await new DocumentLoader(file).open(); + const metadata = fromDocumentMetadata(document.metadata as unknown as FoliateDocumentMetadata); + if (book.meta.coverUrl?.trim()) return metadata; + + try { + const coverBlob = await document.getCover?.(); + if (!coverBlob) return metadata; + return { ...metadata, coverBlob }; + } catch (error) { + console.warn("[BookMetadata] Failed to extract local cover:", error); + return metadata; + } } catch (error) { console.warn("[BookMetadata] Failed to extract local metadata:", error); return null; } } -async function extractEpubOpfMetadata(bytes: Uint8Array): Promise { - const { configure, ZipReader, BlobReader, TextWriter } = await import("@zip.js/zip.js"); - configure({ useWebWorkers: false }); - - const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); - const reader = new ZipReader(new BlobReader(new Blob([buffer]))); - - try { - const entries = await reader.getEntries(); - const entryMap = new Map(entries.map((entry) => [entry.filename, entry])); - - const readTextEntry = async (path: string): Promise => { - let entry = entryMap.get(path); - if (!entry) { - const lower = path.toLowerCase(); - entry = entries.find((candidate) => candidate.filename.toLowerCase() === lower); - } - if (!entry || entry.directory || !entry.getData) return null; - return entry.getData(new TextWriter()); - }; - - const containerXml = await readTextEntry("META-INF/container.xml"); - if (!containerXml) return null; - - const parser = new DOMParser(); - const containerDoc = parser.parseFromString(containerXml, "application/xml"); - const opfPath = - Array.from(containerDoc.getElementsByTagName("rootfile"))[0]?.getAttribute("full-path") || - "content.opf"; - const opfXml = await readTextEntry(opfPath); - if (!opfXml) return null; - - return parseOpfMetadata(opfXml); - } finally { - await reader.close(); - } -} - -function parseOpfMetadata(opfXml: string): ExtractedBookMetadata { - const parser = new DOMParser(); - const doc = parser.parseFromString(opfXml, "application/xml"); - const metadata = - Array.from(doc.getElementsByTagName("*")).find((element) => element.localName === "metadata") ?? - doc.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); - - return { - title: textByLocalName("title"), - author: textByLocalName("creator"), - publisher: textByLocalName("publisher"), - language: textByLocalName("language"), - isbn: extractIsbn(elements), - publishDate: extractPublishDate(elements), - description: textByLocalName("description"), - subjects, - }; -} - -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() || ""; +function isRepairableFormat(format: Book["format"]): boolean { + return format === "epub" || format === "mobi" || format === "azw" || format === "azw3"; } diff --git a/packages/app/src/lib/book/cover-storage.test.ts b/packages/app/src/lib/book/cover-storage.test.ts new file mode 100644 index 000000000..01522ffe7 --- /dev/null +++ b/packages/app/src/lib/book/cover-storage.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fs = vi.hoisted(() => ({ + mkdir: vi.fn(async () => undefined), + writeFile: vi.fn(async () => undefined), + remove: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/storage/desktop-library-root", () => ({ + getDesktopLibraryRoot: vi.fn(async () => "C:/library"), +})); +vi.mock("@tauri-apps/plugin-fs", () => fs); +vi.mock("@tauri-apps/api/path", () => ({ + join: vi.fn(async (...parts: string[]) => parts.join("/")), +})); + +import * as coverStorage from "./cover-storage"; + +describe("desktop cover file extensions", () => { + const getCoverFileExtension = ( + coverStorage as typeof coverStorage & { + getCoverFileExtension?: (blob: Blob) => Promise; + } + ).getCoverFileExtension; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps recognized image MIME types", async () => { + expect(getCoverFileExtension).toBeTypeOf("function"); + if (!getCoverFileExtension) return; + + await expect(getCoverFileExtension(new Blob([], { type: "image/webp" }))).resolves.toBe("webp"); + await expect(getCoverFileExtension(new Blob([], { type: "image/gif" }))).resolves.toBe("gif"); + await expect(getCoverFileExtension(new Blob([], { type: "image/png" }))).resolves.toBe("png"); + await expect(getCoverFileExtension(new Blob([], { type: "image/jpeg" }))).resolves.toBe("jpg"); + }); + + it("sniffs image bytes when the MIME type is absent", async () => { + expect(getCoverFileExtension).toBeTypeOf("function"); + if (!getCoverFileExtension) return; + + await expect( + getCoverFileExtension( + new Blob([new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50])]), + ), + ).resolves.toBe("webp"); + await expect( + getCoverFileExtension(new Blob([new TextEncoder().encode("GIF89a")])), + ).resolves.toBe("gif"); + await expect( + getCoverFileExtension( + new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])]), + ), + ).resolves.toBe("png"); + await expect( + getCoverFileExtension(new Blob([new Uint8Array([0xff, 0xd8, 0xff, 0xe0])])), + ).resolves.toBe("jpg"); + }); + + it("removes only a newly extracted cover when a custom cover wins during persistence", async () => { + const saveExtractedCoverIfStillMissing = ( + coverStorage as typeof coverStorage & { + saveExtractedCoverIfStillMissing?: ( + bookId: string, + blob: Blob, + getCurrentCoverUrl: () => string | undefined, + ) => Promise; + } + ).saveExtractedCoverIfStillMissing; + expect(saveExtractedCoverIfStillMissing).toBeTypeOf("function"); + if (!saveExtractedCoverIfStillMissing) return; + + let currentCoverUrl = ""; + fs.writeFile.mockImplementationOnce(async () => { + currentCoverUrl = "covers/book-custom-user.webp"; + }); + + await expect( + saveExtractedCoverIfStillMissing( + "book", + new Blob([new Uint8Array([1])], { type: "image/webp" }), + () => currentCoverUrl, + ), + ).resolves.toBeUndefined(); + expect(fs.remove).toHaveBeenCalledWith("C:/library/covers/book.webp"); + expect(fs.remove).not.toHaveBeenCalledWith("C:/library/covers/book-custom-user.webp"); + }); + + it("cleans an extracted cover when custom selection completes after persistence", async () => { + const commitCustomCover = ( + coverStorage as typeof coverStorage & { + commitCustomCover?: ( + bookId: string, + customCoverUrl: string, + persist: (coverUrl: string) => Promise, + ) => Promise; + } + ).commitCustomCover; + expect(commitCustomCover).toBeTypeOf("function"); + if (!commitCustomCover) return; + + await expect( + coverStorage.saveExtractedCoverIfStillMissing( + "book", + new Blob([new Uint8Array([1])], { type: "image/webp" }), + () => "", + ), + ).resolves.toBe("covers/book.webp"); + + const persisted: string[] = []; + await commitCustomCover("book", "covers/book-custom-user.png", async (coverUrl) => { + persisted.push(coverUrl); + }); + + expect(persisted).toEqual(["covers/book-custom-user.png"]); + expect(fs.remove).toHaveBeenCalledWith("C:/library/covers/book.webp"); + expect(fs.remove).not.toHaveBeenCalledWith("C:/library/covers/book-custom-user.png"); + }); +}); diff --git a/packages/app/src/lib/book/cover-storage.ts b/packages/app/src/lib/book/cover-storage.ts new file mode 100644 index 000000000..c80fa043a --- /dev/null +++ b/packages/app/src/lib/book/cover-storage.ts @@ -0,0 +1,121 @@ +import { getDesktopLibraryRoot } from "@/lib/storage/desktop-library-root"; + +const extractedCoverPaths = new Map>(); + +/** Save a cover under the managed desktop library and return its relative path. */ +export async function saveCoverToAppData(bookId: string, coverBlob: Blob): Promise { + const { writeFile, mkdir } = await import("@tauri-apps/plugin-fs"); + const { join } = await import("@tauri-apps/api/path"); + + const libraryRoot = await getDesktopLibraryRoot(); + const coversDir = await join(libraryRoot, "covers"); + try { + await mkdir(coversDir, { recursive: true }); + } catch { + // Directory may already exist. + } + + const extension = await getCoverFileExtension(coverBlob); + const relativePath = `covers/${bookId}.${extension}`; + const coverPath = await join(libraryRoot, relativePath); + const arrayBuffer = await coverBlob.arrayBuffer(); + await writeFile(coverPath, new Uint8Array(arrayBuffer)); + return relativePath; +} + +export async function getCoverFileExtension(coverBlob: Blob): Promise { + const mimeExtension = extensionFromMimeType(coverBlob.type); + if (mimeExtension) return mimeExtension; + + const bytes = new Uint8Array(await coverBlob.slice(0, 12).arrayBuffer()); + return extensionFromImageBytes(bytes) ?? "jpg"; +} + +export async function saveExtractedCoverIfStillMissing( + bookId: string, + coverBlob: Blob, + getCurrentCoverUrl: () => string | undefined, +): Promise { + if (getCurrentCoverUrl()?.trim()) return undefined; + + const relativePath = await saveCoverToAppData(bookId, coverBlob); + trackExtractedCover(bookId, relativePath); + if (!getCurrentCoverUrl()?.trim()) return relativePath; + + await removeTrackedExtractedCover(bookId, relativePath); + return undefined; +} + +export async function commitCustomCover( + bookId: string, + customCoverUrl: string, + persist: (coverUrl: string) => Promise, +): Promise { + await persist(customCoverUrl); + const paths = extractedCoverPaths.get(bookId); + if (!paths) return; + + for (const relativePath of [...paths]) { + if (relativePath !== customCoverUrl) { + await removeTrackedExtractedCover(bookId, relativePath); + } + } +} + +function trackExtractedCover(bookId: string, relativePath: string): void { + const paths = extractedCoverPaths.get(bookId) ?? new Set(); + paths.add(relativePath); + extractedCoverPaths.set(bookId, paths); +} + +async function removeTrackedExtractedCover(bookId: string, relativePath: string): Promise { + try { + const { remove } = await import("@tauri-apps/plugin-fs"); + const { join } = await import("@tauri-apps/api/path"); + await remove(await join(await getDesktopLibraryRoot(), relativePath)); + const paths = extractedCoverPaths.get(bookId); + paths?.delete(relativePath); + if (paths?.size === 0) extractedCoverPaths.delete(bookId); + } catch (error) { + console.warn("[BookMetadata] Failed to clean up rejected extracted cover:", error); + } +} + +function extensionFromMimeType(mimeType: string): string | null { + switch (mimeType.toLowerCase().split(";", 1)[0]?.trim()) { + case "image/webp": + return "webp"; + case "image/gif": + return "gif"; + case "image/png": + return "png"; + case "image/jpg": + case "image/jpeg": + return "jpg"; + default: + return null; + } +} + +function extensionFromImageBytes(bytes: Uint8Array): string | null { + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "jpg"; + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) { + return "png"; + } + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) { + return "gif"; + } + if ( + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50 + ) { + return "webp"; + } + return null; +} 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..6996d8975 --- /dev/null +++ b/packages/app/src/lib/book/imported-book-meta.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from "vitest"; +import * as importedBookMeta from "./imported-book-meta"; + +const { buildImportedBookMeta, fromDocumentMetadata } = importedBookMeta; + +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", + isbn: "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: "9781402894626", + 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"]); + }); + + it("handles the exact MOBI metadata arrays emitted by foliate-js", () => { + expect( + fromDocumentMetadata({ + identifier: "123456", + title: "MOBI title", + author: ["First author", "Second author"], + publisher: "MOBI Press", + language: "en", + published: "2024-08-06", + description: "MOBI description", + subject: ["History", "Science"], + contributor: ["Editor Name"], + }), + ).toEqual({ + title: "MOBI title", + author: "First author, Second author", + publisher: "MOBI Press", + language: "en", + isbn: undefined, + publishDate: "2024-08-06", + description: "MOBI description", + subjects: ["History", "Science"], + }); + }); + + it("handles the exact contributor, language-map, and identifier shapes emitted by EPUB", () => { + expect( + fromDocumentMetadata({ + identifier: "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + altIdentifier: [ + "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "urn:isbn:978-1-4028-9462-6", + ], + title: { en: "EPUB title", fr: "Titre EPUB" }, + author: [ + { name: { en: "First author" }, role: ["aut"] }, + { name: "Second author", role: ["aut"] }, + ], + publisher: [{ name: { en: "EPUB Press" }, role: ["pbl"] }], + language: ["en-US", "fr"], + published: "2024-08-06", + description: "EPUB description", + subject: [{ name: { en: "History" } }, { name: "Science" }], + }), + ).toEqual({ + title: "EPUB title", + author: "First author, Second author", + publisher: "EPUB Press", + language: "en-US", + isbn: "9781402894626", + publishDate: "2024-08-06", + description: "EPUB description", + subjects: ["History", "Science"], + }); + }); + + it("never treats Foliate UIDs or UUIDs as ISBNs", () => { + expect(fromDocumentMetadata({ identifier: "123456" }).isbn).toBeUndefined(); + expect(fromDocumentMetadata({ identifier: "9781402894626" }).isbn).toBeUndefined(); + expect( + fromDocumentMetadata({ identifier: "urn:uuid:550e8400-e29b-41d4-a716-446655440000" }).isbn, + ).toBeUndefined(); + expect(fromDocumentMetadata({ identifier: "urn:isbn:978-1-4028-9462-6" }).isbn).toBe( + "9781402894626", + ); + }); + + it("preserves rich PDF info and XMP metadata even when no cover is available", () => { + const fromPdfMetadata = ( + importedBookMeta as typeof importedBookMeta & { + fromPdfMetadata?: ( + info: Record | undefined, + metadata: { get(name: string): unknown } | undefined, + ) => Record; + } + ).fromPdfMetadata; + expect(fromPdfMetadata).toBeTypeOf("function"); + if (!fromPdfMetadata) return; + + const xmp = new Map([ + ["dc:title", "XMP title"], + ["dc:creator", ["XMP author", "Second author"]], + ["dc:description", "XMP description"], + ["dc:language", ["en-US"]], + ["dc:publisher", [{ name: "XMP Press" }]], + ["dc:identifier", "urn:isbn:978-1-4028-9462-6"], + ["prism:publicationdate", ["2024-08-06"]], + ["dc:subject", ["History", "Science"]], + ]); + const embedded = fromPdfMetadata( + { + Title: "Info title", + Author: "Info author", + Subject: "Info description", + Keywords: "Fallback, Keywords", + CreationDate: "D:20230805000000Z", + }, + { get: (name) => xmp.get(name) }, + ); + + expect(buildImportedBookMeta({ embedded, fallbackTitle: "filename" })).toMatchObject({ + title: "XMP title", + author: "XMP author, Second author", + publisher: "XMP Press", + language: "en", + isbn: "9781402894626", + publishDate: "2024-08-06", + description: "XMP description", + subjects: ["History", "Science"], + }); + }); + + it("does not fabricate a publication date from generic PDF timestamps", () => { + const xmp = new Map([["dc:date", "2024-08-06"]]); + + expect( + importedBookMeta.fromPdfMetadata( + { + CreationDate: "D:20230805000000Z", + ModDate: "D:20250102000000Z", + }, + { get: (name) => xmp.get(name) }, + ).publishDate, + ).toBeUndefined(); + }); + + it("reads explicitly publication-scoped PDF metadata exposed by pdfjs", () => { + expect( + importedBookMeta.fromPdfMetadata(undefined, { + get: (name) => (name === "dcterms:issued" ? "2020-05" : undefined), + }).publishDate, + ).toBe("2020-05"); + + expect( + importedBookMeta.fromPdfMetadata({ Custom: { PublicationDate: "2019" } }, undefined) + .publishDate, + ).toBe("2019"); + }); + + it("restores saved publication values byte-for-byte while catalog metadata fills blanks", () => { + expect( + buildImportedBookMeta({ + existing: { + title: " Saved Desktop Title ", + author: "", + publisher: " Saved Desktop Press ", + language: "en-US", + isbn: " ISBN 978-1-4028-9462-6 ", + publishDate: " 2020-4-3 ", + description: " Saved desktop description ", + subjects: [" History ", "History"], + }, + opds: { author: " Catalog author ", language: "fr-FR" }, + embedded: { author: "Embedded author" }, + fallbackTitle: "filename", + }), + ).toMatchObject({ + title: " Saved Desktop Title ", + author: "Catalog author", + publisher: " Saved Desktop Press ", + language: "en-US", + isbn: " ISBN 978-1-4028-9462-6 ", + publishDate: " 2020-4-3 ", + description: " Saved desktop description ", + subjects: [" History ", "History"], + }); + }); + + it("carries a desktop import context into the ordered metadata merge", () => { + const buildDesktopImportedBookMeta = ( + importedBookMeta as typeof importedBookMeta & { + buildDesktopImportedBookMeta?: (input: { + file: string | { path: string; metadata?: Record }; + existing?: Record; + embedded?: Record; + fallbackTitle: string; + }) => Record; + } + ).buildDesktopImportedBookMeta; + + expect(buildDesktopImportedBookMeta).toBeTypeOf("function"); + if (!buildDesktopImportedBookMeta) return; + + expect( + buildDesktopImportedBookMeta({ + file: { + path: "C:/imports/catalog.epub", + metadata: { title: "Catalog title", author: "Catalog author" }, + }, + embedded: { title: "Embedded title", author: "Embedded author", language: "fr" }, + fallbackTitle: "filename", + }), + ).toMatchObject({ title: "Catalog title", author: "Catalog author", language: "fr" }); + + expect( + buildDesktopImportedBookMeta({ + file: "C:/imports/legacy.epub", + embedded: { title: "Embedded title" }, + fallbackTitle: "filename", + }), + ).toMatchObject({ title: "Embedded title" }); + }); + + it("skips embedded cover persistence when saved or import metadata owns the cover", () => { + const shouldPersistEmbeddedCover = ( + importedBookMeta as typeof importedBookMeta & { + shouldPersistEmbeddedCover?: ( + existing?: { coverUrl?: string }, + imported?: { coverUrl?: string }, + ) => boolean; + } + ).shouldPersistEmbeddedCover; + expect(shouldPersistEmbeddedCover).toBeTypeOf("function"); + if (!shouldPersistEmbeddedCover) return; + + expect(shouldPersistEmbeddedCover({ coverUrl: "covers/saved.jpg" }, undefined)).toBe(false); + expect(shouldPersistEmbeddedCover(undefined, { coverUrl: "https://catalog/cover.jpg" })).toBe( + false, + ); + expect(shouldPersistEmbeddedCover({ coverUrl: " " }, { coverUrl: "" })).toBe(true); + }); +}); 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..c4947b51f --- /dev/null +++ b/packages/app/src/lib/book/imported-book-meta.ts @@ -0,0 +1,194 @@ +import type { BookMeta } from "@readany/core/types"; +import { + type ExtractedBookMetadata, + mergeBookMetadataSources, + normalizeIsbn, +} from "@readany/core/utils"; + +type EmbeddedBookMetadata = ExtractedBookMetadata & { coverUrl?: string }; + +type FoliateLanguageMap = Record; +type FoliateText = string | FoliateLanguageMap; + +export interface FoliateContributor { + name?: FoliateText; + sortAs?: FoliateText; + role?: string[]; + code?: string; + scheme?: string; +} + +export interface FoliateDocumentMetadata extends Record { + identifier?: string; + altIdentifier?: Array; + isbn?: string; + title?: FoliateText; + author?: string | FoliateContributor | Array; + contributor?: string | FoliateContributor | Array; + publisher?: string | FoliateContributor | Array; + language?: string | string[]; + published?: string; + description?: FoliateText; + subject?: string | FoliateContributor | Array; +} + +export interface DesktopImportFileContext { + path: string; + name?: string; + metadata?: Partial; +} + +export type DesktopImportFile = string | DesktopImportFileContext; + +export function shouldPersistEmbeddedCover( + existing?: Partial, + imported?: Partial, +): boolean { + return !existing?.coverUrl?.trim() && !imported?.coverUrl?.trim(); +} + +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 normalizeDesktopImportFile(file: DesktopImportFile): DesktopImportFileContext { + return typeof file === "string" ? { path: file } : file; +} + +export function buildDesktopImportedBookMeta(input: { + file: DesktopImportFile; + existing?: Partial; + embedded?: EmbeddedBookMetadata; + fallbackTitle: string; +}): BookMeta { + const file = normalizeDesktopImportFile(input.file); + return buildImportedBookMeta({ + existing: input.existing, + opds: file.metadata, + embedded: input.embedded, + fallbackTitle: input.fallbackTitle, + }); +} + +export function fromDocumentMetadata( + meta: FoliateDocumentMetadata | undefined, +): ExtractedBookMetadata { + return { + title: firstMetadataText(meta?.title), + author: joinMetadataText(meta?.author), + publisher: joinMetadataText(meta?.publisher) || undefined, + language: firstMetadataText(meta?.language) || undefined, + isbn: + firstValidIsbn(meta?.isbn, explicitIsbnIdentifier(meta?.identifier), meta?.altIdentifier) || + undefined, + publishDate: firstMetadataText(meta?.published) || undefined, + description: firstMetadataText(meta?.description) || undefined, + subjects: collectMetadataText(meta?.subject), + }; +} + +export function fromPdfMetadata( + info: Record | undefined, + metadata: { get(name: string): unknown } | undefined, +): ExtractedBookMetadata { + const xmp = (name: string) => metadata?.get(name); + const xmpSubjects = collectMetadataText(xmp("dc:subject")); + const infoSubjects = splitPdfKeywords(info?.Keywords); + + return { + title: firstMetadataText(xmp("dc:title")) || firstMetadataText(info?.Title), + author: joinMetadataText(xmp("dc:creator")) || joinMetadataText(info?.Author), + publisher: + joinMetadataText(xmp("dc:publisher")) || joinMetadataText(info?.Publisher) || undefined, + language: + firstMetadataText(xmp("dc:language")) || firstMetadataText(info?.Language) || undefined, + isbn: firstValidIsbn(xmp("dc:identifier"), info?.ISBN, info?.Isbn, info?.isbn) || undefined, + publishDate: + firstMetadataText(xmp("prism:publicationdate")) || + firstMetadataText(xmp("dcterms:issued")) || + firstMetadataText(getPdfCustomInfo(info, "PublicationDate")) || + firstMetadataText(getPdfCustomInfo(info, "PublishDate")) || + firstMetadataText(getPdfCustomInfo(info, "Published")) || + undefined, + description: + firstMetadataText(xmp("dc:description")) || firstMetadataText(info?.Subject) || undefined, + subjects: xmpSubjects.length > 0 ? xmpSubjects : infoSubjects, + }; +} + +function collectMetadataText(value: unknown): string[] { + if (typeof value === "string") return value.trim() ? [value.trim()] : []; + if (Array.isArray(value)) return value.flatMap(collectMetadataText); + if (!value || typeof value !== "object") return []; + + const record = value as Record; + if (record.name != null) return collectMetadataText(record.name); + if (record.value != null) return collectMetadataText(record.value); + return Object.values(record).flatMap(collectMetadataText); +} + +function firstMetadataText(value: unknown): string { + return collectMetadataText(value)[0] ?? ""; +} + +function joinMetadataText(value: unknown): string { + return collectMetadataText(value).join(", "); +} + +function explicitIsbnIdentifier(value: unknown): unknown { + if (typeof value !== "string") return undefined; + return /^(?:urn:isbn:|isbn(?:-1[03])?:)/i.test(value.trim()) ? value : undefined; +} + +function firstValidIsbn(...values: unknown[]): string { + for (const value of values) { + if (Array.isArray(value)) { + const nested = firstValidIsbn(...value); + if (nested) return nested; + continue; + } + if (value && typeof value === "object") { + const candidate = value as { scheme?: unknown; value?: unknown }; + if (typeof candidate.scheme === "string" && candidate.scheme.toLowerCase() === "isbn") { + const isbn = normalizeIsbn(candidate.value); + if (isbn) return isbn; + } + continue; + } + const isbn = normalizeIsbn(value); + if (isbn) return isbn; + } + return ""; +} + +function splitPdfKeywords(value: unknown): string[] { + const text = firstMetadataText(value); + return text + ? text + .split(/[,;\n]/) + .map((item) => item.trim()) + .filter(Boolean) + : []; +} + +function getPdfCustomInfo(info: Record | undefined, key: string): unknown { + const custom = info?.Custom; + return custom && typeof custom === "object" + ? (custom as Record)[key] + : undefined; +} diff --git a/packages/app/src/lib/platform/tauri-platform-service.test.ts b/packages/app/src/lib/platform/tauri-platform-service.test.ts new file mode 100644 index 000000000..b6770c8f0 --- /dev/null +++ b/packages/app/src/lib/platform/tauri-platform-service.test.ts @@ -0,0 +1,141 @@ +import { OpdsClient, type OpdsCredentials } from "@readany/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { tauriFetch, tauriInvoke } = vi.hoisted(() => ({ + tauriFetch: vi.fn(), + tauriInvoke: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-http", () => ({ fetch: tauriFetch })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: tauriInvoke })); + +import { TauriPlatformService } from "./tauri-platform-service"; + +const ATOM = ` + + Catalog + Book +`; + +const credentials: OpdsCredentials = { + username: "reader", + password: "secret-password", + catalogOrigin: "https://catalog.test", +}; + +function header(init: RequestInit | undefined, name: string): string | null { + return new Headers(init?.headers).get(name); +} + +describe("TauriPlatformService manual redirect contract", () => { + beforeEach(() => { + tauriFetch.mockReset(); + }); + + it("translates redirect manual into the Tauri native maxRedirections option", async () => { + tauriFetch.mockResolvedValue( + new Response(null, { + status: 302, + headers: { Location: "https://other.test/feed.xml" }, + }), + ); + + const response = await new TauriPlatformService().fetch("https://catalog.test/feed.xml", { + redirect: "manual", + headers: new Headers({ Accept: "application/atom+xml" }), + signal: new AbortController().signal, + }); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("https://other.test/feed.xml"); + const [, init] = tauriFetch.mock.calls[0] as [ + string, + RequestInit & { maxRedirections?: number }, + ]; + expect(init.maxRedirections).toBe(0); + expect(init).not.toHaveProperty("redirect"); + expect(header(init, "Accept")).toBe("application/atom+xml"); + }); + + it("lets core enforce its five-hop redirect cap without native auto-follow", async () => { + tauriFetch.mockImplementation( + async (_url: string, _init: RequestInit) => + new Response(null, { + status: 302, + headers: { Location: `/redirect-${tauriFetch.mock.calls.length}` }, + }), + ); + + await expect( + new OpdsClient(new TauriPlatformService()).open("https://catalog.test/feed.xml"), + ).rejects.toMatchObject({ code: "invalid-catalog" }); + expect(tauriFetch).toHaveBeenCalledTimes(6); + expect( + tauriFetch.mock.calls.every( + ([, init]) => init.maxRedirections === 0 && !("redirect" in init), + ), + ).toBe(true); + }); + + it("lets core inspect per-hop origins and remove cross-origin auth", async () => { + tauriFetch.mockImplementation(async (url: string) => + url === "https://catalog.test/feed.xml" + ? new Response(null, { + status: 302, + headers: { Location: "https://cdn.test/feed.xml" }, + }) + : new Response(ATOM, { + headers: { "Content-Type": "application/atom+xml" }, + }), + ); + + await new OpdsClient(new TauriPlatformService()).open( + "https://catalog.test/feed.xml", + credentials, + ); + + expect(header(tauriFetch.mock.calls[0]?.[1], "Authorization")).not.toBeNull(); + expect(header(tauriFetch.mock.calls[1]?.[1], "Authorization")).toBeNull(); + }); + + it("lets core reject HTTPS downgrade before Tauri issues the next hop", async () => { + tauriFetch.mockResolvedValue( + new Response(null, { + status: 302, + headers: { Location: "http://127.0.0.1/feed.xml" }, + }), + ); + + await expect( + new OpdsClient(new TauriPlatformService()).open("https://catalog.test/feed.xml", credentials), + ).rejects.toMatchObject({ code: "insecure-url" }); + expect(tauriFetch).toHaveBeenCalledTimes(1); + }); +}); + +describe("TauriPlatformService secret contract", () => { + const secretKey = "opds.catalog.11111111-1111-4111-8111-111111111111.password"; + + beforeEach(() => { + tauriInvoke.mockReset(); + }); + + it("invokes the three OS credential commands without routing through localStorage", async () => { + tauriInvoke.mockResolvedValueOnce("stored-password").mockResolvedValue(undefined); + const localStorageSet = vi.fn(); + vi.stubGlobal("localStorage", { setItem: localStorageSet }); + const service = new TauriPlatformService(); + + await expect(service.secretGetItem(secretKey)).resolves.toBe("stored-password"); + await service.secretSetItem(secretKey, "new-password"); + await service.secretRemoveItem(secretKey); + + expect(tauriInvoke.mock.calls).toEqual([ + ["secret_get", { key: secretKey }], + ["secret_set", { key: secretKey, value: "new-password" }], + ["secret_remove", { key: secretKey }], + ]); + expect(localStorageSet).not.toHaveBeenCalled(); + vi.unstubAllGlobals(); + }); +}); diff --git a/packages/app/src/lib/platform/tauri-platform-service.ts b/packages/app/src/lib/platform/tauri-platform-service.ts index f85689637..fbc31169c 100644 --- a/packages/app/src/lib/platform/tauri-platform-service.ts +++ b/packages/app/src/lib/platform/tauri-platform-service.ts @@ -15,6 +15,7 @@ import type { UpdateInfo, WebSocketOptions, } from "@readany/core/services"; +import type { ClientOptions } from "@tauri-apps/plugin-http"; const TAURI_LAN_RUNTIME_ERROR = "Tauri desktop runtime is required to use the LAN sender. Open the desktop app instead of the browser dev server."; @@ -192,14 +193,17 @@ export class TauriPlatformService implements IPlatformService { timeoutMs: _timeoutMs, responseType: _responseType, onDownloadProgress: _onDownloadProgress, + redirect, ...fetchOptions } = options ?? {}; - const tauriOptions = allowInsecure - ? { - ...fetchOptions, - danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true }, - } as any - : fetchOptions; + const browserOptions = redirect ? { ...fetchOptions, redirect } : fetchOptions; + const tauriOptions: RequestInit & ClientOptions = { + ...fetchOptions, + ...(redirect === "manual" ? { maxRedirections: 0 } : {}), + ...(allowInsecure + ? { danger: { acceptInvalidCerts: true, acceptInvalidHostnames: true } } + : {}), + }; try { return await tauriFetch(url, tauriOptions); } catch (error: unknown) { @@ -217,7 +221,7 @@ export class TauriPlatformService implements IPlatformService { console.warn( "[TauriPlatform] tauriFetch failed due to non-ASCII response headers; falling back to native fetch", ); - return globalThis.fetch(url, fetchOptions); + return globalThis.fetch(url, browserOptions); } throw error; } @@ -295,6 +299,23 @@ export class TauriPlatformService implements IPlatformService { } } + // ---- Secret Storage (OS credential store via Tauri commands) ---- + + async secretGetItem(key: string): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + return invoke("secret_get", { key }); + } + + async secretSetItem(key: string, value: string): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("secret_set", { key, value }); + } + + async secretRemoveItem(key: string): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + await invoke("secret_remove", { key }); + } + // ---- KV Storage (backed by localStorage on desktop/web) ---- async kvGetItem(key: string): Promise { diff --git a/packages/app/src/stores/library-store.opds.test.ts b/packages/app/src/stores/library-store.opds.test.ts new file mode 100644 index 000000000..ba36d7e37 --- /dev/null +++ b/packages/app/src/stores/library-store.opds.test.ts @@ -0,0 +1,223 @@ +import type { Book } from "@readany/core/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const existingPaths = new Set(); + return { + db: { + initDatabase: vi.fn(async () => undefined), + insertBook: vi.fn(async () => undefined), + updateBook: vi.fn(async () => undefined), + getDeletedBookByFileHash: vi.fn(async () => null as Book | null), + getDeletedBookByTitle: vi.fn(async () => null as Book | null), + }, + invoke: vi.fn(async () => "same-hash" as string | undefined), + copyFile: vi.fn(async (_source: string, _destination: string) => undefined), + remove: vi.fn(async (_path: string) => undefined), + exists: vi.fn(async (path: string) => existingPaths.has(path)), + saveCover: vi.fn(async (bookId: string) => `covers/${bookId}.jpg`), + existingPaths, + }; +}); + +vi.mock("@/lib/book/cover-storage", () => ({ + getCoverFileExtension: async () => "jpg", + saveCoverToAppData: mocks.saveCover, +})); +vi.mock("@/lib/db/database", () => mocks.db); +vi.mock("@/lib/rag/vectorize-trigger", () => ({ triggerVectorizeBook: vi.fn() })); +vi.mock("@/lib/storage/desktop-library-root", () => ({ + getDesktopLibraryRoot: async () => "/library", + isDesktopManagedRelativePath: () => true, + resolveDesktopDataPath: async (path: string) => `/library/${path}`, +})); +vi.mock("@/lib/reader/document-loader", () => ({ + DocumentLoader: class { + async open() { + return { + book: { + metadata: { title: "Embedded title", author: "Embedded author" }, + getCover: async () => new Blob([new Uint8Array([9])]), + }, + }; + } + }, +})); +vi.mock("@readany/core/stores/persist", () => ({ + debouncedSave: vi.fn(), + loadFromFS: vi.fn(), +})); +vi.mock("@readany/core/stores/vector-model-store", () => ({ + useVectorModelStore: { + getState: () => ({ + autoVectorizeOnImport: false, + vectorModelEnabled: false, + hasVectorCapability: () => false, + }), + }, +})); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mocks.invoke, + convertFileSrc: (path: string) => path, +})); +vi.mock("@tauri-apps/api/path", () => ({ + join: async (...parts: string[]) => parts.join("/"), +})); +vi.mock("@tauri-apps/plugin-fs", () => ({ + copyFile: mocks.copyFile, + exists: mocks.exists, + mkdir: vi.fn(async () => undefined), + readFile: vi.fn(async () => new Uint8Array([1, 2, 3])), + remove: mocks.remove, + writeFile: vi.fn(async () => undefined), +})); + +import { useLibraryStore } from "./library-store"; + +function book(overrides: Partial = {}): Book { + return { + id: "existing-id", + filePath: "books/existing-id.mobi", + format: "mobi", + meta: { title: "Saved title", author: "Saved author" }, + progress: 0.5, + isVectorized: false, + vectorizeProgress: 0, + tags: ["user-tag"], + fileHash: "same-hash", + syncStatus: "local", + addedAt: 1, + updatedAt: 1, + ...overrides, + }; +} + +describe("desktop transactional OPDS imports", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.invoke.mockResolvedValue("same-hash"); + mocks.db.getDeletedBookByFileHash.mockResolvedValue(null); + mocks.db.getDeletedBookByTitle.mockResolvedValue(null); + mocks.db.insertBook.mockResolvedValue(undefined); + mocks.db.updateBook.mockResolvedValue(undefined); + mocks.existingPaths.clear(); + useLibraryStore.setState({ books: [], isImporting: false }); + }); + + it("skips an existing book with the same desktop file hash", async () => { + useLibraryStore.setState({ books: [book()] }); + + const result = await useLibraryStore + .getState() + .importBooks([{ path: "C:\\cache\\uuid.mobi", name: "Catalog.mobi" }], { + transactional: true, + }); + + expect(result.skippedDuplicates).toHaveLength(1); + expect(mocks.copyFile).not.toHaveBeenCalled(); + }); + + it("rolls back the managed book and cover when durable insertion fails", async () => { + mocks.invoke.mockResolvedValueOnce("new-hash"); + mocks.db.insertBook.mockRejectedValueOnce(new Error("insert failed")); + + const result = await useLibraryStore + .getState() + .importBooks([{ path: "C:\\cache\\uuid.mobi", name: "Catalog.mobi" }], { + transactional: true, + }); + + expect(result.failures).toHaveLength(1); + expect(result.imported).toHaveLength(0); + expect(useLibraryStore.getState().books).toHaveLength(0); + expect(mocks.remove).toHaveBeenCalledTimes(2); + expect(mocks.remove.mock.calls.map(([path]) => path)).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^\/library\/books\/.*\.mobi$/), + expect.stringMatching(/^\/library\/covers\/.*\.jpg$/), + ]), + ); + }); + + it("preserves existing managed files and state when a strict restore update fails", async () => { + const priorBooks = [book({ id: "visible-id", fileHash: "visible-hash" })]; + useLibraryStore.setState({ books: priorBooks }); + mocks.existingPaths.add("/library/books/existing-id.mobi"); + mocks.existingPaths.add("/library/covers/existing-id.jpg"); + mocks.db.getDeletedBookByFileHash.mockResolvedValueOnce(book({ deletedAt: 10 })); + mocks.db.updateBook.mockRejectedValueOnce(new Error("update failed")); + + const result = await useLibraryStore + .getState() + .importBooks([{ path: "C:\\cache\\uuid.mobi", name: "Catalog.mobi" }], { + transactional: true, + }); + + expect(result.failures).toHaveLength(1); + expect(useLibraryStore.getState().books).toEqual(priorBooks); + expect(mocks.copyFile.mock.calls[0]?.[1]).toMatch(/^\/library\/books\/existing-id-.+\.mobi$/); + expect(mocks.saveCover.mock.calls[0]?.[0]).toMatch(/^existing-id-.+/); + expect(mocks.remove.mock.calls.map(([path]) => path)).toEqual([ + mocks.copyFile.mock.calls[0]?.[1], + expect.stringMatching(/^\/library\/covers\/existing-id-.+\.jpg$/), + ]); + expect(mocks.remove).not.toHaveBeenCalledWith("/library/books/existing-id.mobi"); + expect(mocks.remove).not.toHaveBeenCalledWith("/library/covers/existing-id.jpg"); + }); + + it("uses the suggested display name instead of the UUID temp basename", async () => { + mocks.invoke.mockRejectedValueOnce(new Error("hash unavailable")); + + await useLibraryStore + .getState() + .importBooks([{ path: "C:\\cache\\random-uuid.mobi", name: "Catalog Title.mobi" }], { + transactional: true, + }); + + expect(mocks.db.getDeletedBookByTitle).toHaveBeenCalledWith("Catalog Title"); + }); + + it("restores to new managed paths while retaining pre-existing files", async () => { + mocks.existingPaths.add("/library/books/existing-id.mobi"); + mocks.existingPaths.add("/library/covers/existing-id.jpg"); + mocks.db.getDeletedBookByFileHash.mockResolvedValueOnce(book({ deletedAt: 10 })); + + const result = await useLibraryStore + .getState() + .importBooks([{ path: "C:\\cache\\uuid.mobi", name: "Catalog.mobi" }], { + transactional: true, + }); + + expect(result.imported).toHaveLength(1); + expect(mocks.db.updateBook).toHaveBeenCalledTimes(1); + expect(useLibraryStore.getState().books[0]).toMatchObject({ + id: "existing-id", + filePath: expect.stringMatching(/^books\/existing-id-.+\.mobi$/), + tags: ["user-tag"], + meta: { + title: "Saved title", + author: "Saved author", + coverUrl: expect.stringMatching(/^covers\/existing-id-.+\.jpg$/), + }, + }); + expect(mocks.remove).not.toHaveBeenCalled(); + }); + + it("does not title-match a deleted book after a conclusive hash miss", async () => { + const sameTitleDeleted = book({ deletedAt: 10, fileHash: "old-hash" }); + mocks.invoke.mockResolvedValueOnce("different-valid-hash"); + mocks.db.getDeletedBookByTitle.mockResolvedValueOnce(sameTitleDeleted); + + const result = await useLibraryStore + .getState() + .importBooks([{ path: "C:\\cache\\uuid.mobi", name: "Saved title.mobi" }], { + transactional: true, + }); + + expect(mocks.db.getDeletedBookByFileHash).toHaveBeenCalledWith("different-valid-hash"); + expect(mocks.db.getDeletedBookByTitle).not.toHaveBeenCalled(); + expect(mocks.db.insertBook).toHaveBeenCalledTimes(1); + expect(mocks.db.updateBook).not.toHaveBeenCalled(); + expect(result.imported[0]).not.toMatchObject({ id: "existing-id" }); + }); +}); diff --git a/packages/app/src/stores/library-store.ts b/packages/app/src/stores/library-store.ts index 73fb2b85f..29a658262 100644 --- a/packages/app/src/stores/library-store.ts +++ b/packages/app/src/stores/library-store.ts @@ -1,3 +1,14 @@ +import { getCoverFileExtension, saveCoverToAppData } from "@/lib/book/cover-storage"; +import { + type DesktopImportFile, + type FoliateDocumentMetadata, + buildDesktopImportedBookMeta, + buildImportedBookMeta, + fromDocumentMetadata, + fromPdfMetadata, + normalizeDesktopImportFile, + shouldPersistEmbeddedCover, +} from "@/lib/book/imported-book-meta"; import * as db from "@/lib/db/database"; import { triggerVectorizeBook } from "@/lib/rag/vectorize-trigger"; import { @@ -14,13 +25,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, normalizeIsbn } 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 +38,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 +74,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 +87,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 +176,57 @@ 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 text = element.textContent?.trim() || ""; + const isbn = normalizeIsbn(text); + if (isbn) return isbn; + } + return ""; +} + +async function extractPdfMetadata(source: string): Promise { + const pdfjsLib = await import("pdfjs-dist"); + pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`; + const pdfDoc = await pdfjsLib.getDocument({ + url: source, + useWorkerFetch: false, + isEvalSupported: false, + }).promise; + + try { + const { info, metadata } = await pdfDoc.getMetadata(); + return fromPdfMetadata(info as Record, metadata); + } finally { + await pdfDoc.destroy(); + } +} + +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. @@ -211,6 +278,45 @@ async function resolveAppPath(relativePath: string): Promise { return resolveDesktopDataPath(relativePath); } +async function getDesktopManagedDestination( + directory: "books" | "covers", + bookId: string, + extension: string, + avoidExisting: boolean, +): Promise<{ relativePath: string; destPath: string; storageId: string; created: boolean }> { + const { exists } = await import("@tauri-apps/plugin-fs"); + let storageId = bookId; + let relativePath = `${directory}/${storageId}.${extension}`; + let destPath = await resolveAppPath(relativePath); + let pathExists = await exists(destPath); + + while (avoidExisting && pathExists) { + storageId = `${bookId}-${crypto.randomUUID()}`; + relativePath = `${directory}/${storageId}.${extension}`; + destPath = await resolveAppPath(relativePath); + pathExists = await exists(destPath); + } + + return { relativePath, destPath, storageId, created: !pathExists }; +} + +async function saveImportedDesktopCover(input: { + bookId: string; + cover: Blob; + avoidExisting: boolean; + createdManagedPaths: Set; +}): Promise { + const extension = await getCoverFileExtension(input.cover); + const destination = await getDesktopManagedDestination( + "covers", + input.bookId, + extension, + input.avoidExisting, + ); + if (destination.created) input.createdManagedPaths.add(destination.destPath); + return saveCoverToAppData(destination.storageId, input.cover); +} + /** * Resolve a book or cover path to a displayable asset:// URL. * Handles both legacy absolute/asset:// paths and new relative paths. @@ -231,7 +337,8 @@ async function copyBookToAppData( bookId: string, ext: string, srcPath: string, -): Promise<{ relativePath: string; destPath: string }> { + avoidExisting = false, +): Promise<{ relativePath: string; destPath: string; created: boolean }> { const { copyFile, mkdir } = await import("@tauri-apps/plugin-fs"); const { join } = await import("@tauri-apps/api/path"); @@ -243,34 +350,10 @@ async function copyBookToAppData( /* exists */ } - const relativePath = `books/${bookId}.${ext}`; - const destPath = await join(libraryRoot, relativePath); + const destination = await getDesktopManagedDestination("books", bookId, ext, avoidExisting); + const { relativePath, destPath, created } = destination; await copyFile(srcPath, destPath); - return { relativePath, destPath }; -} - -/** Save cover image to desktop library root and return a relative path (covers/{id}.{ext}) */ -async function saveCoverToAppData(bookId: string, coverBlob: Blob): Promise { - const { writeFile, mkdir } = await import("@tauri-apps/plugin-fs"); - const { join } = await import("@tauri-apps/api/path"); - - const libraryRoot = await getDesktopLibraryRoot(); - const coversDir = await join(libraryRoot, "covers"); - - // Ensure covers directory exists - try { - await mkdir(coversDir, { recursive: true }); - } catch { - // Directory may already exist - } - - const ext = coverBlob.type.includes("png") ? "png" : "jpg"; - const relativePath = `covers/${bookId}.${ext}`; - const coverPath = await join(libraryRoot, relativePath); - const arrayBuffer = await coverBlob.arrayBuffer(); - await writeFile(coverPath, new Uint8Array(arrayBuffer)); - - return relativePath; + return { relativePath, destPath, created }; } export async function repairMissingCovers(): Promise { @@ -330,6 +413,10 @@ function keepActiveGroupId(activeGroupId: string, groups: BookGroup[]): string { return groups.some((group) => group.id === activeGroupId) ? activeGroupId : ""; } +export interface ImportBooksOptions { + transactional?: boolean; +} + export interface LibraryState { books: Book[]; groups: BookGroup[]; @@ -358,7 +445,10 @@ export interface LibraryState { setViewMode: (mode: LibraryViewMode) => void; setSortField: (field: SortField) => void; setSortOrder: (order: SortOrder) => void; - importBooks: (filePaths: string[]) => Promise; + importBooks: ( + files: DesktopImportFile[], + options?: ImportBooksOptions, + ) => Promise; inspectDeletedBookCandidate: ( bookId: string, filePath: string, @@ -405,9 +495,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 +522,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 +548,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 +564,25 @@ 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; - if (epubMeta.coverBlob) { - coverUrl = await saveCoverToAppData(bookId, epubMeta.coverBlob); + embeddedMeta = epubMeta; + if (!originalBook.meta.coverUrl?.trim() && 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); + try { + embeddedMeta = await extractPdfMetadata(pdfUrl); + } catch (err) { + console.warn("[restoreDeletedDesktopBook] PDF metadata extraction failed:", err); + } + try { + const coverBlob = await generatePdfCover(pdfUrl); + if (!originalBook.meta.coverUrl?.trim() && coverBlob) { + embeddedMeta.coverUrl = await saveCoverToAppData(bookId, coverBlob); + } + } catch (err) { + console.warn("[restoreDeletedDesktopBook] PDF cover generation failed:", err); } } else { const { readFile } = await import("@tauri-apps/plugin-fs"); @@ -500,22 +596,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 FoliateDocumentMetadata); try { const coverBlob = await bookDoc.getCover(); - if (coverBlob) { - coverUrl = await saveCoverToAppData(bookId, coverBlob); + if (!originalBook.meta.coverUrl?.trim() && coverBlob) { + embeddedMeta.coverUrl = await saveCoverToAppData(bookId, coverBlob); } } catch (err) { console.warn("[restoreDeletedDesktopBook] getCover failed:", err); @@ -523,29 +608,17 @@ async function restoreDeletedDesktopBook(bookId: string, filePath: string): Prom } } catch (err) { console.warn("[restoreDeletedDesktopBook] Metadata extraction failed, falling back:", err); - if (format === "pdf") { - try { - const { convertFileSrc } = await import("@tauri-apps/api/core"); - const coverBlob = await generatePdfCover(convertFileSrc(destPath)); - if (coverBlob) { - coverUrl = await saveCoverToAppData(bookId, coverBlob); - } - } catch (err) { - console.warn("[Library] PDF cover generation failed:", err); - } - } } return { ...originalBook, filePath: relativePath, format, - meta: { - ...originalBook.meta, - title, - author, - coverUrl, - }, + meta: buildImportedBookMeta({ + existing: originalBook.meta, + embedded: embeddedMeta, + fallbackTitle, + }), deletedAt: undefined, fileHash, syncStatus: "local", @@ -862,7 +935,7 @@ export const useLibraryStore = create((set, get) => ({ setSortOrder: (order) => set((state) => ({ filter: { ...state.filter, sortOrder: order } })), - importBooks: async (filePaths) => { + importBooks: async (files, options = {}) => { set({ isImporting: true }); const result = createEmptyImportBooksResult(); const duplicateIndex = createImportDuplicateIndex(get().books); @@ -870,10 +943,13 @@ export const useLibraryStore = create((set, get) => ({ await db.initDatabase(); const { DocumentLoader } = await import("@/lib/reader/document-loader"); - for (const filePath of filePaths) { + for (const fileInput of files) { + const fileInfo = normalizeDesktopImportFile(fileInput); + const filePath = fileInfo.path; const fileName = decodeURIComponent( - filePath.replace(/\\/g, "/").split("/").pop() || "book", + fileInfo.name || filePath.replace(/\\/g, "/").split("/").pop() || "book", ); + const createdManagedPaths = new Set(); try { const ext = filePath.split(".").pop()?.toLowerCase() || "epub"; const formatMap: Record = { @@ -890,9 +966,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 { @@ -911,14 +986,68 @@ export const useLibraryStore = create((set, get) => ({ continue; } - let deletedMatch = fileHash - ? await db.getDeletedBookByFileHash(fileHash).catch((err) => { console.warn("[Library] Failed to check deleted book by hash:", err); return null; }) - : null; + let deletedMatch: Book | null = null; + let hashLookupConclusive = false; + if (fileHash) { + try { + deletedMatch = await db.getDeletedBookByFileHash(fileHash); + hashLookupConclusive = true; + } catch (err) { + console.warn("[Library] Failed to check deleted book by hash:", err); + } + } // 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 (!hashLookupConclusive && 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(); + const persistEmbeddedCover = shouldPersistEmbeddedCover( + deletedMatch?.meta, + fileInfo.metadata, + ); + let convertedDestination: + | { relativePath: string; destPath: string; created: boolean } + | undefined; + const persistImport = async (book: Book): Promise => { + const restoreUpdates = { + filePath: book.filePath, + format: book.format, + meta: book.meta, + deletedAt: undefined, + progress: book.progress, + currentCfi: book.currentCfi, + isVectorized: false, + vectorizeProgress: 0, + tags: book.tags, + fileHash: book.fileHash, + syncStatus: "local" as const, + lastOpenedAt: Date.now(), + }; + + if (options.transactional) { + if (deletedMatch) { + await db.updateBook(book.id, restoreUpdates); + } else { + await db.insertBook(book); + } + set((state) => ({ books: [...state.books, book] })); + debouncedSave("library-books", get().books); + return; + } + + if (deletedMatch) { + set((state) => ({ books: [...state.books, book] })); + db.updateBook(book.id, restoreUpdates).catch((err) => + console.error("Failed to restore deleted book from database:", err), + ); + debouncedSave("library-books", get().books); + } else { + get().addBook(book); + } + }; // For TXT files, convert to EPUB first before storing if (ext === "txt") { @@ -934,15 +1063,21 @@ 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"); const epubBytes = new Uint8Array(await result.file.arrayBuffer()); await mkdir(await join(await getDesktopLibraryRoot(), "books"), { recursive: true }); - const tmpPath = await resolveAppPath(`books/${bookId}.epub`); - await writeFile(tmpPath, epubBytes); + const destination = await getDesktopManagedDestination( + "books", + bookId, + "epub", + options.transactional === true, + ); + convertedDestination = destination; + if (destination.created) createdManagedPaths.add(destination.destPath); + await writeFile(destination.destPath, epubBytes); } // For UMD files, parse and convert to EPUB before storing @@ -960,13 +1095,19 @@ 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 }); - const destEpub = await resolveAppPath(`books/${bookId}.epub`); - await writeFile(destEpub, result.epubBytes); + const destination = await getDesktopManagedDestination( + "books", + bookId, + "epub", + options.transactional === true, + ); + convertedDestination = destination; + if (destination.created) createdManagedPaths.add(destination.destPath); + await writeFile(destination.destPath, result.epubBytes); } // Copy book file into the managed library root (books/{id}.{ext}) @@ -974,12 +1115,20 @@ export const useLibraryStore = create((set, get) => ({ let relativePath: string; let destPath: string; if (ext === "txt" || ext === "umd") { - relativePath = `books/${bookId}.epub`; - destPath = await resolveAppPath(relativePath); + const destination = convertedDestination; + if (!destination) throw new Error("Converted book destination missing"); + relativePath = destination.relativePath; + destPath = destination.destPath; } else { - const copyResult = await copyBookToAppData(bookId, ext, filePath); + const copyResult = await copyBookToAppData( + bookId, + ext, + filePath, + options.transactional === true, + ); relativePath = copyResult.relativePath; destPath = copyResult.destPath; + if (copyResult.created) createdManagedPaths.add(copyResult.destPath); } // Extract metadata WITHOUT loading the full file into JS memory. @@ -993,35 +1142,37 @@ 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; - if (epubMeta.coverBlob) { - coverUrl = await saveCoverToAppData(bookId, epubMeta.coverBlob); + embeddedMeta = epubMeta; + if (persistEmbeddedCover && epubMeta.coverBlob) { + embeddedMeta.coverUrl = await saveImportedDesktopCover({ + bookId, + cover: epubMeta.coverBlob, + avoidExisting: options.transactional === true, + createdManagedPaths, + }); } } 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"); - pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`; - const pdfDoc = await pdfjsLib.getDocument({ - url: pdfUrl, - useWorkerFetch: false, - isEvalSupported: false, - }).promise; - const metadata = await pdfDoc.getMetadata(); - const pdfTitle = (metadata?.info as Record)?.Title as string; - if (pdfTitle?.trim()) title = pdfTitle.trim(); - pdfDoc.destroy(); + embeddedMeta = await extractPdfMetadata(pdfUrl); } catch (err) { console.warn("[Library] PDF metadata extraction failed:", err); } + try { + const coverBlob = await generatePdfCover(pdfUrl); + if (persistEmbeddedCover && coverBlob) { + embeddedMeta.coverUrl = await saveImportedDesktopCover({ + bookId, + cover: coverBlob, + avoidExisting: options.transactional === true, + createdManagedPaths, + }); + } + } 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 +1184,19 @@ 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 FoliateDocumentMetadata, + ); try { const coverBlob = await bookDoc.getCover(); - if (coverBlob) { - coverUrl = await saveCoverToAppData(bookId, coverBlob); + if (persistEmbeddedCover && coverBlob) { + embeddedMeta.coverUrl = await saveImportedDesktopCover({ + bookId, + cover: coverBlob, + avoidExisting: options.transactional === true, + createdManagedPaths, + }); } } catch (err) { console.warn("[importBooks] getCover failed:", err); @@ -1066,12 +1210,12 @@ export const useLibraryStore = create((set, get) => ({ id: bookId, filePath: relativePath, format, - meta: { - ...(deletedMatch?.meta ?? {}), - title, - author, - coverUrl: coverUrl || deletedMatch?.meta.coverUrl, - }, + meta: buildDesktopImportedBookMeta({ + file: fileInfo, + existing: deletedMatch?.meta, + embedded: embeddedMeta, + fallbackTitle, + }), groupId: deletedMatch?.groupId, progress: deletedMatch?.progress ?? 0, currentCfi: deletedMatch?.currentCfi, @@ -1085,26 +1229,7 @@ export const useLibraryStore = create((set, get) => ({ lastOpenedAt: deletedMatch?.lastOpenedAt ?? Date.now(), }; - if (deletedMatch) { - set((state) => ({ books: [...state.books, book] })); - db.updateBook(book.id, { - filePath: book.filePath, - format: book.format, - meta: book.meta, - deletedAt: undefined, - progress: book.progress, - currentCfi: book.currentCfi, - isVectorized: false, - vectorizeProgress: 0, - tags: book.tags, - fileHash: book.fileHash, - syncStatus: "local", - lastOpenedAt: Date.now(), - }).catch((err) => console.error("Failed to restore deleted book from database:", err)); - debouncedSave("library-books", get().books); - } else { - get().addBook(book); - } + await persistImport(book); result.imported.push(book); if (fileHash) { duplicateIndex.byHash.set(fileHash, book); @@ -1119,15 +1244,20 @@ 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) { + if (options.transactional) { + const { remove } = await import("@tauri-apps/plugin-fs"); + for (const path of createdManagedPaths) { + await remove(path).catch(() => undefined); + } + } console.error(`Failed to import ${filePath}:`, err); result.failures.push({ name: fileName, @@ -1301,7 +1431,9 @@ export const useLibraryStore = create((set, get) => ({ // Persist book tag changes to DB const books = get().books; for (const b of books) { - db.updateBook(b.id, { tags: b.tags }).catch((err) => console.warn("[Library] Failed to update book tags:", err)); + db.updateBook(b.id, { tags: b.tags }).catch((err) => + console.warn("[Library] Failed to update book tags:", err), + ); } }, @@ -1321,7 +1453,9 @@ export const useLibraryStore = create((set, get) => ({ }); for (const b of get().books) { if (b.tags.includes(trimmed)) { - db.updateBook(b.id, { tags: b.tags }).catch((err) => console.warn("[Library] Failed to update book tags:", err)); + db.updateBook(b.id, { tags: b.tags }).catch((err) => + console.warn("[Library] Failed to update book tags:", err), + ); } } }, @@ -1338,7 +1472,10 @@ export const useLibraryStore = create((set, get) => ({ return { books, allTags }; }); const book = get().books.find((b) => b.id === bookId); - if (book) db.updateBook(bookId, { tags: book.tags }).catch((err) => console.warn("[Library] Failed to update book tags:", err)); + if (book) + db.updateBook(bookId, { tags: book.tags }).catch((err) => + console.warn("[Library] Failed to update book tags:", err), + ); }, removeTagFromBook: (bookId, tag) => { @@ -1350,6 +1487,9 @@ export const useLibraryStore = create((set, get) => ({ return { books }; }); const book = get().books.find((b) => b.id === bookId); - if (book) db.updateBook(bookId, { tags: book.tags }).catch((err) => console.warn("[Library] Failed to update book tags:", err)); + if (book) + db.updateBook(bookId, { tags: book.tags }).catch((err) => + console.warn("[Library] Failed to update book tags:", err), + ); }, })); diff --git a/packages/core/package.json b/packages/core/package.json index 7c385a87c..89a0f3737 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -63,6 +63,7 @@ "@xmldom/xmldom": "^0.8.11", "@zip.js/zip.js": "^2.7.52", "clsx": "^2.1.1", + "foliate-js": "workspace:*", "i18next": "^25.8.13", "pdfjs-dist": "^5.5.207", "react-i18next": "^16.5.4", 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 a6229cf6a..72d53cb69 100644 --- a/packages/core/src/i18n/locales/en/library.json +++ b/packages/core/src/i18n/locales/en/library.json @@ -1,5 +1,111 @@ { "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", + "close": "Close", + "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", + "hidePassword": "Hide password", + "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", + "showMore": "Show more", + "showPassword": "Show password", + "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", + "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.", + "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..347f4bbf6 100644 --- a/packages/core/src/i18n/locales/es/library.json +++ b/packages/core/src/i18n/locales/es/library.json @@ -1,5 +1,111 @@ { "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", + "close": "Cerrar", + "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", + "hidePassword": "Ocultar contraseña", + "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", + "showMore": "Mostrar más", + "showPassword": "Mostrar contraseña", + "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", + "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.", + "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..019ae10ae 100644 --- a/packages/core/src/i18n/locales/fr/library.json +++ b/packages/core/src/i18n/locales/fr/library.json @@ -1,5 +1,111 @@ { "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", + "close": "Fermer", + "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", + "hidePassword": "Masquer le mot de passe", + "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", + "showMore": "Afficher plus", + "showPassword": "Afficher le mot de passe", + "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é", + "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.", + "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..d5e6ad6ed 100644 --- a/packages/core/src/i18n/locales/ja/library.json +++ b/packages/core/src/i18n/locales/ja/library.json @@ -1,5 +1,111 @@ { "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": "形式を選択", + "close": "閉じる", + "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": "非表示のプリセット", + "hidePassword": "パスワードを非表示", + "hideCatalog": "{{name}} を非表示", + "imported": "ライブラリに取り込みました", + "importing": "ライブラリに追加中…", + "loadFailed": "カタログを利用できません", + "loading": "カタログを読み込み中…", + "loadingCatalogs": "カタログを開いています…", + "loadingHint": "この書棚と利用可能な形式を確認しています。", + "noCompatibleFormat": "対応するダウンロード形式がありません", + "next": "次へ", + "previous": "前へ", + "publicationDetails": "{{title}} の詳細", + "readerEyebrow": "オープンな書棚", + "readerIntro": "カタログのコレクションをたどり、形式を選んで、本をライブラリへ直接追加できます。", + "refresh": "更新", + "restore": "復元", + "retry": "再試行", + "save": "保存", + "search": "検索", + "showMore": "さらに表示", + "showPassword": "パスワードを表示", + "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": "保存済みのパスワードを残す場合は空欄にします", + "passwordRequiredForIdentityChange": "この変更にはパスワードを再入力してください", + "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..9ff862ef8 100644 --- a/packages/core/src/i18n/locales/ko/library.json +++ b/packages/core/src/i18n/locales/ko/library.json @@ -1,5 +1,111 @@ { "library": { + "opds": { + "alreadyImported": "이 책은 이미 라이브러리에 있습니다", + "authAnonymous": "로그인 필요 없음", + "authMissing": "기본 인증 · 비밀번호 필요", + "authSecure": "기본 인증 · 보안 저장소", + "authSession": "기본 인증 · 이번 세션만", + "available": "사용 가능한 카탈로그", + "back": "뒤로", + "books": "책", + "browseCatalog": "{{name}} 둘러보기", + "builtIn": "기본 제공", + "builtInLocked": "프리셋 URL은 변경할 수 없습니다", + "catalog": "카탈로그", + "catalogActionFailed": "카탈로그를 업데이트하지 못했습니다.", + "catalogsLoadFailed": "카탈로그를 불러오지 못했습니다.", + "catalogsSubtitle": "공개 서가와 나만의 OPDS 서버를 둘러보세요.", + "catalogsTitle": "온라인 카탈로그", + "cancel": "취소", + "chooseFormat": "형식 선택", + "close": "닫기", + "collections": "컬렉션", + "continue": "계속", + "delete": "삭제", + "deleteCatalog": "{{name}} 삭제", + "deleteDescription": "카탈로그와 안전하게 저장된 비밀번호가 삭제됩니다.", + "deleteTitle": "이 카탈로그를 삭제할까요?", + "disabled": "사용 안 함", + "done": "완료", + "downloadAndImport": "다운로드 후 가져오기", + "downloadFormat": "{{format}} 다운로드", + "downloading": "다운로드 중…", + "downloadingProgress": "다운로드 중 {{percent}}%", + "downloadTitle": "{{title}} 다운로드", + "editCatalog": "{{name}} 편집", + "editCredentials": "로그인 정보 편집", + "empty": "이 서가에는 아직 항목이 없습니다", + "emptyHint": "다른 컬렉션을 열거나 이전 단계로 돌아가세요.", + "enabled": "사용", + "hiddenPresets": "숨긴 프리셋", + "hidePassword": "비밀번호 숨기기", + "hideCatalog": "{{name}} 숨기기", + "imported": "라이브러리에 가져왔습니다", + "importing": "라이브러리에 추가 중…", + "loadFailed": "카탈로그를 사용할 수 없음", + "loading": "카탈로그 불러오는 중…", + "loadingCatalogs": "카탈로그 여는 중…", + "loadingHint": "이 서가와 사용 가능한 형식을 확인하고 있습니다.", + "noCompatibleFormat": "호환되는 다운로드 형식 없음", + "next": "다음", + "previous": "이전", + "publicationDetails": "{{title}} 상세 정보", + "readerEyebrow": "열린 서가", + "readerIntro": "카탈로그의 컬렉션을 둘러보고 형식을 선택해 책을 라이브러리로 바로 가져오세요.", + "refresh": "새로 고침", + "restore": "복원", + "retry": "다시 시도", + "save": "저장", + "search": "검색", + "showMore": "더 보기", + "showPassword": "비밀번호 표시", + "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": "저장된 비밀번호를 유지하려면 비워 두세요", + "passwordRequiredForIdentityChange": "이 변경을 위해 비밀번호를 다시 입력하세요", + "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..2c318f2b6 100644 --- a/packages/core/src/i18n/locales/zh-TW/library.json +++ b/packages/core/src/i18n/locales/zh-TW/library.json @@ -1,5 +1,111 @@ { "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": "選擇格式", + "close": "關閉", + "collections": "分類", + "continue": "繼續", + "delete": "刪除", + "deleteCatalog": "刪除 {{name}}", + "deleteDescription": "將刪除此目錄及其安全儲存的密碼。", + "deleteTitle": "刪除此目錄?", + "disabled": "已停用", + "done": "完成", + "downloadAndImport": "下載並匯入", + "downloadFormat": "下載 {{format}}", + "downloading": "正在下載…", + "downloadingProgress": "正在下載 {{percent}}%", + "downloadTitle": "下載 {{title}}", + "editCatalog": "編輯 {{name}}", + "editCredentials": "編輯登入資訊", + "empty": "這個書架還沒有內容", + "emptyHint": "試試其他分類,或返回上一層。", + "enabled": "已啟用", + "hiddenPresets": "已隱藏的預設項目", + "hidePassword": "隱藏密碼", + "hideCatalog": "隱藏 {{name}}", + "imported": "已匯入書庫", + "importing": "正在加入書庫…", + "loadFailed": "目錄無法使用", + "loading": "正在載入目錄…", + "loadingCatalogs": "正在開啟目錄…", + "loadingHint": "正在讀取此書架並檢查可用格式。", + "noCompatibleFormat": "沒有相容的下載格式", + "next": "下一頁", + "previous": "上一頁", + "publicationDetails": "{{title}} 的詳細資料", + "readerEyebrow": "開放書架", + "readerIntro": "進入目錄中的分類,選擇格式,然後把書籍直接匯入書庫。", + "refresh": "重新整理", + "restore": "還原", + "retry": "重試", + "save": "儲存", + "search": "搜尋", + "showMore": "顯示更多", + "showPassword": "顯示密碼", + "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": "留空即可保留已儲存的密碼", + "passwordRequiredForIdentityChange": "請為這些變更重新輸入密碼", + "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..fe24dfe09 100644 --- a/packages/core/src/i18n/locales/zh/library.json +++ b/packages/core/src/i18n/locales/zh/library.json @@ -1,5 +1,111 @@ { "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": "选择格式", + "close": "关闭", + "collections": "分类", + "continue": "继续", + "delete": "删除", + "deleteCatalog": "删除 {{name}}", + "deleteDescription": "将删除此目录及其安全存储的密码。", + "deleteTitle": "删除此目录?", + "disabled": "已停用", + "done": "完成", + "downloadAndImport": "下载并导入", + "downloadFormat": "下载 {{format}}", + "downloading": "正在下载…", + "downloadingProgress": "正在下载 {{percent}}%", + "downloadTitle": "下载 {{title}}", + "editCatalog": "编辑 {{name}}", + "editCredentials": "编辑登录信息", + "empty": "这个书架还没有内容", + "emptyHint": "试试其他分类,或返回上一级。", + "enabled": "已启用", + "hiddenPresets": "已隐藏的预设", + "hidePassword": "隐藏密码", + "hideCatalog": "隐藏 {{name}}", + "imported": "已导入书库", + "importing": "正在添加到书库…", + "loadFailed": "目录不可用", + "loading": "正在加载目录…", + "loadingCatalogs": "正在打开目录…", + "loadingHint": "正在读取此书架并检查可用格式。", + "noCompatibleFormat": "没有兼容的下载格式", + "next": "下一页", + "previous": "上一页", + "publicationDetails": "{{title}} 的详情", + "readerEyebrow": "开放书架", + "readerIntro": "进入目录中的分类,选择格式,然后把图书直接导入书库。", + "refresh": "刷新", + "restore": "恢复", + "retry": "重试", + "save": "保存", + "search": "搜索", + "showMore": "显示更多", + "showPassword": "显示密码", + "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": "留空可保留已保存的密码", + "passwordRequiredForIdentityChange": "请为这些更改重新输入密码", + "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..9edaa3d0e --- /dev/null +++ b/packages/core/src/i18n/opds-locales.test.ts @@ -0,0 +1,149 @@ +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", + "close", + "collections", + "continue", + "delete", + "deleteCatalog", + "deleteDescription", + "deleteTitle", + "disabled", + "done", + "downloadAndImport", + "downloadFormat", + "downloading", + "downloadingProgress", + "downloadTitle", + "editCatalog", + "editCredentials", + "empty", + "emptyHint", + "enabled", + "hiddenPresets", + "hideCatalog", + "hidePassword", + "imported", + "importing", + "loadFailed", + "loading", + "loadingCatalogs", + "loadingHint", + "noCompatibleFormat", + "next", + "previous", + "publicationDetails", + "readerEyebrow", + "readerIntro", + "refresh", + "restore", + "restoreCatalog", + "searchPlaceholder", + "search", + "retry", + "save", + "showMore", + "showPassword", + "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.passwordRequiredForIdentityChange", + "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 e8af67daa..d9e9978b1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -59,6 +59,65 @@ export type { } from "./import/webdav-import-types"; export type { ImportBooksResult, ImportDuplicateIndex } from "./import/import-dedupe"; +// OPDS catalogs +export { + OPDS_BUILT_IN_CATALOGS, + OPDS_CATALOG_STORAGE_KEY, + OpdsCatalogStore, + canPreserveOpdsCatalogPassword, + opdsCatalogSecretKey, + type OpdsCatalog, + type OpdsCatalogAuth, + type OpdsCatalogInput, + type OpdsCatalogStorage, + type OpdsCatalogUpdate, + type OpdsPasswordStorage, +} from "./opds/opds-catalog-store"; +export { OpdsClient, OpdsError, type OpdsAssetResponse } from "./opds/opds-client"; +export { + OPDS_MAX_ACQUISITION_BYTES, + createExclusiveOpdsDownloadRunner, + downloadOpdsAcquisition, + listSupportedAcquisitions, + sanitizeOpdsFileName, + toBookMeta, + type DownloadOpdsAcquisitionInput, + type DownloadOpdsAcquisitionResult, + type OpdsDownloadProgress, + 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"; +export { createOpdsBackController } from "./opds/opds-back-controller"; +export { + createOpdsCoverCache, + readOpdsCover, + type OpdsCoverLease, + type OpdsCoverValue, +} from "./opds/opds-cover-cache"; +export { + opdsDescriptionToPlainText, + sanitizeOpdsDescription, +} from "./opds/opds-sanitize"; +export type { + OpdsAcquisition, + OpdsCredentials, + OpdsErrorCode, + OpdsFacet, + OpdsFeed, + OpdsLink, + OpdsNavigationItem, + OpdsPublication, + OpdsSearchDescriptor, +} from "./opds/opds-types"; + // EPUB services export { inspectEpubBytes } from "./epub/inspect"; export type { @@ -76,4 +135,8 @@ export type { export { readEpubChapterFromBookFile, readEpubChapterFromDraft } from "./epub/chapter"; export type { EpubChapterReadResult } from "./epub/chapter"; export { searchKnowledge } from "./knowledge/search"; -export type { KnowledgeSearchHit, KnowledgeSearchResult, KnowledgeSearchSource } from "./knowledge/search"; +export type { + KnowledgeSearchHit, + KnowledgeSearchResult, + KnowledgeSearchSource, +} from "./knowledge/search"; diff --git a/packages/core/src/opds/foliate-opds.d.ts b/packages/core/src/opds/foliate-opds.d.ts new file mode 100644 index 000000000..1fa2fc53e --- /dev/null +++ b/packages/core/src/opds/foliate-opds.d.ts @@ -0,0 +1,14 @@ +declare module "foliate-js/opds.js" { + export const SYMBOL: { + SUMMARY: symbol; + CONTENT: symbol; + }; + + export function getFeed(document: Document): unknown; + export function getOpenSearch(document: Document): unknown; + export function getSearch(link: { + href: string; + title?: string; + type?: string; + }): Promise; +} diff --git a/packages/core/src/opds/opds-acquisition.test.ts b/packages/core/src/opds/opds-acquisition.test.ts new file mode 100644 index 000000000..50f3dce8a --- /dev/null +++ b/packages/core/src/opds/opds-acquisition.test.ts @@ -0,0 +1,481 @@ +import { describe, expect, it, vi } from "vitest"; +import type { IPlatformService } from "../services/platform"; +import { + OPDS_MAX_ACQUISITION_BYTES, + createExclusiveOpdsDownloadRunner, + downloadOpdsAcquisition, + listSupportedAcquisitions, + toBookMeta, +} from "./opds-acquisition"; +import { OpdsClient, OpdsError } from "./opds-client"; +import type { OpdsAcquisition, OpdsCredentials, OpdsPublication } from "./opds-types"; + +function publication(acquisitions: OpdsAcquisition[]): OpdsPublication { + return { + id: "urn:test:book", + title: "../A / Strange \\ Book\u0000", + authors: ["First Author", "Second Author"], + publisher: "Press", + language: "en", + identifier: "9781234567897", + published: "2024-01-02", + description: "Description", + subjects: ["Fiction", "Adventure"], + images: [], + acquisitions, + readingOrder: [], + }; +} + +function acquisition(url: string, type?: string, format: OpdsAcquisition["format"] = null) { + return { rel: ["http://opds-spec.org/acquisition"], url, type, format }; +} + +function fakePlatform( + fetchImpl: IPlatformService["fetch"], + writeFile = vi.fn(async () => undefined), +) { + return { + fetch: fetchImpl, + writeFile, + }; +} + +function streamResponse(chunks: Uint8Array[], headers?: HeadersInit): Response { + return new Response( + new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }), + { status: 200, headers }, + ); +} + +describe("listSupportedAcquisitions", () => { + it("keeps every supported direct format as an explicit choice", () => { + const formats = [ + ["application/epub+zip", "epub"], + ["application/pdf", "pdf"], + ["application/x-mobipocket-ebook", "mobi"], + ["application/vnd.amazon.ebook", "azw"], + ["application/vnd.amazon.mobi8-ebook", "azw3"], + ["application/x-fictionbook+xml", "fb2"], + ["application/x-fictionbook+zip", "fbz"], + ["application/vnd.comicbook+zip", "cbz"], + ["text/plain", "txt"], + ["application/x-umd", "umd"], + ] as const; + const input = publication( + formats.map(([type], index) => acquisition(`https://catalog.test/book-${index}`, type)), + ); + + expect(listSupportedAcquisitions(input).map(({ format }) => format)).toEqual( + formats.map(([, format]) => format), + ); + }); + + it("falls back to supported extensions and excludes unknown or indirect links", () => { + const input = publication([ + acquisition("https://catalog.test/book.FBZ?download=1"), + acquisition("https://catalog.test/book.azw3"), + { ...acquisition("https://catalog.test/license.epub"), rel: ["license"] }, + acquisition("https://catalog.test/book.exe", "application/octet-stream"), + ]); + + expect(listSupportedAcquisitions(input).map(({ format }) => format)).toEqual(["fbz", "azw3"]); + }); + + it("recognizes reader MIME aliases and uses the extension to disambiguate Amazon ebooks", () => { + const input = publication([ + acquisition("https://catalog.test/book.fb2.zip", "application/x-zip-compressed-fb2"), + acquisition("https://catalog.test/book.azw3", "application/vnd.amazon.ebook"), + ]); + + expect(listSupportedAcquisitions(input).map(({ format }) => format)).toEqual(["fbz", "azw3"]); + }); + + it("uses the advertised MIME format when the URL extension disagrees", () => { + const [choice] = listSupportedAcquisitions( + publication([acquisition("https://catalog.test/wrong.pdf", "application/epub+zip")]), + ); + + expect(choice).toMatchObject({ format: "epub" }); + expect(choice.suggestedFileName).toMatch(/\.epub$/); + expect(choice.suggestedFileName).not.toContain(".."); + expect(choice.suggestedFileName).not.toMatch(/[\\/:*?"<>|]/); + expect(choice.suggestedFileName).not.toContain("\u0000"); + }); + + it("avoids Windows device names in suggested filenames", () => { + const input = publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + ]); + input.title = "CON"; + + expect(listSupportedAcquisitions(input)[0]?.suggestedFileName).toBe("_CON.epub"); + }); + + it("protects a reserved Windows device stem before a suffix", () => { + const input = publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + ]); + input.title = "CON.txt"; + + expect(listSupportedAcquisitions(input)[0]?.suggestedFileName).toBe("_CON.txt.epub"); + }); +}); + +describe("toBookMeta", () => { + it("maps catalog metadata with subjects kept separate from library tags", () => { + const meta = toBookMeta(publication([])); + + expect(meta).toEqual({ + title: "../A / Strange \\ Book\u0000", + author: "First Author, Second Author", + publisher: "Press", + language: "en", + isbn: "9781234567897", + publishDate: "2024-01-02", + description: "Description", + subjects: ["Fiction", "Adventure"], + }); + expect(meta).not.toHaveProperty("tags"); + }); + + it("does not put a non-ISBN OPDS identifier into the ISBN field", () => { + const input = publication([]); + input.identifier = "urn:uuid:not-an-isbn"; + + 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", () => { + it("requires an explicit choice when multiple formats are supported", async () => { + const input = publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + acquisition("https://catalog.test/book.pdf", "application/pdf"), + ]); + const platform = fakePlatform(vi.fn()); + + await expect( + downloadOpdsAcquisition({ + publication: input, + client: new OpdsClient(platform), + platform, + catalogOrigin: "https://catalog.test", + destinationPath: "/cache/book.epub", + }), + ).rejects.toMatchObject({ code: "unsupported-acquisition" }); + expect(platform.fetch).not.toHaveBeenCalled(); + }); + + it("rejects a selected acquisition that does not belong to the publication", async () => { + const input = publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + ]); + const platform = fakePlatform(vi.fn()); + + await expect( + downloadOpdsAcquisition({ + publication: input, + acquisition: acquisition("https://evil.test/book.epub", "application/epub+zip"), + client: new OpdsClient(platform), + platform, + catalogOrigin: "https://catalog.test", + destinationPath: "/cache/book.epub", + }), + ).rejects.toMatchObject({ code: "unsupported-acquisition" }); + }); + + it("routes same-origin credentials through OpdsClient and strips them after a cross-origin redirect", async () => { + const seen: Array<{ url: string; authorization: string | null }> = []; + const platform = fakePlatform( + vi.fn(async (url: string, options?: RequestInit) => { + const headers = new Headers(options?.headers); + seen.push({ url, authorization: headers.get("Authorization") }); + if (url === "https://catalog.test/book.epub") { + return new Response(null, { + status: 302, + headers: { Location: "https://cdn.test/book.epub" }, + }); + } + return streamResponse([new Uint8Array([1, 2, 3])], { "Content-Length": "3" }); + }) as IPlatformService["fetch"], + ); + const credentials: OpdsCredentials = { + username: "reader", + password: "secret-password", + catalogOrigin: "https://catalog.test", + }; + + await downloadOpdsAcquisition({ + publication: publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + ]), + client: new OpdsClient(platform), + platform, + catalogOrigin: "https://catalog.test", + credentials, + destinationPath: "/cache/book.epub", + }); + + expect(seen[0]?.authorization).toMatch(/^Basic /); + expect(seen[1]).toEqual({ url: "https://cdn.test/book.epub", authorization: null }); + expect(JSON.stringify(seen)).not.toContain("secret-password"); + }); + + it("reports monotonic bounded progress for streamed and unknown-length assets", async () => { + const platform = fakePlatform( + vi.fn(async () => + streamResponse([new Uint8Array([1, 2]), new Uint8Array([3]), new Uint8Array([4, 5, 6])]), + ) as IPlatformService["fetch"], + ); + const progress: Array<{ loaded: number; total: number }> = []; + + const result = await downloadOpdsAcquisition({ + publication: publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + ]), + client: new OpdsClient(platform), + platform, + catalogOrigin: "https://catalog.test", + destinationPath: "/cache/book.epub", + onProgress: (value) => progress.push(value), + }); + + expect(progress).toEqual([ + { loaded: 0, total: 0 }, + { loaded: 2, total: 0 }, + { loaded: 3, total: 0 }, + { loaded: 6, total: 0 }, + ]); + expect(result.bytesWritten).toBe(6); + expect(platform.writeFile).toHaveBeenCalledWith( + "/cache/book.epub", + new Uint8Array([1, 2, 3, 4, 5, 6]), + ); + }); + + it("clamps a dishonest content length instead of emitting out-of-range progress", async () => { + const platform = fakePlatform( + vi.fn(async () => + streamResponse([new Uint8Array([1, 2]), new Uint8Array([3, 4])], { + "Content-Length": "3", + }), + ) as IPlatformService["fetch"], + ); + const progress: Array<{ loaded: number; total: number }> = []; + + await downloadOpdsAcquisition({ + publication: publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + ]), + client: new OpdsClient(platform), + platform, + catalogOrigin: "https://catalog.test", + destinationPath: "/cache/book.epub", + onProgress: (value) => progress.push(value), + }); + + expect(progress).toEqual([ + { loaded: 0, total: 3 }, + { loaded: 2, total: 3 }, + { loaded: 3, total: 3 }, + ]); + }); + + it("cancels a download during streaming and never writes the partial bytes", async () => { + let releaseChunk!: () => void; + const chunkGate = new Promise((resolve) => { + releaseChunk = resolve; + }); + const response = new Response( + new ReadableStream({ + async start(controller) { + controller.enqueue(new Uint8Array([1])); + await chunkGate; + controller.enqueue(new Uint8Array([2])); + controller.close(); + }, + }), + { status: 200 }, + ); + const platform = fakePlatform(vi.fn(async () => response) as IPlatformService["fetch"]); + const controller = new AbortController(); + const progress: number[] = []; + const promise = downloadOpdsAcquisition({ + publication: publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + ]), + client: new OpdsClient(platform), + platform, + catalogOrigin: "https://catalog.test", + destinationPath: "/cache/book.epub", + signal: controller.signal, + onProgress: ({ loaded }) => { + progress.push(loaded); + if (loaded === 1) controller.abort(); + }, + }); + releaseChunk(); + + await expect(promise).rejects.toMatchObject({ code: "cancelled" }); + expect(platform.writeFile).not.toHaveBeenCalled(); + }); + + it("waits for an in-flight write, then reports cancellation", async () => { + let finishWrite!: () => void; + const writeGate = new Promise((resolve) => { + finishWrite = resolve; + }); + const writeFile = vi.fn(async () => writeGate); + const platform = fakePlatform( + vi.fn(async () => streamResponse([new Uint8Array([1])])) as IPlatformService["fetch"], + writeFile, + ); + const controller = new AbortController(); + const promise = downloadOpdsAcquisition({ + publication: publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + ]), + client: new OpdsClient(platform), + platform, + catalogOrigin: "https://catalog.test", + destinationPath: "/cache/book.epub", + signal: controller.signal, + }); + + await vi.waitFor(() => expect(writeFile).toHaveBeenCalledOnce()); + controller.abort(); + finishWrite(); + + await expect(promise).rejects.toMatchObject({ code: "cancelled" }); + }); + + it("maps transport and write errors to download-failed without leaking their messages", async () => { + const secret = "secret-password"; + const transport = fakePlatform( + vi.fn(async () => { + throw new Error(`network exposed ${secret}`); + }) as IPlatformService["fetch"], + ); + const write = fakePlatform( + vi.fn(async () => streamResponse([new Uint8Array([1])])) as IPlatformService["fetch"], + vi.fn(async () => { + throw new Error(`disk exposed ${secret}`); + }), + ); + const run = (platform: ReturnType) => + downloadOpdsAcquisition({ + publication: publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + ]), + client: new OpdsClient(platform), + platform, + catalogOrigin: "https://catalog.test", + destinationPath: "/cache/book.epub", + }); + + for (const platform of [transport, write]) { + const error = await run(platform).catch((value: unknown) => value); + expect(error).toBeInstanceOf(OpdsError); + expect(error).toMatchObject({ code: "download-failed" }); + expect(String(error)).not.toContain(secret); + } + }); + + it("rejects an oversized advertised asset before reading and cancels its transport", async () => { + const response = streamResponse([new Uint8Array([1])], { + "Content-Length": String(OPDS_MAX_ACQUISITION_BYTES + 1), + }) as Response & { cancel: ReturnType }; + response.cancel = vi.fn(async () => undefined); + const client = { fetchAsset: vi.fn(async () => response) }; + const platform = fakePlatform(vi.fn()); + + await expect( + downloadOpdsAcquisition({ + publication: publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + ]), + client, + platform, + catalogOrigin: "https://catalog.test", + destinationPath: "/cache/book.epub", + }), + ).rejects.toMatchObject({ code: "asset-too-large" }); + expect(response.cancel).toHaveBeenCalledOnce(); + expect(platform.writeFile).not.toHaveBeenCalled(); + }); + + it("bounds a missing or dishonest content length by cumulative bytes", async () => { + const response = streamResponse([ + new Uint8Array([1, 2, 3]), + new Uint8Array([4, 5, 6]), + ]) as Response & { cancel: ReturnType }; + response.cancel = vi.fn(async () => undefined); + const platform = fakePlatform(vi.fn()); + + await expect( + downloadOpdsAcquisition({ + publication: publication([ + acquisition("https://catalog.test/book.epub", "application/epub+zip"), + ]), + client: { fetchAsset: vi.fn(async () => response) }, + platform, + catalogOrigin: "https://catalog.test", + destinationPath: "/cache/book.epub", + maxBytes: 5, + }), + ).rejects.toMatchObject({ code: "asset-too-large" }); + expect(response.cancel).toHaveBeenCalledOnce(); + expect(platform.writeFile).not.toHaveBeenCalled(); + }); +}); + +describe("createExclusiveOpdsDownloadRunner", () => { + it("rejects overlap, keeps cancellation on the first operation, and permits a later retry", async () => { + let started = 0; + const execute = vi.fn( + (input: { value: number; signal: AbortSignal; onProgress: (value: number) => void }) => + new Promise((resolve, reject) => { + started += 1; + input.onProgress(input.value); + if (input.value === 2) { + resolve(input.value); + return; + } + input.signal.addEventListener("abort", () => reject(new OpdsError("cancelled")), { + once: true, + }); + }), + ); + const progress: number[] = []; + const runner = createExclusiveOpdsDownloadRunner(execute, { + onProgress: (value) => progress.push(value), + }); + + const first = runner.download({ value: 1 }); + await expect(runner.download({ value: 99 })).rejects.toMatchObject({ + code: "download-in-progress", + }); + expect(runner.isActive()).toBe(true); + runner.cancel(); + await expect(first).rejects.toMatchObject({ code: "cancelled" }); + await expect(runner.download({ value: 2 })).resolves.toBe(2); + + expect(started).toBe(2); + expect(progress).toEqual([1, 2]); + expect(runner.isActive()).toBe(false); + }); +}); diff --git a/packages/core/src/opds/opds-acquisition.ts b/packages/core/src/opds/opds-acquisition.ts new file mode 100644 index 000000000..ae9a566b6 --- /dev/null +++ b/packages/core/src/opds/opds-acquisition.ts @@ -0,0 +1,347 @@ +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 { opdsDescriptionToPlainText } from "./opds-sanitize"; +import type { OpdsAcquisition, OpdsCredentials, OpdsPublication } from "./opds-types"; + +const FORMAT_BY_MEDIA_TYPE: Readonly> = { + "application/epub+zip": "epub", + "application/pdf": "pdf", + "application/x-pdf": "pdf", + "application/x-mobipocket-ebook": "mobi", + "application/vnd.amazon.ebook": "azw", + "application/vnd.amazon.mobi8-ebook": "azw3", + "application/x-fictionbook+xml": "fb2", + "application/x-fictionbook+zip": "fbz", + "application/x-zip-compressed-fb2": "fbz", + "application/vnd.comicbook+zip": "cbz", + "application/x-cbz": "cbz", + "text/plain": "txt", + "application/x-umd": "umd", +}; + +const SUPPORTED_FORMATS = new Set([ + "epub", + "pdf", + "mobi", + "azw", + "azw3", + "fb2", + "fbz", + "cbz", + "txt", + "umd", +]); + +/** Hard safety ceiling for the whole-file platform write fallback. */ +export const OPDS_MAX_ACQUISITION_BYTES = 256 * 1024 * 1024; + +export interface SupportedOpdsAcquisition extends OpdsAcquisition { + format: BookFormat; + suggestedFileName: string; +} + +export interface OpdsDownloadProgress { + loaded: number; + total: number; +} + +export interface DownloadOpdsAcquisitionInput { + publication: OpdsPublication; + acquisition?: OpdsAcquisition; + client: Pick; + platform: Pick; + catalogOrigin: string; + credentials?: OpdsCredentials; + destinationPath: string; + signal?: AbortSignal; + onProgress?: (progress: OpdsDownloadProgress) => void; + /** Optional stricter caller limit; never raises the global safety ceiling. */ + maxBytes?: number; +} + +export interface DownloadOpdsAcquisitionResult { + acquisition: SupportedOpdsAcquisition; + destinationPath: string; + suggestedFileName: string; + bytesWritten: number; +} + +export function createExclusiveOpdsDownloadRunner< + TInput extends { signal: AbortSignal; onProgress: (progress: TProgress) => void }, + TResult, + TProgress, +>( + execute: (input: TInput) => Promise, + callbacks: { + onStart?: () => void; + onProgress?: (progress: TProgress) => void; + onFinish?: () => void; + } = {}, +) { + let activeController: AbortController | undefined; + return { + async download(request: Omit): Promise { + if (activeController) throw new OpdsError("download-in-progress"); + const controller = new AbortController(); + activeController = controller; + callbacks.onStart?.(); + try { + return await execute({ + ...request, + signal: controller.signal, + onProgress: (progress: TProgress) => callbacks.onProgress?.(progress), + } as TInput); + } finally { + if (activeController === controller) { + activeController = undefined; + callbacks.onFinish?.(); + } + } + }, + cancel(): void { + activeController?.abort(); + }, + isActive(): boolean { + return activeController !== undefined; + }, + }; +} + +function mediaType(type: string | undefined): string { + return type?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; +} + +function extensionFromUrl(url: string): string | undefined { + try { + return new URL(url).pathname.match(/\.([^.\/]+)$/)?.[1]?.toLowerCase(); + } catch { + return undefined; + } +} + +function getSupportedFormat(acquisition: OpdsAcquisition): BookFormat | undefined { + const normalizedMediaType = mediaType(acquisition.type); + const extension = extensionFromUrl(acquisition.url) as BookFormat | undefined; + if (normalizedMediaType === "application/vnd.amazon.ebook" && extension === "azw3") { + return "azw3"; + } + const advertised = FORMAT_BY_MEDIA_TYPE[normalizedMediaType]; + if (advertised) return advertised; + if (extension && SUPPORTED_FORMATS.has(extension)) return extension; + if (acquisition.format && SUPPORTED_FORMATS.has(acquisition.format)) return acquisition.format; + return undefined; +} + +function isDirectAcquisition(acquisition: OpdsAcquisition): boolean { + return ( + (acquisition.relation ?? classifyOpdsAcquisitionRelation(acquisition.rel))?.downloadable === + true + ); +} + +export function sanitizeOpdsFileName(title: string, format: BookFormat): string { + const withoutControls = Array.from(title, (character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || code === 0x7f ? "" : character; + }).join(""); + const base = withoutControls + .normalize("NFKC") + .replace(/[\\/:*?"<>|]/g, "-") + .replace(/\.{2,}/g, ".") + .replace(/\s+/g, " ") + .replace(/^[ .-]+|[ .]+$/g, "") + .slice(0, 120) + .replace(/[ .]+$/g, ""); + const safeBase = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(base) ? `_${base}` : base; + return `${safeBase || "book"}.${format}`; +} + +export function listSupportedAcquisitions( + publication: OpdsPublication, +): SupportedOpdsAcquisition[] { + return publication.acquisitions.flatMap((acquisition) => { + if (!isDirectAcquisition(acquisition)) return []; + const format = getSupportedFormat(acquisition); + if (!format) return []; + return [ + { + ...acquisition, + format, + suggestedFileName: sanitizeOpdsFileName(publication.title, format), + }, + ]; + }); +} + +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(", "), + ...(publication.publisher ? { publisher: publication.publisher } : {}), + ...(publication.language ? { language: publication.language } : {}), + ...(isbn ? { isbn } : {}), + ...(publication.published ? { publishDate: publication.published } : {}), + ...(description ? { description } : {}), + ...(publication.subjects.length > 0 ? { subjects: [...publication.subjects] } : {}), + }; +} + +function sameAcquisition(left: OpdsAcquisition, right: OpdsAcquisition): boolean { + return ( + left.url === right.url && + mediaType(left.type) === mediaType(right.type) && + left.rel.length === right.rel.length && + left.rel.every((rel, index) => rel === right.rel[index]) + ); +} + +function selectAcquisition(input: DownloadOpdsAcquisitionInput): SupportedOpdsAcquisition { + const supported = listSupportedAcquisitions(input.publication); + if (!input.acquisition) { + if (supported.length === 1) return supported[0]; + throw new OpdsError("unsupported-acquisition"); + } + const requested = input.acquisition; + const selected = supported.find((choice) => sameAcquisition(choice, requested)); + if (!selected) throw new OpdsError("unsupported-acquisition"); + return selected; +} + +function parseContentLength(response: OpdsAssetResponse): number { + const value = Number(response.headers.get("Content-Length")); + return Number.isSafeInteger(value) && value > 0 ? value : 0; +} + +function throwIfCancelled(signal?: AbortSignal): void { + if (signal?.aborted) throw new OpdsError("cancelled"); +} + +function mapDownloadError(error: unknown): OpdsError { + if (error instanceof OpdsError) { + if ( + error.code === "cancelled" || + error.code === "insecure-url" || + error.code === "unauthorized" || + error.code === "unsupported-auth" || + error.code === "unsupported-acquisition" || + error.code === "asset-too-large" + ) { + return error; + } + } + return new OpdsError("download-failed"); +} + +async function readAsset( + response: OpdsAssetResponse, + signal: AbortSignal | undefined, + onProgress: ((progress: OpdsDownloadProgress) => void) | undefined, + maxBytes: number, +): Promise { + const total = parseContentLength(response); + if (total > maxBytes) { + await response.cancel().catch(() => {}); + throw new OpdsError("asset-too-large"); + } + let loaded = 0; + onProgress?.({ loaded, total }); + throwIfCancelled(signal); + + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maxBytes) throw new OpdsError("asset-too-large"); + throwIfCancelled(signal); + loaded = bytes.byteLength; + onProgress?.({ loaded: total > 0 ? Math.min(loaded, total) : loaded, total }); + return bytes; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let preallocated = total > 0 ? new Uint8Array(total) : undefined; + try { + for (;;) { + throwIfCancelled(signal); + const { done, value } = await reader.read(); + throwIfCancelled(signal); + if (done) break; + if (loaded + value.byteLength > maxBytes) throw new OpdsError("asset-too-large"); + if (preallocated) { + if (loaded + value.byteLength > preallocated.byteLength) { + chunks.push(preallocated.subarray(0, loaded), value); + preallocated = undefined; + } else { + preallocated.set(value, loaded); + } + } else { + chunks.push(value); + } + loaded += value.byteLength; + onProgress?.({ loaded: total > 0 ? Math.min(loaded, total) : loaded, total }); + } + } catch (error) { + await response.cancel().catch(() => {}); + throw error; + } finally { + reader.releaseLock(); + } + + if (preallocated) { + if (loaded === preallocated.byteLength) return preallocated; + chunks.push(preallocated.subarray(0, loaded)); + } + + const bytes = new Uint8Array(loaded); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +export async function downloadOpdsAcquisition( + input: DownloadOpdsAcquisitionInput, +): Promise { + const acquisition = selectAcquisition(input); + if (!input.destinationPath.trim()) throw new OpdsError("download-failed"); + throwIfCancelled(input.signal); + + let response: OpdsAssetResponse | undefined; + try { + response = await input.client.fetchAsset( + acquisition.url, + input.catalogOrigin, + input.credentials, + input.signal, + ); + const requestedMax = input.maxBytes; + const maxBytes = + requestedMax !== undefined && Number.isSafeInteger(requestedMax) && requestedMax > 0 + ? Math.min(requestedMax, OPDS_MAX_ACQUISITION_BYTES) + : OPDS_MAX_ACQUISITION_BYTES; + const bytes = await readAsset(response, input.signal, input.onProgress, maxBytes); + throwIfCancelled(input.signal); + await input.platform.writeFile(input.destinationPath, bytes); + throwIfCancelled(input.signal); + return { + acquisition, + destinationPath: input.destinationPath, + suggestedFileName: acquisition.suggestedFileName, + bytesWritten: bytes.byteLength, + }; + } catch (error) { + if (input.signal?.aborted) { + await response?.cancel().catch(() => {}); + throw new OpdsError("cancelled"); + } + throw mapDownloadError(error); + } +} 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-catalog-store.test.ts b/packages/core/src/opds/opds-catalog-store.test.ts new file mode 100644 index 000000000..5a866e471 --- /dev/null +++ b/packages/core/src/opds/opds-catalog-store.test.ts @@ -0,0 +1,1580 @@ +import { describe, expect, it, vi } from "vitest"; +import { + OPDS_BUILT_IN_CATALOGS, + OPDS_CATALOG_STORAGE_KEY, + type OpdsCatalogInput, + type OpdsCatalogStorage, + OpdsCatalogStore, + type OpdsCatalogUpdate, + opdsCatalogSecretKey, +} from "./opds-catalog-store"; + +const CUSTOM_ID = "11111111-1111-4111-8111-111111111111"; +const OTHER_ID = "22222222-2222-4222-8222-222222222222"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function createStorage(initial: string | null = null) { + let persisted = initial; + const secrets = new Map(); + const storage = { + kvGetItem: vi.fn(async () => persisted), + kvSetItem: vi.fn(async (_key: string, value: string) => { + persisted = value; + }), + secretGetItem: vi.fn(async (key: string) => secrets.get(key) ?? null), + secretSetItem: vi.fn(async (key: string, value: string) => { + secrets.set(key, value); + }), + secretRemoveItem: vi.fn(async (key: string) => { + secrets.delete(key); + }), + } satisfies OpdsCatalogStorage; + return { + storage, + secrets, + persisted: () => persisted, + setPersisted: (value: string | null) => { + persisted = value; + }, + }; +} + +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); + await store.load(); + + expect(OPDS_BUILT_IN_CATALOGS.map(({ id, url }) => ({ id, url }))).toEqual([ + { id: "gutenberg", url: "https://www.gutenberg.org/ebooks/search.opds/" }, + { + id: "gutenberg-zh", + url: "https://www.gutenberg.org/ebooks/search.opds/?query=l.zh", + }, + ]); + expect(store.getCatalog("gutenberg-zh")?.url).toBe( + "https://www.gutenberg.org/ebooks/search.opds/?query=l.zh", + ); + await expect( + store.updateCatalog("gutenberg", { url: "https://attacker.test/catalog" }), + ).rejects.toThrow("Built-in catalogs cannot be edited"); + expect(store.getCatalog("gutenberg")?.url).toBe( + "https://www.gutenberg.org/ebooks/search.opds/", + ); + }); + + it("adds, edits, disables, enables, and deletes a custom catalog", async () => { + const { storage } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + + const catalog = await store.addCatalog({ + name: "My catalog", + url: "https://catalog.test/opds", + auth: "anonymous", + }); + expect(catalog).toMatchObject({ id: CUSTOM_ID, enabled: true, builtIn: false }); + + await store.updateCatalog(CUSTOM_ID, { name: "Renamed" }); + await store.setCatalogEnabled(CUSTOM_ID, false); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ name: "Renamed", enabled: false }); + await store.setCatalogEnabled(CUSTOM_ID, true); + expect(store.getCatalog(CUSTOM_ID)?.enabled).toBe(true); + + await store.removeCatalog(CUSTOM_ID); + expect(store.getCatalog(CUSTOM_ID)).toBeUndefined(); + }); + + it("does not expose or secret-store an add rejected by KV persistence", async () => { + const { storage, persisted, secrets } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + vi.mocked(storage.kvSetItem).mockRejectedValueOnce(new Error("write failed")); + + await expect( + store.addCatalog({ + name: "Rejected", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "must-not-survive", + }), + ).rejects.toThrow("write failed"); + + expect(store.getCatalog(CUSTOM_ID)).toBeUndefined(); + expect(persisted()).toBeNull(); + expect(secrets.size).toBe(0); + expect(storage.secretSetItem).not.toHaveBeenCalled(); + }); + + it("retains the full catalog and credential snapshot when update persistence fails", async () => { + const { storage, persisted, secrets } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Original", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "original-password", + }); + const before = persisted(); + vi.mocked(storage.kvSetItem).mockRejectedValueOnce(new Error("write failed")); + vi.mocked(storage.secretRemoveItem).mockClear(); + + await expect( + store.updateCatalog(CUSTOM_ID, { + name: "Rejected", + url: "https://other.test/opds", + username: "other-reader", + password: "new-password", + }), + ).rejects.toThrow("write failed"); + + expect(persisted()).toBe(before); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ + name: "Original", + url: "https://catalog.test/opds", + username: "reader", + passwordStorage: "persistent", + }); + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("original-password"); + expect(storage.secretRemoveItem).not.toHaveBeenCalled(); + }); + + it("rolls persisted update identity back when secret removal fails", async () => { + const { storage, persisted, secrets } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Original", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "original-password", + }); + const before = persisted(); + vi.mocked(storage.kvSetItem).mockClear(); + vi.mocked(storage.secretRemoveItem).mockRejectedValueOnce(new Error("remove failed")); + vi.mocked(storage.secretSetItem).mockClear(); + + await expect( + store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds", password: "new-password" }), + ).rejects.toThrow("remove failed"); + + expect(storage.kvSetItem).toHaveBeenCalledTimes(2); + expect(persisted()).toBe(before); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ + url: "https://catalog.test/opds", + passwordStorage: "persistent", + }); + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("original-password"); + expect(storage.secretSetItem).toHaveBeenCalledWith( + opdsCatalogSecretKey(CUSTOM_ID), + "original-password", + ); + }); + + it("aborts identity update before side effects when the compensation secret cannot be read", async () => { + const { storage, persisted, secrets } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Original", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "original-password", + }); + const before = persisted(); + vi.mocked(storage.kvSetItem).mockClear(); + vi.mocked(storage.secretGetItem).mockRejectedValueOnce(new Error("secret read failed")); + + await expect( + 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); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ + url: "https://catalog.test/opds", + passwordStorage: "persistent", + }); + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("original-password"); + }); + + it("rolls KV back and retains a truthful session credential when secret compensation fails", async () => { + const { storage, persisted, secrets } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Original", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "original-password", + }); + const before = persisted(); + vi.mocked(storage.secretRemoveItem).mockRejectedValueOnce(new Error("remove failed")); + vi.mocked(storage.secretSetItem).mockRejectedValueOnce(new Error("restore failed")); + + await expect( + 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); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ + url: "https://catalog.test/opds", + passwordStorage: "session-only", + }); + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("original-password"); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toEqual({ + username: "reader", + password: "original-password", + catalogOrigin: "https://catalog.test", + }); + }); + + it("blocks stale credentials and reports a compound error when update rollback also fails", async () => { + const { storage, persisted, secrets, setPersisted } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Original", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "original-password", + }); + vi.mocked(storage.kvSetItem) + .mockImplementationOnce(async (_key, value) => setPersisted(value)) + .mockRejectedValueOnce(new Error("rollback write leaked a backend detail")); + vi.mocked(storage.secretRemoveItem).mockRejectedValueOnce(new Error("remove failed")); + + await expect( + 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: "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.toMatchObject({ + password: "new-password", + catalogOrigin: "https://other.test", + }); + }); + + it("durably blocks a stale update secret across restart until cleanup succeeds", async () => { + const { storage, persisted, secrets, setPersisted } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Original", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "original-password", + }); + + let writeAttempt = 0; + vi.mocked(storage.kvSetItem).mockImplementation(async (_key, value) => { + writeAttempt += 1; + if (writeAttempt === 2) throw new Error("rollback failed"); + setPersisted(value); + }); + let removeAttempt = 0; + vi.mocked(storage.secretRemoveItem).mockImplementation(async (key) => { + removeAttempt += 1; + if (removeAttempt <= 3) throw new Error("remove failed"); + secrets.delete(key); + }); + + await expect( + 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() ?? "{}"); + expect(failedState.customCatalogs[0].url).toBe("https://other.test/opds"); + expect(failedState.pendingSecretCleanups).toEqual([ + { id: CUSTOM_ID, revision: 1, action: "remove-secret" }, + ]); + expect(JSON.stringify(failedState)).not.toContain("original-password"); + + vi.mocked(storage.secretGetItem).mockClear(); + const reloaded = new OpdsCatalogStore(storage, () => OTHER_ID); + await reloaded.load(); + expect(removeAttempt).toBe(3); + await expect(reloaded.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + expect(storage.secretGetItem).not.toHaveBeenCalledWith(opdsCatalogSecretKey(CUSTOM_ID)); + + await reloaded.updateCatalog(CUSTOM_ID, { name: "Cleanup retried" }); + expect(removeAttempt).toBe(4); + expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toBeUndefined(); + expect(secrets.has(opdsCatalogSecretKey(CUSTOM_ID))).toBe(false); + await expect(reloaded.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + }); + + it("uses only the new session password when identity replacement cannot persist its secret", async () => { + const { storage, secrets } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Original", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "original-password", + }); + vi.mocked(storage.secretSetItem).mockRejectedValueOnce(new Error("set failed")); + + const updated = await store.updateCatalog(CUSTOM_ID, { + url: "https://other.test/opds", + password: "new-session-password", + }); + + expect(updated.passwordStorage).toBe("session-only"); + expect(secrets.has(opdsCatalogSecretKey(CUSTOM_ID))).toBe(false); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toEqual({ + username: "reader", + password: "new-session-password", + catalogOrigin: "https://other.test", + }); + }); + + it("retains the full catalog and credential snapshot when delete persistence fails", async () => { + const { storage, persisted, secrets } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Original", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "original-password", + }); + const before = persisted(); + vi.mocked(storage.kvSetItem).mockRejectedValueOnce(new Error("write failed")); + vi.mocked(storage.secretRemoveItem).mockClear(); + + await expect(store.removeCatalog(CUSTOM_ID)).rejects.toThrow("write failed"); + expect(persisted()).toBe(before); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ passwordStorage: "persistent" }); + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("original-password"); + expect(storage.secretRemoveItem).not.toHaveBeenCalled(); + }); + + it("rolls persisted deletion back when secret removal fails", async () => { + const { storage, persisted, secrets } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Original", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "original-password", + }); + const before = persisted(); + vi.mocked(storage.kvSetItem).mockClear(); + vi.mocked(storage.secretRemoveItem).mockRejectedValueOnce(new Error("remove failed")); + vi.mocked(storage.secretSetItem).mockClear(); + + await expect(store.removeCatalog(CUSTOM_ID)).rejects.toThrow("remove failed"); + expect(storage.kvSetItem).toHaveBeenCalledTimes(2); + expect(persisted()).toBe(before); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ passwordStorage: "persistent" }); + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("original-password"); + expect(storage.secretSetItem).toHaveBeenCalledWith( + opdsCatalogSecretKey(CUSTOM_ID), + "original-password", + ); + }); + + it("aborts deletion before side effects when the compensation secret cannot be read", async () => { + const { storage, persisted, secrets } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Original", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "original-password", + }); + const before = persisted(); + vi.mocked(storage.kvSetItem).mockClear(); + vi.mocked(storage.secretGetItem).mockRejectedValueOnce(new Error("secret read failed")); + + await expect(store.removeCatalog(CUSTOM_ID)).rejects.toThrow("secret read failed"); + expect(storage.kvSetItem).not.toHaveBeenCalled(); + expect(persisted()).toBe(before); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ passwordStorage: "persistent" }); + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("original-password"); + }); + + it("persists a deletion cleanup tombstone and removes the orphan after restart", async () => { + const { storage, persisted, secrets, setPersisted } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Original", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "orphan-password", + }); + + let writeAttempt = 0; + vi.mocked(storage.kvSetItem).mockImplementation(async (_key, value) => { + writeAttempt += 1; + if (writeAttempt === 2) throw new Error("rollback failed"); + setPersisted(value); + }); + let removeAttempt = 0; + vi.mocked(storage.secretRemoveItem).mockImplementation(async (key) => { + removeAttempt += 1; + if (removeAttempt <= 2) throw new Error("remove failed"); + secrets.delete(key); + }); + + await expect(store.removeCatalog(CUSTOM_ID)).rejects.toThrow( + "Catalog removal failed and rollback failed", + ); + expect(JSON.parse(persisted() ?? "{}")).toMatchObject({ + customCatalogs: [], + pendingSecretCleanups: [{ id: CUSTOM_ID, revision: 1, action: "remove-secret" }], + }); + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("orphan-password"); + + const reloaded = new OpdsCatalogStore(storage, () => OTHER_ID); + await reloaded.load(); + + expect(removeAttempt).toBe(3); + expect(secrets.has(opdsCatalogSecretKey(CUSTOM_ID))).toBe(false); + expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toBeUndefined(); + expect(reloaded.getCatalog(CUSTOM_ID)).toBeUndefined(); + }); + + it("stays fail-closed when clearing a completed cleanup tombstone cannot persist", async () => { + const initial = JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: CUSTOM_ID, + name: "Changed", + url: "https://other.test/opds", + enabled: true, + auth: "basic", + username: "reader", + }, + ], + hiddenBuiltInIds: [], + pendingSecretCleanups: [{ id: CUSTOM_ID, revision: 7, action: "remove-secret" }], + }); + const { storage, persisted, secrets, setPersisted } = createStorage(initial); + secrets.set(opdsCatalogSecretKey(CUSTOM_ID), "stale-password"); + vi.mocked(storage.kvSetItem) + .mockImplementationOnce(async (_key, value) => setPersisted(value)) + .mockRejectedValueOnce(new Error("cleanup marker write failed")); + const store = new OpdsCatalogStore(storage, () => OTHER_ID); + + await expect(store.load()).rejects.toThrow("cleanup marker write failed"); + + expect(secrets.has(opdsCatalogSecretKey(CUSTOM_ID))).toBe(false); + expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toHaveLength(1); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + + await expect(store.load()).resolves.toBeUndefined(); + expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toBeUndefined(); + await expect( + store.updateCatalog(CUSTOM_ID, { name: "Queue recovered" }), + ).resolves.toMatchObject({ name: "Queue recovered" }); + }); + + it("sanitizes cleanup tombstones and prevents ID reuse while cleanup is pending", async () => { + const initial = JSON.stringify({ + version: 1, + customCatalogs: [], + hiddenBuiltInIds: [], + pendingSecretCleanups: [ + { id: CUSTOM_ID, revision: 2, action: "remove-secret" }, + { id: CUSTOM_ID, revision: 4, action: "remove-secret" }, + { id: "gutenberg", revision: 9, action: "remove-secret" }, + { id: OTHER_ID, revision: -1, action: "remove-secret" }, + { id: OTHER_ID, revision: 3, action: "restore-secret" }, + { id: "__proto__", revision: 5, action: "remove-secret" }, + ], + }); + const { storage, persisted } = createStorage(initial); + vi.mocked(storage.secretRemoveItem).mockRejectedValue(new Error("still unavailable")); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + + await store.load(); + + expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toEqual([ + { id: CUSTOM_ID, revision: 4, action: "remove-secret" }, + { id: OTHER_ID, revision: 0, action: "remove-secret" }, + ]); + await expect( + store.addCatalog({ + name: "Must not reuse", + url: "https://catalog.test/opds", + auth: "anonymous", + }), + ).rejects.toThrow("Could not generate a unique catalog id"); + expect(store.getCatalog(CUSTOM_ID)).toBeUndefined(); + }); + + it.each([ + { revision: "not-a-revision", action: "remove-secret" }, + { revision: 3, action: "restore-secret" }, + ])( + "quarantines a valid catalog id when its cleanup marker is malformed: %o", + async ({ revision, action }) => { + const initial = JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: CUSTOM_ID, + name: "Changed identity", + url: "https://other.test/opds", + enabled: true, + auth: "basic", + username: "reader", + }, + ], + hiddenBuiltInIds: [], + pendingSecretCleanups: [{ id: CUSTOM_ID, revision, action }], + }); + const { storage, persisted, secrets } = createStorage(initial); + secrets.set(opdsCatalogSecretKey(CUSTOM_ID), "old-identity-password"); + vi.mocked(storage.secretRemoveItem).mockRejectedValue(new Error("cleanup unavailable")); + const store = new OpdsCatalogStore(storage, () => OTHER_ID); + + await store.load(); + + expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toEqual([ + { id: CUSTOM_ID, revision: 0, action: "remove-secret" }, + ]); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + expect(storage.secretGetItem).not.toHaveBeenCalled(); + + const fresh = new OpdsCatalogStore(storage, () => OTHER_ID); + await fresh.load(); + await expect(fresh.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toHaveLength(1); + }, + ); + + it("rejects a non-array cleanup field without adopting the possibly changed identity", async () => { + const initial = JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: CUSTOM_ID, + name: "Changed identity", + url: "https://other.test/opds", + enabled: true, + auth: "basic", + username: "reader", + }, + ], + hiddenBuiltInIds: [], + pendingSecretCleanups: { id: CUSTOM_ID }, + }); + const { storage, persisted, secrets } = createStorage(initial); + secrets.set(opdsCatalogSecretKey(CUSTOM_ID), "old-identity-password"); + const store = new OpdsCatalogStore(storage, () => OTHER_ID); + + await expect(store.load()).rejects.toThrow("Catalog cleanup storage is invalid"); + + expect(store.getCatalog(CUSTOM_ID)).toBeUndefined(); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + expect(storage.secretGetItem).not.toHaveBeenCalled(); + expect(persisted()).toBe(initial); + }); + + it("fails closed instead of overflowing a cleanup revision", async () => { + const initial = JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: CUSTOM_ID, + name: "Original", + url: "https://catalog.test/opds", + enabled: true, + auth: "basic", + username: "reader", + }, + ], + hiddenBuiltInIds: [], + pendingSecretCleanups: [ + { + id: OTHER_ID, + revision: Number.MAX_SAFE_INTEGER, + action: "remove-secret", + }, + ], + }); + const { storage, persisted, secrets } = createStorage(initial); + secrets.set(opdsCatalogSecretKey(CUSTOM_ID), "original-password"); + vi.mocked(storage.secretRemoveItem).mockRejectedValue(new Error("cleanup unavailable")); + const store = new OpdsCatalogStore(storage, () => "33333333-3333-4333-8333-333333333333"); + await store.load(); + const before = persisted(); + vi.mocked(storage.kvSetItem).mockClear(); + + await expect( + store.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds", password: "new-password" }), + ).rejects.toThrow("Catalog cleanup revision is exhausted"); + + expect(storage.kvSetItem).not.toHaveBeenCalled(); + expect(persisted()).toBe(before); + expect(store.getCatalog(CUSTOM_ID)?.url).toBe("https://catalog.test/opds"); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toMatchObject({ + password: "original-password", + catalogOrigin: "https://catalog.test", + }); + }); + + it("serializes cleanup completion before a following edit", async () => { + const initial = JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: CUSTOM_ID, + name: "Changed", + url: "https://other.test/opds", + enabled: true, + auth: "basic", + username: "reader", + }, + ], + hiddenBuiltInIds: [], + pendingSecretCleanups: [{ id: CUSTOM_ID, revision: 3, action: "remove-secret" }], + }); + const { storage, setPersisted } = createStorage(initial); + const removal = deferred(); + vi.mocked(storage.secretRemoveItem).mockImplementationOnce(() => removal.promise); + vi.mocked(storage.kvSetItem).mockImplementation(async (_key, value) => setPersisted(value)); + const store = new OpdsCatalogStore(storage, () => OTHER_ID); + + const loading = store.load(); + await vi.waitFor(() => expect(storage.secretRemoveItem).toHaveBeenCalledTimes(1)); + const editing = store.updateCatalog(CUSTOM_ID, { name: "After cleanup" }); + await Promise.resolve(); + expect(storage.kvSetItem).toHaveBeenCalledTimes(1); + + removal.resolve(); + await loading; + await editing; + + expect(store.getCatalog(CUSTOM_ID)?.name).toBe("After cleanup"); + expect(storage.kvSetItem).toHaveBeenCalledTimes(3); + }); + + it("keeps built-in visibility failure-atomic when KV persistence rejects", async () => { + const { storage } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + vi.mocked(storage.kvSetItem).mockRejectedValueOnce(new Error("hide failed")); + + await expect(store.hideBuiltIn("gutenberg")).rejects.toThrow("hide failed"); + expect(store.getCatalog("gutenberg")?.hidden).toBe(false); + + await store.hideBuiltIn("gutenberg"); + vi.mocked(storage.kvSetItem).mockRejectedValueOnce(new Error("restore failed")); + await expect(store.restoreBuiltIn("gutenberg")).rejects.toThrow("restore failed"); + expect(store.getCatalog("gutenberg")?.hidden).toBe(true); + }); + + it("serializes concurrent mutations so delayed writes cannot overwrite a newer snapshot", async () => { + const { storage, persisted, setPersisted } = createStorage(); + const firstWrite = deferred(); + let writes = 0; + vi.mocked(storage.kvSetItem).mockImplementation(async (_key, value) => { + writes += 1; + if (writes === 1) await firstWrite.promise; + setPersisted(value); + }); + const ids = [CUSTOM_ID, OTHER_ID]; + const store = new OpdsCatalogStore(storage, () => ids.shift() ?? "missing"); + await store.load(); + + const first = store.addCatalog({ + name: "First", + url: "https://first.test/opds", + auth: "anonymous", + }); + await vi.waitFor(() => expect(storage.kvSetItem).toHaveBeenCalledTimes(1)); + const second = store.addCatalog({ + name: "Second", + url: "https://second.test/opds", + auth: "anonymous", + }); + await Promise.resolve(); + expect(storage.kvSetItem).toHaveBeenCalledTimes(1); + + firstWrite.resolve(); + await Promise.all([first, second]); + + expect(store.listCatalogs({ includeHidden: true }).map(({ id }) => id)).toEqual([ + "gutenberg", + "gutenberg-zh", + CUSTOM_ID, + OTHER_ID, + ]); + expect( + JSON.parse(persisted() ?? "{}").customCatalogs.map(({ id }: { id: string }) => id), + ).toEqual([CUSTOM_ID, OTHER_ID]); + }); + + it("serializes load with following mutations so an older load cannot overwrite an add", async () => { + const initial = JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: OTHER_ID, + name: "Loaded", + url: "https://loaded.test/opds", + enabled: true, + auth: "anonymous", + }, + ], + hiddenBuiltInIds: [], + }); + const { storage, persisted, setPersisted } = createStorage(initial); + const pendingRead = deferred(); + vi.mocked(storage.kvGetItem).mockImplementationOnce(() => pendingRead.promise); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + + const loading = store.load(); + const adding = store.addCatalog({ + name: "Added", + url: "https://added.test/opds", + auth: "anonymous", + }); + await Promise.resolve(); + expect(storage.kvSetItem).not.toHaveBeenCalled(); + pendingRead.resolve(initial); + vi.mocked(storage.kvSetItem).mockImplementation(async (_key, value) => setPersisted(value)); + await Promise.all([loading, adding]); + + expect( + JSON.parse(persisted() ?? "{}").customCatalogs.map(({ id }: { id: string }) => id), + ).toEqual([OTHER_ID, CUSTOM_ID]); + }); + + it("persists only versioned definitions and built-in hidden state, never passwords", async () => { + const { storage, persisted } = createStorage(); + 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: "secret-password", + }); + await store.hideBuiltIn("gutenberg"); + + const raw = persisted(); + expect(storage.kvSetItem).toHaveBeenCalledWith(OPDS_CATALOG_STORAGE_KEY, expect.any(String)); + expect(raw).not.toBeNull(); + expect(raw).not.toContain("secret-password"); + expect(raw).not.toContain("Authorization"); + expect(JSON.parse(raw ?? "{}")).toEqual({ + version: 1, + customCatalogs: [ + { + id: CUSTOM_ID, + name: "Private", + url: "https://catalog.test/opds", + enabled: true, + auth: "basic", + username: "reader", + }, + ], + hiddenBuiltInIds: ["gutenberg"], + }); + }); + + it("hides and restores built-ins without deleting their definitions", async () => { + const { storage } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + + await store.hideBuiltIn("gutenberg-zh"); + expect(store.listCatalogs().map((catalog) => catalog.id)).not.toContain("gutenberg-zh"); + expect(store.getCatalog("gutenberg-zh")?.hidden).toBe(true); + await store.restoreBuiltIn("gutenberg-zh"); + expect(store.listCatalogs().map((catalog) => catalog.id)).toContain("gutenberg-zh"); + }); + + it("reload clears session credentials before accepting a changed catalog identity", async () => { + const { storage: fullStorage, setPersisted } = createStorage(); + const storage: OpdsCatalogStorage = { + kvGetItem: fullStorage.kvGetItem, + kvSetItem: fullStorage.kvSetItem, + }; + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Session catalog", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "session-password", + }); + setPersisted( + JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: CUSTOM_ID, + name: "Changed elsewhere", + url: "https://other.test/opds", + enabled: true, + auth: "basic", + username: "other-reader", + }, + ], + hiddenBuiltInIds: [], + }), + ); + + await store.load(); + + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ + url: "https://other.test/opds", + username: "other-reader", + passwordStorage: "none", + }); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + }); + + it("retains the complete live snapshot when persisted catalog read fails", async () => { + const { storage: fullStorage } = createStorage(); + const storage: OpdsCatalogStorage = { + kvGetItem: fullStorage.kvGetItem, + kvSetItem: fullStorage.kvSetItem, + }; + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Session catalog", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "session-password", + }); + vi.mocked(fullStorage.kvGetItem).mockRejectedValueOnce(new Error("read failed")); + + await expect(store.load()).rejects.toThrow("read failed"); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ + name: "Session catalog", + passwordStorage: "session-only", + }); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toMatchObject({ + password: "session-password", + }); + }); + + it("retains the complete live snapshot when canonical persistence during reload fails", async () => { + const { storage: fullStorage, setPersisted } = createStorage(); + const storage: OpdsCatalogStorage = { + kvGetItem: fullStorage.kvGetItem, + kvSetItem: fullStorage.kvSetItem, + }; + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Session catalog", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "session-password", + }); + setPersisted( + JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: OTHER_ID, + name: "Other catalog", + url: "https://other.test/opds", + enabled: true, + auth: "anonymous", + }, + ], + hiddenBuiltInIds: ["gutenberg"], + }), + ); + vi.mocked(fullStorage.kvSetItem).mockRejectedValueOnce(new Error("write failed")); + + await expect(store.load()).rejects.toThrow("write failed"); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ + name: "Session catalog", + passwordStorage: "session-only", + }); + expect(store.getCatalog(OTHER_ID)).toBeUndefined(); + expect(store.getCatalog("gutenberg")?.hidden).toBe(false); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toMatchObject({ + password: "session-password", + }); + }); + + it("removes a catalog secret on delete and treats a missing secret as idempotent", async () => { + const { storage } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Private", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + }); + + await expect(store.removeCatalog(CUSTOM_ID)).resolves.toBe(true); + expect(storage.secretRemoveItem).toHaveBeenCalledWith(`opds.catalog.${CUSTOM_ID}.password`); + await expect(store.removeCatalog(CUSTOM_ID)).resolves.toBe(false); + }); + + it("clears the old secret when URL or auth identity changes but retains it for display edits", async () => { + const { storage } = createStorage(); + 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: "secret-password", + }); + vi.mocked(storage.secretRemoveItem).mockClear(); + + await store.updateCatalog(CUSTOM_ID, { name: "Still private" }); + expect(storage.secretRemoveItem).not.toHaveBeenCalled(); + 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(); + await store.updateCatalog(CUSTOM_ID, { auth: "anonymous" }); + expect(storage.secretRemoveItem).toHaveBeenCalledWith(opdsCatalogSecretKey(CUSTOM_ID)); + }); + + it("uses a per-instance session password when secret persistence is unavailable", async () => { + const { storage: persistentStorage } = createStorage(); + const storage: OpdsCatalogStorage = { + kvGetItem: persistentStorage.kvGetItem, + kvSetItem: persistentStorage.kvSetItem, + }; + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + + const catalog = await store.addCatalog({ + name: "Session catalog", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "session-password", + }); + expect(catalog.passwordStorage).toBe("session-only"); + await expect(store.getCredentials(CUSTOM_ID)).resolves.toEqual({ + username: "reader", + password: "session-password", + catalogOrigin: "https://catalog.test", + }); + + const reloaded = new OpdsCatalogStore(storage, () => OTHER_ID); + await reloaded.load(); + expect(reloaded.getCatalog(CUSTOM_ID)?.passwordStorage).toBe("none"); + await expect(reloaded.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + }); + + it("falls back to session-only without leaking a password when secret storage fails", async () => { + const { storage, persisted } = createStorage(); + vi.mocked(storage.secretSetItem).mockRejectedValue(new Error("backend included a secret")); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + + const catalog = await store.addCatalog({ + name: "Private", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "never-persist-me", + }); + + expect(catalog.passwordStorage).toBe("session-only"); + expect(persisted()).not.toContain("never-persist-me"); + expect(JSON.stringify(store.listCatalogs({ includeHidden: true }))).not.toContain( + "never-persist-me", + ); + }); + + it.each([true, false])( + "rejects an anonymous password before touching complete=%s secret storage", + async (complete) => { + const { storage: fullStorage } = createStorage(); + const storage: OpdsCatalogStorage = complete + ? fullStorage + : { kvGetItem: fullStorage.kvGetItem, kvSetItem: fullStorage.kvSetItem }; + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + vi.mocked(fullStorage.kvSetItem).mockClear(); + + await expect( + store.addCatalog({ + name: "Anonymous", + url: "https://catalog.test/opds", + auth: "anonymous", + password: "must-not-be-stored", + }), + ).rejects.toThrow("Anonymous catalogs cannot have a password"); + expect(fullStorage.kvSetItem).not.toHaveBeenCalled(); + expect(fullStorage.secretSetItem).not.toHaveBeenCalled(); + expect(store.getCatalog(CUSTOM_ID)).toBeUndefined(); + }, + ); + + it.each([ + ["enabled", { enabled: "yes" }], + ["username", { auth: "basic", username: 7 }], + ["password", { auth: "basic", username: "reader", password: 7 }], + ])("rejects a runtime-invalid add %s before side effects", async (_field, invalid) => { + const { storage } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + vi.mocked(storage.kvSetItem).mockClear(); + + await expect( + store.addCatalog({ + name: "Invalid", + url: "https://catalog.test/opds", + auth: "anonymous", + ...invalid, + } as unknown as OpdsCatalogInput), + ).rejects.toThrow("Catalog input is invalid"); + expect(storage.kvSetItem).not.toHaveBeenCalled(); + expect(storage.secretSetItem).not.toHaveBeenCalled(); + expect(store.getCatalog(CUSTOM_ID)).toBeUndefined(); + }); + + it.each([ + ["enabled", { enabled: "yes" }], + ["username", { username: 7 }], + ["password", { password: 7 }], + ])("rejects a runtime-invalid update %s before side effects", async (_field, invalid) => { + const { storage } = createStorage(); + const store = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await store.load(); + await store.addCatalog({ + name: "Valid", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "current-password", + }); + vi.mocked(storage.kvSetItem).mockClear(); + vi.mocked(storage.secretSetItem).mockClear(); + vi.mocked(storage.secretRemoveItem).mockClear(); + + await expect( + store.updateCatalog(CUSTOM_ID, invalid as unknown as OpdsCatalogUpdate), + ).rejects.toThrow("Catalog input is invalid"); + expect(storage.kvSetItem).not.toHaveBeenCalled(); + expect(storage.secretSetItem).not.toHaveBeenCalled(); + expect(storage.secretRemoveItem).not.toHaveBeenCalled(); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ + name: "Valid", + enabled: true, + username: "reader", + passwordStorage: "persistent", + }); + }); + + it("treats a partial secret adapter as unavailable instead of reusing an unremovable secret", async () => { + const persisted = JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: CUSTOM_ID, + name: "Private", + url: "https://catalog.test/opds", + enabled: true, + auth: "basic", + username: "reader", + }, + ], + hiddenBuiltInIds: [], + }); + const { storage: completeStorage, secrets } = createStorage(persisted); + secrets.set(opdsCatalogSecretKey(CUSTOM_ID), "stale-password"); + const partialStorage: OpdsCatalogStorage = { + kvGetItem: completeStorage.kvGetItem, + kvSetItem: completeStorage.kvSetItem, + secretGetItem: completeStorage.secretGetItem, + secretSetItem: completeStorage.secretSetItem, + }; + const store = new OpdsCatalogStore(partialStorage, () => OTHER_ID); + await store.load(); + + await expect(store.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + const added = await store.addCatalog({ + name: "Session only", + url: "https://other.test/opds", + auth: "basic", + username: "reader", + password: "session-password", + }); + expect(added.passwordStorage).toBe("session-only"); + }); + + 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(); + await complete.addCatalog({ + name: "Private", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "old-password", + }); + const missingStorage: OpdsCatalogStorage = { + kvGetItem: storage.kvGetItem, + kvSetItem: storage.kvSetItem, + }; + const missing = new OpdsCatalogStore(missingStorage, () => OTHER_ID); + await missing.load(); + + await expect( + missing.updateCatalog(CUSTOM_ID, { url: "https://other.test/opds" }), + ).rejects.toThrow("A password is required when changing catalog credentials"); + + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("old-password"); + 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).toBeUndefined(); + + vi.mocked(storage.secretRemoveItem).mockClear(); + const restored = new OpdsCatalogStore(storage, () => OTHER_ID); + await restored.load(); + + 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.toMatchObject({ + password: "old-password", + }); + }); + + it("allows safe missing-backend edits and deletion while retaining cleanup", async () => { + const { storage, secrets, persisted } = createStorage(); + const complete = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await complete.load(); + await complete.addCatalog({ + name: "Private", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "old-password", + }); + const missingStorage: OpdsCatalogStorage = { + kvGetItem: storage.kvGetItem, + kvSetItem: storage.kvSetItem, + }; + const missing = new OpdsCatalogStore(missingStorage, () => OTHER_ID); + await missing.load(); + vi.mocked(storage.secretGetItem).mockClear(); + vi.mocked(storage.secretSetItem).mockClear(); + vi.mocked(storage.secretRemoveItem).mockClear(); + + await expect(missing.updateCatalog(CUSTOM_ID, { auth: "anonymous" })).resolves.toMatchObject({ + auth: "anonymous", + }); + await expect(missing.updateCatalog(CUSTOM_ID, { name: "Renamed" })).resolves.toMatchObject({ + name: "Renamed", + }); + await expect(missing.setCatalogEnabled(CUSTOM_ID, false)).resolves.toMatchObject({ + enabled: false, + }); + await expect(missing.removeCatalog(CUSTOM_ID)).resolves.toBe(true); + + expect(missing.getCatalog(CUSTOM_ID)).toBeUndefined(); + expect(JSON.parse(persisted() ?? "{}")).toMatchObject({ + customCatalogs: [], + pendingSecretCleanups: [{ id: CUSTOM_ID, revision: 1, action: "remove-secret" }], + }); + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("old-password"); + expect(storage.secretGetItem).not.toHaveBeenCalled(); + expect(storage.secretSetItem).not.toHaveBeenCalled(); + expect(storage.secretRemoveItem).not.toHaveBeenCalled(); + + 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(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toBeUndefined(); + expect(restored.getCatalog(CUSTOM_ID)).toBeUndefined(); + await expect(restored.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + }); + + it("uses only a new session password for identity edits while cleanup is pending", async () => { + const { storage, secrets, persisted } = createStorage(); + const complete = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await complete.load(); + await complete.addCatalog({ + name: "Private", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "old-password", + }); + const missingStorage: OpdsCatalogStorage = { + kvGetItem: storage.kvGetItem, + kvSetItem: storage.kvSetItem, + }; + const missing = new OpdsCatalogStore(missingStorage, () => OTHER_ID); + await missing.load(); + 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(); + + const updated = await missing.updateCatalog(CUSTOM_ID, { + url: "https://third.test/opds", + password: "new-session-password", + }); + + expect(updated).toMatchObject({ + url: "https://third.test/opds", + passwordStorage: "session-only", + }); + await expect(missing.getCredentials(CUSTOM_ID)).resolves.toEqual({ + username: "reader", + password: "new-session-password", + catalogOrigin: "https://third.test", + }); + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("old-password"); + expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toEqual([ + { id: CUSTOM_ID, revision: 1, action: "remove-secret" }, + ]); + expect(storage.secretGetItem).not.toHaveBeenCalled(); + expect(storage.secretSetItem).not.toHaveBeenCalled(); + expect(storage.secretRemoveItem).not.toHaveBeenCalled(); + }); + + it("retains an orphan cleanup marker when deletion uses a partial adapter", async () => { + const { storage, secrets, persisted } = createStorage(); + const complete = new OpdsCatalogStore(storage, () => CUSTOM_ID); + await complete.load(); + await complete.addCatalog({ + name: "Private", + url: "https://catalog.test/opds", + auth: "basic", + username: "reader", + password: "old-password", + }); + const partialStorage: OpdsCatalogStorage = { + kvGetItem: storage.kvGetItem, + kvSetItem: storage.kvSetItem, + secretGetItem: storage.secretGetItem, + secretSetItem: storage.secretSetItem, + }; + const partial = new OpdsCatalogStore(partialStorage, () => OTHER_ID); + await partial.load(); + + await expect(partial.removeCatalog(CUSTOM_ID)).resolves.toBe(true); + + expect(partial.getCatalog(CUSTOM_ID)).toBeUndefined(); + expect(secrets.get(opdsCatalogSecretKey(CUSTOM_ID))).toBe("old-password"); + expect(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toEqual([ + { id: CUSTOM_ID, revision: 1, action: "remove-secret" }, + ]); + + 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(JSON.parse(persisted() ?? "{}").pendingSecretCleanups).toBeUndefined(); + expect(restored.getCatalog(CUSTOM_ID)).toBeUndefined(); + }); + + it("loads valid records while discarding userinfo URLs, invalid IDs, pollution keys, and duplicates", async () => { + const persisted = JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: CUSTOM_ID, + name: "Good", + url: "https://catalog.test/opds?query=books", + enabled: true, + auth: "anonymous", + }, + { + id: OTHER_ID, + name: "Malicious URL", + url: "https://user:password@catalog.test/opds", + enabled: true, + auth: "anonymous", + }, + { + id: "__proto__", + name: "Pollution", + url: "https://pollution.test/opds", + enabled: true, + auth: "anonymous", + }, + { + id: "gutenberg", + name: "Fake built-in", + url: "https://attacker.test/opds", + enabled: true, + auth: "anonymous", + }, + { + id: CUSTOM_ID, + name: "Duplicate", + url: "https://duplicate.test/opds", + enabled: true, + auth: "anonymous", + }, + ], + hiddenBuiltInIds: ["gutenberg-zh", "gutenberg-zh", "__proto__"], + password: "must-be-ignored", + }); + const { storage, persisted: saved } = createStorage(persisted); + const store = new OpdsCatalogStore(storage, () => OTHER_ID); + + await expect(store.load()).resolves.toBeUndefined(); + expect(store.listCatalogs({ includeHidden: true }).map((catalog) => catalog.id)).toEqual([ + "gutenberg", + "gutenberg-zh", + CUSTOM_ID, + ]); + expect(store.getCatalog(CUSTOM_ID)?.url).toBe("https://catalog.test/opds?query=books"); + expect(store.getCatalog("gutenberg-zh")?.hidden).toBe(true); + expect(Object.prototype.polluted).toBeUndefined(); + expect(saved()).not.toContain("must-be-ignored"); + expect(saved()).not.toContain("user:password"); + }); + + it("does not collide secret keys for distinct catalog IDs", () => { + expect(opdsCatalogSecretKey(CUSTOM_ID)).toBe(`opds.catalog.${CUSTOM_ID}.password`); + expect(opdsCatalogSecretKey(OTHER_ID)).toBe(`opds.catalog.${OTHER_ID}.password`); + expect(opdsCatalogSecretKey(CUSTOM_ID)).not.toBe(opdsCatalogSecretKey(OTHER_ID)); + }); + + it("does not change catalog or password state when secret retrieval fails", async () => { + const persisted = JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: CUSTOM_ID, + name: "Private", + url: "https://catalog.test/opds", + enabled: true, + auth: "basic", + username: "reader", + }, + ], + hiddenBuiltInIds: [], + }); + const { storage } = createStorage(persisted); + vi.mocked(storage.secretGetItem).mockRejectedValueOnce(new Error("read failed")); + const store = new OpdsCatalogStore(storage, () => OTHER_ID); + await store.load(); + + await expect(store.getCredentials(CUSTOM_ID)).resolves.toBeUndefined(); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ + url: "https://catalog.test/opds", + passwordStorage: "none", + }); + }); + + it("does not return or resurrect a secret read that loses a race with identity update", async () => { + const { storage } = createStorage(); + 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 pendingSecret = deferred(); + vi.mocked(storage.secretGetItem).mockImplementationOnce(() => pendingSecret.promise); + + const reading = store.getCredentials(CUSTOM_ID); + await vi.waitFor(() => expect(storage.secretGetItem).toHaveBeenCalled()); + 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("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 () => { + const { storage } = createStorage(); + 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 pendingSecret = deferred(); + vi.mocked(storage.secretGetItem).mockImplementationOnce(() => pendingSecret.promise); + + const reading = store.getCredentials(CUSTOM_ID); + await vi.waitFor(() => expect(storage.secretGetItem).toHaveBeenCalled()); + await store.removeCatalog(CUSTOM_ID); + pendingSecret.resolve("old-password"); + + await expect(reading).resolves.toBeUndefined(); + expect(store.getCatalog(CUSTOM_ID)).toBeUndefined(); + }); + + it("does not return or resurrect a secret read that loses a race with reload", async () => { + const { storage, setPersisted } = createStorage(); + 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 pendingSecret = deferred(); + vi.mocked(storage.secretGetItem).mockImplementationOnce(() => pendingSecret.promise); + setPersisted( + JSON.stringify({ + version: 1, + customCatalogs: [ + { + id: CUSTOM_ID, + name: "Reloaded", + url: "https://other.test/opds", + enabled: true, + auth: "basic", + username: "other-reader", + }, + ], + hiddenBuiltInIds: [], + }), + ); + + const reading = store.getCredentials(CUSTOM_ID); + await vi.waitFor(() => expect(storage.secretGetItem).toHaveBeenCalled()); + await store.load(); + pendingSecret.resolve("old-password"); + + await expect(reading).resolves.toBeUndefined(); + expect(store.getCatalog(CUSTOM_ID)).toMatchObject({ + url: "https://other.test/opds", + passwordStorage: "none", + }); + }); +}); diff --git a/packages/core/src/opds/opds-catalog-store.ts b/packages/core/src/opds/opds-catalog-store.ts new file mode 100644 index 000000000..e63eab2f8 --- /dev/null +++ b/packages/core/src/opds/opds-catalog-store.ts @@ -0,0 +1,763 @@ +import type { IPlatformService } from "../services/platform"; +import { generateId } from "../utils/generate-id"; +import { classifyOpdsUrl } from "./opds-security"; +import type { OpdsCredentials } from "./opds-types"; + +export const OPDS_CATALOG_STORAGE_KEY = "opds.catalogs.v1"; + +export type OpdsCatalogAuth = "anonymous" | "basic"; +export type OpdsPasswordStorage = "none" | "persistent" | "session-only"; + +export interface OpdsCatalog { + readonly id: string; + readonly name: string; + readonly url: string; + readonly enabled: boolean; + readonly builtIn: boolean; + readonly hidden: boolean; + readonly auth: OpdsCatalogAuth; + readonly username?: string; + readonly passwordStorage: OpdsPasswordStorage; +} + +export interface OpdsCatalogInput { + name: string; + url: string; + enabled?: boolean; + auth: OpdsCatalogAuth; + username?: string; + password?: string; +} + +export interface OpdsCatalogUpdate { + name?: string; + url?: string; + enabled?: boolean; + auth?: OpdsCatalogAuth; + username?: string; + password?: string; +} + +export type OpdsCatalogStorage = Pick< + IPlatformService, + "kvGetItem" | "kvSetItem" | "secretGetItem" | "secretSetItem" | "secretRemoveItem" +>; + +interface BuiltInCatalogDefinition { + readonly id: "gutenberg" | "gutenberg-zh"; + readonly name: string; + readonly url: string; +} + +export const OPDS_BUILT_IN_CATALOGS: readonly BuiltInCatalogDefinition[] = Object.freeze([ + Object.freeze({ + id: "gutenberg", + name: "Project Gutenberg", + url: "https://www.gutenberg.org/ebooks/search.opds/", + }), + Object.freeze({ + id: "gutenberg-zh", + name: "Project Gutenberg — Chinese Books", + url: "https://www.gutenberg.org/ebooks/search.opds/?query=l.zh", + }), +]); + +interface CustomCatalogDefinition { + id: string; + name: string; + url: string; + enabled: boolean; + auth: OpdsCatalogAuth; + username?: string; +} + +interface PersistedCatalogsV1 { + version: 1; + customCatalogs: CustomCatalogDefinition[]; + hiddenBuiltInIds: string[]; + pendingSecretCleanups?: PendingSecretCleanup[]; +} + +interface PendingSecretCleanup { + id: string; + revision: number; + action: "remove-secret"; +} + +const CUSTOM_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const builtInIds = new Set(OPDS_BUILT_IN_CATALOGS.map(({ id }) => id)); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeName(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const name = value.trim(); + return name.length > 0 ? name : undefined; +} + +function normalizeUrl(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const classification = classifyOpdsUrl(value); + if (!classification.allowed) return undefined; + try { + return new URL(value).href; + } catch { + return 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 : ""; + const name = normalizeName(value.name); + const url = normalizeUrl(value.url); + const auth = value.auth; + if ( + !CUSTOM_ID_PATTERN.test(id) || + builtInIds.has(id) || + !name || + !url || + typeof value.enabled !== "boolean" || + (auth !== "anonymous" && auth !== "basic") + ) { + return undefined; + } + + const username = typeof value.username === "string" ? value.username : undefined; + return { + id, + name, + url, + enabled: value.enabled, + auth, + ...(auth === "basic" && username !== undefined ? { username } : {}), + }; +} + +function normalizePendingSecretCleanup(value: unknown): PendingSecretCleanup | undefined { + if (!isRecord(value)) return undefined; + if (typeof value.id !== "string" || !CUSTOM_ID_PATTERN.test(value.id)) { + return undefined; + } + const validMarker = + Number.isSafeInteger(value.revision) && + (value.revision as number) >= 0 && + value.action === "remove-secret"; + return { + id: value.id, + revision: validMarker ? (value.revision as number) : 0, + action: "remove-secret", + }; +} + +function assertCatalogInputShape( + value: unknown, + options: { requireDefinition: boolean }, +): asserts value is OpdsCatalogInput | OpdsCatalogUpdate { + if (!isRecord(value)) throw new Error("Catalog input is invalid"); + if ( + (options.requireDefinition && + (typeof value.name !== "string" || + typeof value.url !== "string" || + (value.auth !== "anonymous" && value.auth !== "basic"))) || + (!options.requireDefinition && value.name !== undefined && typeof value.name !== "string") || + (!options.requireDefinition && value.url !== undefined && typeof value.url !== "string") || + (!options.requireDefinition && + value.auth !== undefined && + value.auth !== "anonymous" && + value.auth !== "basic") || + (value.enabled !== undefined && typeof value.enabled !== "boolean") || + (value.username !== undefined && typeof value.username !== "string") || + (value.password !== undefined && typeof value.password !== "string") + ) { + throw new Error("Catalog input is invalid"); + } +} + +export function opdsCatalogSecretKey(catalogId: string): string { + if (!CUSTOM_ID_PATTERN.test(catalogId)) { + throw new Error("Invalid custom catalog id"); + } + return `opds.catalog.${catalogId}.password`; +} + +export class OpdsCatalogStore { + private customCatalogs = new Map(); + private hiddenBuiltInIds = new Set(); + private readonly sessionPasswords = new Map(); + private readonly passwordStorage = new Map>(); + private readonly blockedPersistentPasswords = new Set(); + private pendingSecretCleanups = new Map(); + private mutationQueue: Promise = Promise.resolve(); + + constructor( + private readonly storage: OpdsCatalogStorage, + private readonly createId: () => string = generateId, + ) {} + + async load(): Promise { + return this.enqueueMutation(() => this.loadInternal()); + } + + private async loadInternal(): Promise { + const raw = await this.storage.kvGetItem(OPDS_CATALOG_STORAGE_KEY); + if (!raw) { + this.replaceState(new Map(), new Set(), new Map(), { clearPasswords: true }); + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("Catalog storage is invalid"); + } + if (!isRecord(parsed) || parsed.version !== 1) { + throw new Error("Catalog storage is invalid"); + } + + const nextCustomCatalogs = new Map(); + const nextHiddenBuiltInIds = new Set(); + const nextPendingSecretCleanups = new Map(); + + if (Array.isArray(parsed.hiddenBuiltInIds)) { + for (const id of parsed.hiddenBuiltInIds) { + if (typeof id === "string" && builtInIds.has(id)) nextHiddenBuiltInIds.add(id); + } + } + + if (Array.isArray(parsed.customCatalogs)) { + for (const value of parsed.customCatalogs) { + const catalog = normalizeCustomCatalog(value); + if (catalog && !nextCustomCatalogs.has(catalog.id)) { + nextCustomCatalogs.set(catalog.id, catalog); + } + } + } + if ( + parsed.pendingSecretCleanups !== undefined && + !Array.isArray(parsed.pendingSecretCleanups) + ) { + throw new Error("Catalog cleanup storage is invalid"); + } + if (Array.isArray(parsed.pendingSecretCleanups)) { + for (const value of parsed.pendingSecretCleanups) { + const cleanup = normalizePendingSecretCleanup(value); + const existing = cleanup ? nextPendingSecretCleanups.get(cleanup.id) : undefined; + if (cleanup && (!existing || cleanup.revision > existing.revision)) { + nextPendingSecretCleanups.set(cleanup.id, cleanup); + } + } + } + // Rewrite the validated projection so unknown or malicious fields do not remain in general KV. + await this.persistState(nextCustomCatalogs, nextHiddenBuiltInIds, nextPendingSecretCleanups); + this.replaceState(nextCustomCatalogs, nextHiddenBuiltInIds, nextPendingSecretCleanups, { + clearPasswords: true, + }); + for (const id of [...nextPendingSecretCleanups.keys()]) { + await this.retryPendingSecretCleanup(id); + } + } + + listCatalogs(options: { includeHidden?: boolean } = {}): OpdsCatalog[] { + const builtIns = OPDS_BUILT_IN_CATALOGS.map((definition) => + this.toBuiltInCatalog(definition), + ).filter((catalog) => options.includeHidden || !catalog.hidden); + const custom = Array.from(this.customCatalogs.values(), (definition) => + this.toCustomCatalog(definition), + ); + return [...builtIns, ...custom]; + } + + getCatalog(id: string): OpdsCatalog | undefined { + const builtIn = OPDS_BUILT_IN_CATALOGS.find((catalog) => catalog.id === id); + if (builtIn) return this.toBuiltInCatalog(builtIn); + const custom = this.customCatalogs.get(id); + return custom ? this.toCustomCatalog(custom) : undefined; + } + + async addCatalog(input: OpdsCatalogInput): Promise { + assertCatalogInputShape(input, { requireDefinition: true }); + if (input.auth === "anonymous" && input.password !== undefined) { + throw new Error("Anonymous catalogs cannot have a password"); + } + return this.enqueueMutation(async () => { + const id = this.createId(); + if (this.pendingSecretCleanups.has(id)) { + await this.retryPendingSecretCleanup(id); + } + if (!CUSTOM_ID_PATTERN.test(id) || builtInIds.has(id) || this.customCatalogs.has(id)) { + throw new Error("Could not generate a unique catalog id"); + } + if (this.pendingSecretCleanups.has(id)) { + throw new Error("Could not generate a unique catalog id"); + } + const catalog = this.catalogFromInput(id, input); + const nextCatalogs = new Map(this.customCatalogs).set(id, catalog); + await this.persistState(nextCatalogs, this.hiddenBuiltInIds, this.pendingSecretCleanups); + if (input.password) await this.storePassword(id, input.password); + this.customCatalogs = nextCatalogs; + return this.toCustomCatalog(catalog); + }); + } + + async updateCatalog(id: string, update: OpdsCatalogUpdate): Promise { + assertCatalogInputShape(update, { requireDefinition: false }); + return this.enqueueMutation(async () => { + if (builtInIds.has(id)) throw new Error("Built-in catalogs cannot be edited"); + if (this.pendingSecretCleanups.has(id) && this.hasCompleteSecretStorage()) { + await this.requirePendingSecretCleanupResolved(id); + } + const current = this.customCatalogs.get(id); + if (!current) throw new Error("Catalog not found"); + + const next = this.catalogFromInput(id, { + name: update.name ?? current.name, + url: update.url ?? current.url, + enabled: update.enabled ?? current.enabled, + auth: update.auth ?? current.auth, + username: update.username ?? current.username, + }); + if (next.auth === "anonymous" && update.password !== undefined) { + throw new Error("Anonymous catalogs cannot have a password"); + } + 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 + ? await this.readPersistentPassword(id) + : undefined; + const previousCatalogs = this.customCatalogs; + const nextCatalogs = new Map(previousCatalogs).set(id, next); + const previousPendingSecretCleanups = this.pendingSecretCleanups; + const nextPendingSecretCleanups = + credentialChanged && !cleanupAlreadyPending + ? this.withPendingSecretCleanup(id) + : previousPendingSecretCleanups; + await this.persistState(nextCatalogs, this.hiddenBuiltInIds, nextPendingSecretCleanups); + this.customCatalogs = nextCatalogs; + this.pendingSecretCleanups = nextPendingSecretCleanups; + + if (credentialChanged) { + if (cleanupAlreadyPending || !this.hasCompleteSecretStorage()) { + this.clearPasswordState(id); + if (next.auth === "basic" && update.password) { + this.storeSessionPassword(id, update.password); + } + return this.toCustomCatalog(next); + } + try { + await this.removePersistentPassword(id); + } catch (error) { + await this.compensateSecretMutationFailure( + id, + previousPersistentPassword, + previousCatalogs, + this.hiddenBuiltInIds, + previousPendingSecretCleanups, + nextCatalogs, + nextPendingSecretCleanups, + error, + "Catalog update", + () => { + this.clearPasswordState(id); + if (next.auth === "basic" && update.password) { + this.sessionPasswords.set(id, update.password); + this.passwordStorage.set(id, "session-only"); + } else { + this.blockedPersistentPasswords.add(id); + } + }, + ); + } + await this.clearPendingSecretCleanup(id, nextCatalogs); + this.clearPasswordState(id); + if (next.auth === "basic" && update.password) { + await this.storePassword(id, update.password); + } + } + + return this.toCustomCatalog(next); + }); + } + + async setCatalogEnabled(id: string, enabled: boolean): Promise { + return this.updateCatalog(id, { enabled }); + } + + async removeCatalog(id: string): Promise { + return this.enqueueMutation(async () => { + if (builtInIds.has(id)) throw new Error("Built-in catalogs cannot be deleted"); + if (this.pendingSecretCleanups.has(id) && this.hasCompleteSecretStorage()) { + await this.requirePendingSecretCleanupResolved(id); + } + if (!this.customCatalogs.has(id)) return false; + const cleanupAlreadyPending = this.pendingSecretCleanups.has(id); + const previousPersistentPassword = cleanupAlreadyPending + ? undefined + : await this.readPersistentPassword(id); + const previousCatalogs = this.customCatalogs; + const nextCatalogs = new Map(previousCatalogs); + nextCatalogs.delete(id); + const previousPendingSecretCleanups = this.pendingSecretCleanups; + const nextPendingSecretCleanups = cleanupAlreadyPending + ? previousPendingSecretCleanups + : this.withPendingSecretCleanup(id); + await this.persistState(nextCatalogs, this.hiddenBuiltInIds, nextPendingSecretCleanups); + this.customCatalogs = nextCatalogs; + this.pendingSecretCleanups = nextPendingSecretCleanups; + if (cleanupAlreadyPending || !this.hasCompleteSecretStorage()) { + this.clearPasswordState(id); + return true; + } + try { + await this.removePersistentPassword(id); + } catch (error) { + await this.compensateSecretMutationFailure( + id, + previousPersistentPassword, + previousCatalogs, + this.hiddenBuiltInIds, + previousPendingSecretCleanups, + nextCatalogs, + nextPendingSecretCleanups, + error, + "Catalog removal", + () => { + this.clearPasswordState(id); + }, + ); + } + await this.clearPendingSecretCleanup(id, nextCatalogs); + this.clearPasswordState(id); + return true; + }); + } + + async hideBuiltIn(id: string): Promise { + return this.enqueueMutation(async () => { + this.requireBuiltIn(id); + const nextHiddenBuiltInIds = new Set(this.hiddenBuiltInIds).add(id); + await this.persistState( + this.customCatalogs, + nextHiddenBuiltInIds, + this.pendingSecretCleanups, + ); + this.hiddenBuiltInIds = nextHiddenBuiltInIds; + }); + } + + async restoreBuiltIn(id: string): Promise { + return this.enqueueMutation(async () => { + this.requireBuiltIn(id); + const nextHiddenBuiltInIds = new Set(this.hiddenBuiltInIds); + nextHiddenBuiltInIds.delete(id); + await this.persistState( + this.customCatalogs, + nextHiddenBuiltInIds, + this.pendingSecretCleanups, + ); + this.hiddenBuiltInIds = nextHiddenBuiltInIds; + }); + } + + async getCredentials(id: string): Promise { + const catalog = this.customCatalogs.get(id); + if (!catalog || catalog.auth !== "basic") return undefined; + + let password = this.sessionPasswords.get(id); + const { secretGetItem, secretSetItem, secretRemoveItem } = this.storage; + if ( + !password && + !this.pendingSecretCleanups.has(id) && + !this.blockedPersistentPasswords.has(id) && + secretGetItem && + secretSetItem && + secretRemoveItem + ) { + try { + password = (await secretGetItem(opdsCatalogSecretKey(id))) ?? undefined; + if ( + this.customCatalogs.get(id) !== catalog || + this.pendingSecretCleanups.has(id) || + this.blockedPersistentPasswords.has(id) + ) { + return undefined; + } + if (password) this.passwordStorage.set(id, "persistent"); + } catch { + password = undefined; + } + } + if (!password) return undefined; + return { + username: catalog.username ?? "", + password, + catalogOrigin: new URL(catalog.url).origin, + }; + } + + private catalogFromInput( + id: string, + input: Omit, + ): CustomCatalogDefinition { + const name = normalizeName(input.name); + const url = normalizeUrl(input.url); + if (!name) throw new Error("Catalog name is required"); + if (!url) throw new Error("Catalog URL is not allowed"); + if (input.auth !== "anonymous" && input.auth !== "basic") { + throw new Error("Catalog authentication type is invalid"); + } + return { + id, + name, + url, + enabled: input.enabled ?? true, + auth: input.auth, + ...(input.auth === "basic" ? { username: input.username ?? "" } : {}), + }; + } + + private async storePassword(id: string, password: string): Promise { + const { secretGetItem, secretSetItem, secretRemoveItem } = this.storage; + if (secretGetItem && secretSetItem && secretRemoveItem) { + try { + await secretSetItem(opdsCatalogSecretKey(id), password); + this.sessionPasswords.delete(id); + this.passwordStorage.set(id, "persistent"); + this.blockedPersistentPasswords.delete(id); + return; + } catch { + // A secret backend failure intentionally degrades to an explicit in-memory session secret. + } + } + this.storeSessionPassword(id, password); + } + + private storeSessionPassword(id: string, password: string): void { + this.sessionPasswords.set(id, password); + this.passwordStorage.set(id, "session-only"); + this.blockedPersistentPasswords.delete(id); + } + + private async removePersistentPassword(id: string): Promise { + if (this.storage.secretRemoveItem) { + await this.storage.secretRemoveItem(opdsCatalogSecretKey(id)); + } + } + + private async readPersistentPassword(id: string): Promise { + const { secretGetItem, secretSetItem, secretRemoveItem } = this.storage; + if ( + this.blockedPersistentPasswords.has(id) || + !secretGetItem || + !secretSetItem || + !secretRemoveItem + ) { + return undefined; + } + return secretGetItem(opdsCatalogSecretKey(id)); + } + + private clearPasswordState(id: string): void { + this.sessionPasswords.delete(id); + this.passwordStorage.delete(id); + this.blockedPersistentPasswords.delete(id); + } + + private async compensateSecretMutationFailure( + id: string, + previousPersistentPassword: string | null | undefined, + customCatalogs: ReadonlyMap, + hiddenBuiltInIds: ReadonlySet, + previousPendingSecretCleanups: ReadonlyMap, + nextCatalogs: ReadonlyMap, + nextPendingSecretCleanups: ReadonlyMap, + originalError: unknown, + operation: string, + onRollbackFailure: () => void, + ): Promise { + let compensationFailed = false; + if (previousPersistentPassword !== undefined && previousPersistentPassword !== null) { + try { + await this.storage.secretSetItem?.(opdsCatalogSecretKey(id), previousPersistentPassword); + } catch { + compensationFailed = true; + } + } + try { + await this.persistState(customCatalogs, hiddenBuiltInIds, previousPendingSecretCleanups); + } catch { + try { + await this.removePersistentPassword(id); + await this.clearPendingSecretCleanup(id, nextCatalogs); + } catch { + this.pendingSecretCleanups = new Map(nextPendingSecretCleanups); + } + onRollbackFailure(); + throw new Error(`${operation} failed and rollback failed`); + } + this.customCatalogs = new Map(customCatalogs); + this.pendingSecretCleanups = new Map(previousPendingSecretCleanups); + if (compensationFailed) { + this.sessionPasswords.set(id, previousPersistentPassword ?? ""); + this.passwordStorage.set(id, "session-only"); + this.blockedPersistentPasswords.add(id); + throw new Error(`${operation} failed and secret compensation failed`); + } + throw originalError; + } + + private async persistState( + customCatalogs: ReadonlyMap, + hiddenBuiltInIds: ReadonlySet, + pendingSecretCleanups: ReadonlyMap, + ): Promise { + const value: PersistedCatalogsV1 = { + version: 1, + customCatalogs: Array.from(customCatalogs.values(), (catalog) => ({ ...catalog })), + hiddenBuiltInIds: Array.from(hiddenBuiltInIds), + ...(pendingSecretCleanups.size > 0 + ? { pendingSecretCleanups: Array.from(pendingSecretCleanups.values()) } + : {}), + }; + await this.storage.kvSetItem(OPDS_CATALOG_STORAGE_KEY, JSON.stringify(value)); + } + + private replaceState( + customCatalogs: ReadonlyMap, + hiddenBuiltInIds: ReadonlySet, + pendingSecretCleanups: ReadonlyMap, + options: { clearPasswords: boolean }, + ): void { + this.customCatalogs = new Map(customCatalogs); + this.hiddenBuiltInIds = new Set(hiddenBuiltInIds); + this.pendingSecretCleanups = new Map(pendingSecretCleanups); + if (options.clearPasswords) { + this.sessionPasswords.clear(); + this.passwordStorage.clear(); + this.blockedPersistentPasswords.clear(); + } + } + + private enqueueMutation(operation: () => Promise): Promise { + const result = this.mutationQueue.then(operation, operation); + this.mutationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + private hasCompleteSecretStorage(): boolean { + return Boolean( + this.storage.secretGetItem && this.storage.secretSetItem && this.storage.secretRemoveItem, + ); + } + + private withPendingSecretCleanup(id: string): Map { + const currentRevision = Math.max( + 0, + ...Array.from(this.pendingSecretCleanups.values(), ({ revision }) => revision), + ); + if (currentRevision >= Number.MAX_SAFE_INTEGER) { + throw new Error("Catalog cleanup revision is exhausted"); + } + const pending = new Map(this.pendingSecretCleanups); + pending.set(id, { + id, + revision: currentRevision + 1, + action: "remove-secret", + }); + return pending; + } + + private async clearPendingSecretCleanup( + id: string, + customCatalogs: ReadonlyMap, + ): Promise { + if (!this.pendingSecretCleanups.has(id)) return; + const pending = new Map(this.pendingSecretCleanups); + pending.delete(id); + await this.persistState(customCatalogs, this.hiddenBuiltInIds, pending); + this.pendingSecretCleanups = pending; + this.blockedPersistentPasswords.delete(id); + } + + private async retryPendingSecretCleanup(id: string): Promise { + if (!this.pendingSecretCleanups.has(id)) return true; + if (!this.hasCompleteSecretStorage()) return false; + try { + await this.removePersistentPassword(id); + } catch { + return false; + } + await this.clearPendingSecretCleanup(id, this.customCatalogs); + return true; + } + + private async requirePendingSecretCleanupResolved(id: string): Promise { + if (!this.pendingSecretCleanups.has(id)) return; + const resolved = await this.retryPendingSecretCleanup(id); + if (!resolved) throw new Error("Pending catalog secret cleanup failed"); + } + + private requireBuiltIn(id: string): void { + if (!builtInIds.has(id)) throw new Error("Built-in catalog not found"); + } + + private toBuiltInCatalog(definition: BuiltInCatalogDefinition): OpdsCatalog { + return { + ...definition, + enabled: true, + builtIn: true, + hidden: this.hiddenBuiltInIds.has(definition.id), + auth: "anonymous", + passwordStorage: "none", + }; + } + + private toCustomCatalog(definition: CustomCatalogDefinition): OpdsCatalog { + const storage = this.passwordStorage.get(definition.id); + return { + ...definition, + builtIn: false, + hidden: false, + passwordStorage: + this.pendingSecretCleanups.has(definition.id) && storage !== "session-only" + ? "none" + : (storage ?? "none"), + }; + } +} diff --git a/packages/core/src/opds/opds-client.test.ts b/packages/core/src/opds/opds-client.test.ts new file mode 100644 index 000000000..ec23d11b4 --- /dev/null +++ b/packages/core/src/opds/opds-client.test.ts @@ -0,0 +1,1157 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { FetchOptions } from "../services/platform"; +import { + type OpdsAssetResponse, + OpdsClient, + type OpdsCredentials, + type OpdsError, +} from "./opds-client"; + +const ATOM = ` + + Catalog + + Book + + +`; + +const OPENSEARCH = ` + + Catalog search + +`; + +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", + catalogOrigin: "https://catalog.test", +}; + +interface FetchCall { + url: string; + options?: FetchOptions; +} + +function response( + body: string, + init: { status?: number; headers?: Record } = {}, +): Response { + return new Response(body, { + status: init.status ?? 200, + headers: init.headers ?? { "Content-Type": "application/atom+xml" }, + }); +} + +function fakePlatform( + handler: ( + url: string, + options: FetchOptions | undefined, + call: number, + ) => Promise | Response, +): { fetch: (url: string, options?: FetchOptions) => Promise; calls: FetchCall[] } { + const calls: FetchCall[] = []; + return { + calls, + async fetch(url, options) { + calls.push({ url, options }); + return handler(url, options, calls.length); + }, + }; +} + +function authorization(call: FetchCall): string | null { + return new Headers(call.options?.headers).get("Authorization"); +} + +function transportResponse(response: Response) { + const cancelTransport = vi.fn(); + const onDispose = vi.fn(); + Object.assign(response, { cancelTransport, onDispose }); + return { response, cancelTransport, onDispose }; +} + +function scheduleCancelAfterReadNext(asset: OpdsAssetResponse): void { + const managed = asset as unknown as { + readNext( + reader: ReadableStreamDefaultReader, + ): Promise>; + }; + const readNext = managed.readNext.bind(managed); + managed.readNext = async (reader) => { + const result = await readNext(reader); + queueMicrotask(() => { + void asset.cancel(); + }); + return result; + }; +} + +function stalledBodyResponse(contentType = "application/atom+xml") { + let startReading: (() => void) | undefined; + let cancelled = false; + const readingStarted = new Promise((resolve) => { + startReading = resolve; + }); + const body = new ReadableStream({ + pull() { + startReading?.(); + return new Promise(() => {}); + }, + cancel() { + cancelled = true; + }, + }); + return { + response: new Response(body, { headers: { "Content-Type": contentType } }), + readingStarted, + wasCancelled: () => cancelled, + }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +async function expectOpdsError(promise: Promise, code: OpdsError["code"]): Promise { + await expect(promise).rejects.toMatchObject({ name: "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); + + await client.open("https://catalog.test/root/feed.xml", credentials); + await client.open("https://other.test/feed.xml", credentials); + + expect(authorization(platform.calls[0] as FetchCall)).toBe( + "Basic cmVhZGVyOnNlY3JldC1wYXNzd29yZA==", + ); + expect(authorization(platform.calls[1] as FetchCall)).toBeNull(); + }); + + it("does not send Authorization for anonymous requests", async () => { + const platform = fakePlatform(() => response(ATOM)); + + await new OpdsClient(platform).open("https://catalog.test/feed.xml"); + + expect(authorization(platform.calls[0] as FetchCall)).toBeNull(); + }); + + it("uses manual redirects and strips Authorization after a cross-origin redirect", async () => { + const redirect = transportResponse( + response("", { status: 302, headers: { Location: "https://cdn.test/feed.xml" } }), + ); + const destination = transportResponse(response(ATOM)); + const platform = fakePlatform((url) => + url === "https://catalog.test/feed.xml" ? redirect.response : destination.response, + ); + + await new OpdsClient(platform).open("https://catalog.test/feed.xml", credentials); + + expect(platform.calls.map((call) => call.url)).toEqual([ + "https://catalog.test/feed.xml", + "https://cdn.test/feed.xml", + ]); + expect(authorization(platform.calls[0] as FetchCall)).not.toBeNull(); + expect(authorization(platform.calls[1] as FetchCall)).toBeNull(); + expect(platform.calls.every((call) => call.options?.redirect === "manual")).toBe(true); + expect(redirect.cancelTransport).toHaveBeenCalledOnce(); + expect(redirect.onDispose).toHaveBeenCalledOnce(); + expect(destination.cancelTransport).not.toHaveBeenCalled(); + expect(destination.onDispose).toHaveBeenCalledOnce(); + }); + + it("rejects HTTPS-to-HTTP redirect downgrades before making the target request", async () => { + const platform = fakePlatform(() => + response("", { status: 302, headers: { Location: "http://127.0.0.1/feed.xml" } }), + ); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml", credentials), + "insecure-url", + ); + expect(platform.calls).toHaveLength(1); + }); + + it("reclassifies redirect targets and rejects embedded credentials", async () => { + const platform = fakePlatform(() => + response("", { + status: 302, + headers: { Location: "https://reader:secret-password@catalog.test/private" }, + }), + ); + + const request = new OpdsClient(platform).open("https://catalog.test/feed.xml", credentials); + + await expectOpdsError(request, "insecure-url"); + await expect(request).rejects.not.toThrow("secret-password"); + expect(platform.calls).toHaveLength(1); + }); + + it("follows no more than five redirects", async () => { + const platform = fakePlatform((_url, _options, call) => + response("", { status: 302, headers: { Location: `/redirect-${call}` } }), + ); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml"), + "invalid-catalog", + ); + expect(platform.calls).toHaveLength(6); + }); + + it("maps Basic challenges to unauthorized", async () => { + const platform = fakePlatform(() => + response("", { status: 401, headers: { "WWW-Authenticate": 'Basic realm="Books"' } }), + ); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml", credentials), + "unauthorized", + ); + }); + + it("maps a 401 without a challenge to unauthorized", async () => { + const platform = fakePlatform(() => response("", { status: 401 })); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml"), + "unauthorized", + ); + }); + + it("maps unsupported authentication challenges separately", async () => { + const unauthorized = transportResponse( + response("", { status: 401, headers: { "WWW-Authenticate": 'Digest realm="Books"' } }), + ); + const platform = fakePlatform(() => unauthorized.response); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml", credentials), + "unsupported-auth", + ); + expect(unauthorized.cancelTransport).toHaveBeenCalledOnce(); + expect(unauthorized.onDispose).toHaveBeenCalledOnce(); + }); + + it("passes the 15 second timeout through the platform and maps timeout failures", async () => { + const platform = fakePlatform(() => Promise.reject(new Error("request timed out"))); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml"), + "unreachable", + ); + expect(platform.calls[0]?.options?.timeoutMs).toBe(15_000); + }); + + it("keeps the 15 second timeout active while a catalog body is stalled", async () => { + vi.useFakeTimers(); + const stalled = stalledBodyResponse(); + const platform = fakePlatform(() => stalled.response); + let rejection: unknown; + const request = new OpdsClient(platform) + .open("https://catalog.test/feed.xml") + .catch((error: unknown) => { + rejection = error; + }); + await stalled.readingStarted; + + await vi.advanceTimersByTimeAsync(15_000); + await Promise.resolve(); + + expect(rejection).toMatchObject({ name: "OpdsError", code: "unreachable" }); + expect(stalled.wasCancelled()).toBe(true); + expect(vi.getTimerCount()).toBe(0); + await request; + }); + + it("keeps user cancellation active while a catalog body is stalled", async () => { + const stalled = stalledBodyResponse(); + const platform = fakePlatform(() => stalled.response); + const controller = new AbortController(); + const request = new OpdsClient(platform).open( + "https://catalog.test/feed.xml", + undefined, + controller.signal, + ); + await stalled.readingStarted; + + controller.abort(); + + const outcome = await Promise.race([ + request.then( + () => ({ kind: "resolved" as const }), + (error: unknown) => ({ kind: "rejected" as const, error }), + ), + new Promise<{ kind: "pending" }>((resolve) => + setTimeout(() => resolve({ kind: "pending" }), 0), + ), + ]); + expect(outcome).toMatchObject({ + kind: "rejected", + error: { name: "OpdsError", code: "cancelled" }, + }); + expect(stalled.wasCancelled()).toBe(true); + }); + + it("removes its abort listener and timer after body completion", async () => { + vi.useFakeTimers(); + const platform = fakePlatform(() => response(ATOM)); + const controller = new AbortController(); + const removeListener = vi.spyOn(controller.signal, "removeEventListener"); + + await new OpdsClient(platform).open( + "https://catalog.test/feed.xml", + undefined, + controller.signal, + ); + + expect(removeListener).toHaveBeenCalledWith("abort", expect.any(Function)); + expect(vi.getTimerCount()).toBe(0); + }); + + it("rejects a pre-cancelled request without starting network work", async () => { + const platform = fakePlatform(() => response(ATOM)); + const controller = new AbortController(); + controller.abort(); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml", undefined, controller.signal), + "cancelled", + ); + expect(platform.calls).toHaveLength(0); + }); + + it("maps cancellation during platform fetch without exposing its error", async () => { + const controller = new AbortController(); + const platform = fakePlatform( + (_url, options) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener("abort", () => reject(new Error("secret-password"))); + }), + ); + const request = new OpdsClient(platform).open( + "https://catalog.test/feed.xml", + credentials, + controller.signal, + ); + + controller.abort(); + + await expectOpdsError(request, "cancelled"); + await expect(request).rejects.not.toThrow("secret-password"); + }); + + it("rejects oversized catalogs from Content-Length without reading the body", async () => { + let cancelled = false; + const oversized = new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { + headers: { "Content-Type": "application/atom+xml", "Content-Length": "5242881" }, + }, + ); + const transport = transportResponse(oversized); + const platform = fakePlatform(() => transport.response); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml"), + "too-large", + ); + expect(cancelled).toBe(true); + expect(transport.cancelTransport).toHaveBeenCalledOnce(); + expect(transport.onDispose).toHaveBeenCalledOnce(); + }); + + it("rejects oversized decoded catalog text", async () => { + const platform = fakePlatform(() => + response("x".repeat(5 * 1024 * 1024 + 1), { + headers: { "Content-Type": "application/atom+xml" }, + }), + ); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml"), + "too-large", + ); + }); + + it("stops reading a streamed catalog as soon as it exceeds the size limit", async () => { + let reads = 0; + let cancelled = false; + const oversized = response(ATOM); + Object.defineProperty(oversized, "body", { + value: { + getReader: () => ({ + async read() { + reads += 1; + if (reads <= 2) return { done: false, value: new Uint8Array(3 * 1024 * 1024) }; + throw new Error("read beyond the limit"); + }, + async cancel() { + cancelled = true; + }, + }), + }, + }); + Object.defineProperty(oversized, "text", { + value: async () => { + throw new Error("text fallback used"); + }, + }); + const transport = transportResponse(oversized); + const platform = fakePlatform(() => transport.response); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml"), + "too-large", + ); + expect(reads).toBe(2); + expect(cancelled).toBe(true); + expect(transport.cancelTransport).toHaveBeenCalledOnce(); + expect(transport.onDispose).toHaveBeenCalledOnce(); + }); + + it("rejects unsupported content types even when the body looks like OPDS", async () => { + const platform = fakePlatform(() => + response(ATOM, { headers: { "Content-Type": "text/html" } }), + ); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml"), + "invalid-catalog", + ); + }); + + it("cancels the response stream when the content type is unsupported", async () => { + let cancelled = false; + const invalid = new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { headers: { "Content-Type": "text/html" } }, + ); + const transport = transportResponse(invalid); + const platform = fakePlatform(() => transport.response); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml"), + "invalid-catalog", + ); + expect(cancelled).toBe(true); + expect(transport.cancelTransport).toHaveBeenCalledOnce(); + expect(transport.onDispose).toHaveBeenCalledOnce(); + }); + + it("maps malformed supported content to invalid-catalog", async () => { + const platform = fakePlatform(() => response("")); + + await expectOpdsError( + new OpdsClient(platform).open("https://catalog.test/feed.xml"), + "invalid-catalog", + ); + }); + + it("does not expose passwords from network failures", async () => { + const platform = fakePlatform(() => Promise.reject(new Error("secret-password"))); + const request = new OpdsClient(platform).open("https://catalog.test/feed.xml", credentials); + + await expectOpdsError(request, "unreachable"); + await expect(request).rejects.not.toThrow("secret-password"); + }); + + it("does not expose passwords from response body failures", async () => { + const unreadable = response(ATOM); + Object.defineProperty(unreadable, "body", { value: null }); + Object.defineProperty(unreadable, "text", { + value: async () => { + throw new Error("secret-password"); + }, + }); + const platform = fakePlatform(() => unreadable); + const request = new OpdsClient(platform).open("https://catalog.test/feed.xml", credentials); + + await expectOpdsError(request, "unreachable"); + await expect(request).rejects.not.toThrow("secret-password"); + }); +}); + +describe("OpdsClient assets", () => { + it.each([ + ["EOF", { done: true as const, value: undefined }], + ["a chunk", { done: false as const, value: Uint8Array.of(7, 8, 9) }], + ])( + "rejects when cancellation lands after readNext fulfills with %s but before pull continues", + async (_label, outcome) => { + const nativeReader = { + read: vi.fn(async () => outcome), + cancel: vi.fn(async () => {}), + }; + const source = response("unused", { headers: {} }); + Object.defineProperty(source, "body", { + value: { getReader: () => nativeReader }, + }); + const transport = transportResponse(source); + const platform = fakePlatform(() => transport.response); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + ); + scheduleCancelAfterReadNext(asset); + const reader = asset.body?.getReader(); + if (!reader) throw new Error("Expected a managed asset body"); + + await expectOpdsError(reader.read(), "cancelled"); + await asset.cancel(); + + expect(nativeReader.cancel).toHaveBeenCalledOnce(); + expect(transport.cancelTransport).toHaveBeenCalledOnce(); + expect(transport.onDispose).toHaveBeenCalledOnce(); + }, + ); + + it("returns exact binary bytes and metadata without constructing an ambient Response", async () => { + const bytes = Uint8Array.of(0, 255, 16, 128, 42); + const source = new Response(bytes, { headers: { "Content-Type": "image/jpeg" } }); + Object.defineProperties(source, { + url: { value: "https://cdn.test/final-cover.jpg" }, + redirected: { value: true }, + type: { value: "cors" }, + }); + const platform = fakePlatform(() => source); + vi.stubGlobal( + "Response", + class NonStreamingWhatwgResponse { + constructor() { + throw new Error("This React Native Response cannot wrap streams"); + } + }, + ); + + const asset = await new OpdsClient(platform).fetchAsset( + "https://cdn.test/final-cover.jpg", + "https://catalog.test", + ); + + expect(Array.from(new Uint8Array(await asset.arrayBuffer()))).toEqual(Array.from(bytes)); + expect(asset.url).toBe("https://cdn.test/final-cover.jpg"); + expect(asset.redirected).toBe(true); + expect(asset.type).toBe("cors"); + expect(asset.status).toBe(source.status); + expect(asset.headers.get("Content-Type")).toBe("image/jpeg"); + }); + + it("supports text and enforces single body consumption", async () => { + const platform = fakePlatform( + () => + new Response(new TextEncoder().encode("héllo"), { + headers: { "Content-Type": "text/plain" }, + }), + ); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/readme.txt", + "https://catalog.test", + ); + + expect(asset.bodyUsed).toBe(false); + expect(await asset.text()).toBe("héllo"); + expect(asset.bodyUsed).toBe(true); + await expect(asset.arrayBuffer()).rejects.toThrow(TypeError); + }); + + it("supports JSON and preserves JSON parse errors", async () => { + const validPlatform = fakePlatform( + () => + new Response('{"title":"Catalog","count":2}', { + headers: { "Content-Type": "application/json" }, + }), + ); + const valid = await new OpdsClient(validPlatform).fetchAsset( + "https://catalog.test/data.json", + "https://catalog.test", + ); + + await expect(valid.json()).resolves.toEqual({ title: "Catalog", count: 2 }); + expect(valid.bodyUsed).toBe(true); + + const invalidPlatform = fakePlatform( + () => new Response("{bad json", { headers: { "Content-Type": "application/json" } }), + ); + const invalid = await new OpdsClient(invalidPlatform).fetchAsset( + "https://catalog.test/bad.json", + "https://catalog.test", + ); + + await expect(invalid.json()).rejects.toThrow(SyntaxError); + expect(invalid.bodyUsed).toBe(true); + }); + + it("supports Blob when the platform provides Blob", async () => { + const bytes = Uint8Array.of(5, 4, 3, 2, 1); + const platform = fakePlatform( + () => new Response(bytes, { headers: { "Content-Type": "application/octet-stream" } }), + ); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/book.bin", + "https://catalog.test", + ); + + const blob = await asset.blob(); + + expect(blob.type).toBe("application/octet-stream"); + expect(Array.from(new Uint8Array(await blob.arrayBuffer()))).toEqual(Array.from(bytes)); + }); + + it("reports unavailable Blob support without consuming the body", async () => { + const platform = fakePlatform(() => response("asset", { headers: {} })); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/book.bin", + "https://catalog.test", + ); + vi.stubGlobal("Blob", undefined); + + await expect(asset.blob()).rejects.toThrow(TypeError); + expect(asset.bodyUsed).toBe(false); + expect(await asset.text()).toBe("asset"); + }); + + it("completes a normal asset read without aborting its transport", async () => { + const transport = transportResponse(response("asset", { headers: {} })); + const platform = fakePlatform(() => transport.response); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + ); + + expect(await asset.text()).toBe("asset"); + await Promise.all([asset.cancel(), asset.cancel()]); + + expect(transport.cancelTransport).not.toHaveBeenCalled(); + expect(transport.onDispose).toHaveBeenCalledOnce(); + }); + + it("rejects an active multi-chunk read when explicit cancellation resolves the native read as done", async () => { + let reads = 0; + let resolveSecond: ((result: ReadableStreamReadResult) => void) | undefined; + let markSecondStarted: (() => void) | undefined; + const secondStarted = new Promise((resolve) => { + markSecondStarted = resolve; + }); + const nativeReader = { + read: vi.fn(() => { + reads += 1; + if (reads === 1) { + return Promise.resolve({ done: false as const, value: Uint8Array.of(1, 2, 3) }); + } + markSecondStarted?.(); + return new Promise>((resolve) => { + resolveSecond = resolve; + }); + }), + cancel: vi.fn(async () => { + resolveSecond?.({ done: true, value: undefined }); + }), + }; + const source = response("unused", { headers: {} }); + Object.defineProperty(source, "body", { + value: { getReader: () => nativeReader }, + }); + const transport = transportResponse(source); + const platform = fakePlatform(() => transport.response); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + ); + const reading = asset.arrayBuffer(); + await secondStarted; + + const cancels = [asset.cancel(), asset.cancel(), asset.cancel()]; + + await expectOpdsError(reading, "cancelled"); + await Promise.all(cancels); + await asset.cancel(); + await expectOpdsError(asset.text(), "cancelled"); + expect(nativeReader.read).toHaveBeenCalledTimes(2); + expect(nativeReader.cancel).toHaveBeenCalledOnce(); + expect(transport.cancelTransport).toHaveBeenCalledOnce(); + expect(transport.onDispose).toHaveBeenCalledOnce(); + }); + + it("maps a native read rejection caused by explicit cancellation to cancelled", async () => { + let rejectRead: ((error: Error) => void) | undefined; + let markReading: (() => void) | undefined; + const readingStarted = new Promise((resolve) => { + markReading = resolve; + }); + const nativeReader = { + read: vi.fn( + () => + new Promise>((_resolve, reject) => { + rejectRead = reject; + markReading?.(); + }), + ), + cancel: vi.fn(async () => { + rejectRead?.(new Error("native request aborted")); + }), + }; + const source = response("unused", { headers: {} }); + Object.defineProperty(source, "body", { + value: { getReader: () => nativeReader }, + }); + const platform = fakePlatform(() => source); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + ); + const reading = asset.arrayBuffer(); + await readingStarted; + + await asset.cancel(); + + await expectOpdsError(reading, "cancelled"); + }); + + it("makes cancellation before consumption stable and idempotent", async () => { + const nativeReader = { + read: vi.fn(), + cancel: vi.fn(async () => {}), + }; + const source = response("unused", { headers: {} }); + Object.defineProperty(source, "body", { + value: { getReader: () => nativeReader }, + }); + const transport = transportResponse(source); + const platform = fakePlatform(() => transport.response); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + ); + + await Promise.all([asset.cancel(), asset.cancel()]); + + await expectOpdsError(asset.arrayBuffer(), "cancelled"); + expect(nativeReader.read).not.toHaveBeenCalled(); + expect(nativeReader.cancel).toHaveBeenCalledOnce(); + expect(transport.cancelTransport).toHaveBeenCalledOnce(); + }); + + it.each(["arrayBuffer", "text", "json", "blob"] as const)( + "makes future %s consumption reject cancelled", + async (method) => { + const platform = fakePlatform(() => response("asset", { headers: {} })); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + ); + + await asset.cancel(); + + await expectOpdsError(asset[method](), "cancelled"); + }, + ); + + it("aborts the asset transport when its returned body is cancelled", async () => { + const transport = transportResponse(response("asset", { headers: {} })); + const platform = fakePlatform(() => transport.response); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + ); + + await asset.body?.cancel(); + + await expectOpdsError(asset.text(), "cancelled"); + expect(transport.cancelTransport).toHaveBeenCalledOnce(); + expect(transport.onDispose).toHaveBeenCalledOnce(); + }); + + it("rejects mismatched credential and catalog origins without sending a request", async () => { + const platform = fakePlatform(() => response("asset", { headers: {} })); + + await expectOpdsError( + new OpdsClient(platform).fetchAsset( + "https://other.test/cover.jpg", + "https://other.test", + credentials, + ), + "insecure-url", + ); + expect(platform.calls).toHaveLength(0); + }); + + it("sends credentials to cover and acquisition assets only on the exact catalog origin", async () => { + const platform = fakePlatform(() => response("asset", { headers: {} })); + const client = new OpdsClient(platform); + + await client.fetchAsset("https://catalog.test/cover.jpg", "https://catalog.test", credentials); + await client.fetchAsset("https://cdn.test/book.epub", "https://catalog.test", credentials); + + expect(authorization(platform.calls[0] as FetchCall)).not.toBeNull(); + expect(authorization(platform.calls[1] as FetchCall)).toBeNull(); + expect(platform.calls.every((call) => call.options?.responseType === "arraybuffer")).toBe(true); + }); + + it("strips credentials when an authenticated asset redirects across origins", async () => { + const platform = fakePlatform((url) => + url === "https://catalog.test/cover.jpg" + ? response("", { status: 302, headers: { Location: "https://cdn.test/cover.jpg" } }) + : response("asset", { headers: {} }), + ); + + await new OpdsClient(platform).fetchAsset( + "https://catalog.test/cover.jpg", + "https://catalog.test", + credentials, + ); + + expect(authorization(platform.calls[0] as FetchCall)).not.toBeNull(); + expect(authorization(platform.calls[1] as FetchCall)).toBeNull(); + }); + + it("keeps user cancellation active while an asset body is being consumed", async () => { + const stalled = stalledBodyResponse("application/epub+zip"); + const platform = fakePlatform(() => stalled.response); + const controller = new AbortController(); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + undefined, + controller.signal, + ); + const reading = asset.arrayBuffer(); + await stalled.readingStarted; + + controller.abort(); + + const outcome = await Promise.race([ + reading.then( + () => ({ kind: "resolved" as const }), + (error: unknown) => ({ kind: "rejected" as const, error }), + ), + new Promise<{ kind: "pending" }>((resolve) => + setTimeout(() => resolve({ kind: "pending" }), 0), + ), + ]); + expect(outcome).toMatchObject({ + kind: "rejected", + error: { name: "OpdsError", code: "cancelled" }, + }); + }); + + it("keeps the timeout active while an asset body is being consumed", async () => { + vi.useFakeTimers(); + const stalled = stalledBodyResponse("application/epub+zip"); + const platform = fakePlatform(() => stalled.response); + const asset = await new OpdsClient(platform).fetchAsset( + "https://catalog.test/book.epub", + "https://catalog.test", + ); + let rejection: unknown; + const reading = asset.arrayBuffer().catch((error: unknown) => { + rejection = error; + }); + await stalled.readingStarted; + + await vi.advanceTimersByTimeAsync(15_000); + await Promise.resolve(); + + expect(rejection).toMatchObject({ name: "OpdsError", code: "unreachable" }); + expect(vi.getTimerCount()).toBe(0); + await reading; + }); +}); + +describe("OpdsClient search", () => { + it("upgrades Gutenberg-style public HTTP search templates advertised by HTTPS", async () => { + const platform = fakePlatform((url) => { + if (url === "https://www.gutenberg.org/catalog/osd-books.xml") { + return response(GUTENBERG_OPENSEARCH, { + 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("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)); + + 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("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") { + return response(OPENSEARCH, { + headers: { "Content-Type": "application/opensearchdescription+xml" }, + }); + } + if (url === "https://catalog.test/search?q=cats%20%26%20dogs") return response(ATOM); + throw new Error(`Unexpected test URL: ${url}`); + }); + + const feed = await new OpdsClient(platform).search( + { + kind: "openSearch", + descriptorUrl: "https://catalog.test/open-search.xml", + }, + "cats & dogs", + credentials, + ); + + expect(feed.title).toBe("Catalog"); + expect(platform.calls.map((call) => call.url)).toEqual([ + "https://catalog.test/open-search.xml", + "https://catalog.test/search?q=cats%20%26%20dogs", + ]); + expect(platform.calls.map(authorization)).toEqual([ + "Basic cmVhZGVyOnNlY3JldC1wYXNzd29yZA==", + "Basic cmVhZGVyOnNlY3JldC1wYXNzd29yZA==", + ]); + }); + + it("expands an advertised OPDS 2 URI template without allowing query injection", async () => { + const platform = fakePlatform((url) => { + if (url === "https://catalog.test/search?query=a%26admin%3Dtrue%23fragment") { + return response(ATOM); + } + throw new Error(`Unexpected test URL: ${url}`); + }); + + await new OpdsClient(platform).search( + { kind: "template", urlTemplate: "https://catalog.test/search{?query}" }, + "a&admin=true#fragment", + credentials, + ); + + expect(platform.calls).toHaveLength(1); + }); + + it("does not guess search parameters for a feed without advertised search", async () => { + const platform = fakePlatform(() => response(ATOM)); + + const feed = await new OpdsClient(platform).open("https://catalog.test/feed.xml"); + + expect(feed.search).toBeUndefined(); + expect(platform.calls.map((call) => call.url)).toEqual(["https://catalog.test/feed.xml"]); + }); +}); diff --git a/packages/core/src/opds/opds-client.ts b/packages/core/src/opds/opds-client.ts new file mode 100644 index 000000000..1e831a6ab --- /dev/null +++ b/packages/core/src/opds/opds-client.ts @@ -0,0 +1,842 @@ +import { DOMParser } from "@xmldom/xmldom"; +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"; +import type { OpdsCredentials, OpdsErrorCode, OpdsFeed, OpdsSearchDescriptor } from "./opds-types"; + +const CATALOG_ACCEPT = + "application/opds+json, application/atom+xml;profile=opds-catalog, application/xml;q=0.8"; +const OPENSEARCH_ACCEPT = "application/opensearchdescription+xml, application/xml;q=0.8"; +const CATALOG_MEDIA_TYPES = new Set([ + "application/opds+json", + "application/json", + "application/atom+xml", + "application/xml", + "text/xml", +]); +const OPENSEARCH_MEDIA_TYPES = new Set([ + "application/opensearchdescription+xml", + "application/xml", + "text/xml", +]); +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const REQUEST_TIMEOUT_MS = 15_000; +const MAX_REDIRECTS = 5; +const MAX_CATALOG_BYTES = 5 * 1024 * 1024; +const disposedTransports = new WeakSet(); + +const ERROR_MESSAGES: Record = { + unauthorized: "Catalog authentication failed.", + "unsupported-auth": "The catalog requires an unsupported authentication method.", + "insecure-url": "The catalog URL is not allowed.", + unreachable: "The catalog could not be reached.", + "invalid-catalog": "The response is not a valid OPDS catalog.", + cancelled: "The catalog request was cancelled.", + "too-large": "The catalog response is too large.", + "unsupported-acquisition": "The selected book format is not supported.", + "download-failed": "The book could not be downloaded.", + "asset-too-large": "The book is too large to download safely.", + "download-in-progress": "Another catalog download is already in progress.", + "import-failed": "The downloaded book could not be imported.", +}; + +interface SearchDocument { + search(values: Map>): string; + params: Array<{ name: string; ns?: string | null }>; +} + +interface RequestResult { + response: PlatformFetchResponse; + finalUrl: string; +} + +interface RequestOptions { + accept: string; + responseType: "text" | "arraybuffer"; + credentials?: OpdsCredentials; + catalogOrigin?: string; +} + +type OpdsFetchPlatform = Pick; + +export type { OpdsCredentials, OpdsErrorCode } from "./opds-types"; + +export interface OpdsAssetResponse { + readonly body: ReadableStream | null; + readonly bodyUsed: boolean; + readonly headers: Headers; + readonly ok: boolean; + readonly redirected: boolean; + readonly status: number; + readonly statusText: string; + readonly type: ResponseType; + readonly url: string; + arrayBuffer(): Promise; + blob(): Promise; + json(): Promise; + text(): Promise; + cancel(reason?: unknown): Promise; +} + +export class OpdsError extends Error { + readonly code: OpdsErrorCode; + + constructor(code: OpdsErrorCode) { + super(ERROR_MESSAGES[code]); + this.name = "OpdsError"; + this.code = code; + } +} + +class RequestLifecycle { + private readonly controller = new AbortController(); + private readonly userSignal?: AbortSignal; + private readonly onUserAbort = () => this.abort("cancelled"); + private timeout: ReturnType | undefined; + private abortCode: "cancelled" | "unreachable" | undefined; + private disposed = false; + + constructor(userSignal?: AbortSignal) { + this.userSignal = userSignal; + if (userSignal?.aborted) { + this.abort("cancelled"); + return; + } + userSignal?.addEventListener("abort", this.onUserAbort, { once: true }); + this.timeout = setTimeout(() => this.abort("unreachable"), REQUEST_TIMEOUT_MS); + } + + get signal(): AbortSignal { + return this.controller.signal; + } + + throwIfAborted(): void { + if (this.abortCode) throw new OpdsError(this.abortCode); + } + + mapError(error: unknown): OpdsError { + if (error instanceof OpdsError) return error; + return new OpdsError(this.abortCode ?? "unreachable"); + } + + async race(operation: Promise): Promise { + this.throwIfAborted(); + let onAbort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + onAbort = () => reject(new OpdsError(this.abortCode ?? "cancelled")); + this.controller.signal.addEventListener("abort", onAbort, { once: true }); + }); + try { + return await Promise.race([operation, aborted]); + } catch (error) { + throw this.mapError(error); + } finally { + if (onAbort) this.controller.signal.removeEventListener("abort", onAbort); + } + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.timeout !== undefined) clearTimeout(this.timeout); + this.userSignal?.removeEventListener("abort", this.onUserAbort); + } + + private abort(code: "cancelled" | "unreachable"): void { + if (this.abortCode) return; + this.abortCode = code; + this.controller.abort(); + this.dispose(); + } +} + +function normalizeAllowedOrigin(value: string): string { + const classification = classifyOpdsUrl(value); + if (!classification.allowed) throw new OpdsError("insecure-url"); + try { + return new URL(value).origin; + } catch { + throw new OpdsError("insecure-url"); + } +} + +function encodeBase64(value: string): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const bytes = new TextEncoder().encode(value); + let encoded = ""; + 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 bits = (first << 16) | ((second ?? 0) << 8) | (third ?? 0); + encoded += alphabet[(bits >> 18) & 63]; + encoded += alphabet[(bits >> 12) & 63]; + encoded += second === undefined ? "=" : alphabet[(bits >> 6) & 63]; + encoded += third === undefined ? "=" : alphabet[bits & 63]; + } + return encoded; +} + +function getAuthOrigin(credentials?: OpdsCredentials): string | undefined { + if (!credentials) return undefined; + return normalizeAllowedOrigin(credentials.catalogOrigin); +} + +function getHeaders( + current: URL, + accept: string, + credentials?: OpdsCredentials, + authOrigin?: string, +): Headers { + const headers = new Headers({ Accept: accept }); + if (credentials && authOrigin === current.origin) { + headers.set( + "Authorization", + `Basic ${encodeBase64(`${credentials.username}:${credentials.password}`)}`, + ); + } + return headers; +} + +function authError(response: Response): OpdsError | undefined { + if (response.status !== 401) return undefined; + const challenge = response.headers.get("WWW-Authenticate"); + if (!challenge || /(?:^|,)\s*basic(?:\s|$)/i.test(challenge)) { + return new OpdsError("unauthorized"); + } + return new OpdsError("unsupported-auth"); +} + +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 { + 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, + options: FetchOptions, + lifecycle: RequestLifecycle, +): Promise { + try { + return lifecycle.race( + Promise.resolve(platform.fetch(url, { ...options, signal: lifecycle.signal })), + ); + } catch (error) { + return Promise.reject(lifecycle.mapError(error)); + } +} + +function cancelResponseBody(response: Response): void { + if (response.body && !response.body.locked) { + void response.body.cancel().catch(() => {}); + } +} + +function abortResponseTransport(response: PlatformFetchResponse): void { + if (disposedTransports.has(response)) return; + disposedTransports.add(response); + response.cancelTransport?.(); + response.onDispose?.(); +} + +function discardResponse(response: PlatformFetchResponse): void { + cancelResponseBody(response); + abortResponseTransport(response); +} + +function disposeResponse(response: PlatformFetchResponse): void { + if (disposedTransports.has(response)) return; + disposedTransports.add(response); + response.onDispose?.(); +} + +async function readLimitedText( + response: PlatformFetchResponse, + lifecycle: RequestLifecycle, +): Promise { + const contentLength = response.headers.get("Content-Length"); + if (contentLength) { + const parsedLength = Number(contentLength); + if (Number.isFinite(parsedLength) && parsedLength > MAX_CATALOG_BYTES) { + discardResponse(response); + throw new OpdsError("too-large"); + } + } + + const reader = response.body?.getReader(); + if (reader) { + const decoder = new TextDecoder(); + const chunks: string[] = []; + let received = 0; + try { + for (;;) { + const { done, value } = await lifecycle.race(reader.read()); + if (done) break; + received += value.byteLength; + if (received > MAX_CATALOG_BYTES) { + throw new OpdsError("too-large"); + } + chunks.push(decoder.decode(value, { stream: true })); + } + chunks.push(decoder.decode()); + disposeResponse(response); + return chunks.join(""); + } catch (error) { + void reader.cancel().catch(() => {}); + abortResponseTransport(response); + throw lifecycle.mapError(error); + } + } + + let body: string; + try { + body = await lifecycle.race(response.text()); + } catch (error) { + discardResponse(response); + throw lifecycle.mapError(error); + } + if (new TextEncoder().encode(body).byteLength > MAX_CATALOG_BYTES) { + discardResponse(response); + throw new OpdsError("too-large"); + } + disposeResponse(response); + return body; +} + +class ManagedAssetResponse implements OpdsAssetResponse { + readonly headers: Headers; + readonly ok: boolean; + readonly redirected: boolean; + readonly status: number; + readonly statusText: string; + readonly type: ResponseType; + readonly url: string; + readonly body: ReadableStream | null; + + private readonly reader: ReadableStreamDefaultReader | undefined; + private readonly cancellation = new AbortController(); + private state: "open" | "cancelled" | "completed" | "failed" = "open"; + private cancellationCleanup: Promise | undefined; + private used = false; + + constructor( + private readonly response: PlatformFetchResponse, + private readonly lifecycle: RequestLifecycle, + ) { + this.headers = response.headers; + this.ok = response.ok; + this.redirected = response.redirected; + this.status = response.status; + this.statusText = response.statusText; + this.type = response.type; + this.url = response.url; + + if (!response.body) { + this.body = null; + disposeResponse(response); + lifecycle.dispose(); + return; + } + + const reader = response.body.getReader(); + this.reader = reader; + this.body = new ReadableStream( + { + pull: async (controller) => { + this.used = true; + try { + const result = await this.readNext(reader); + this.throwIfCancelled(); + const { done, value } = result; + if (done) { + controller.close(); + this.finishNormally(); + return; + } + controller.enqueue(value); + } catch (error) { + const mapped = + this.state === "cancelled" ? new OpdsError("cancelled") : lifecycle.mapError(error); + if (this.state !== "cancelled") void this.fail(error); + controller.error(mapped); + } + }, + cancel: async (reason) => { + await this.transitionToCancelled(reason); + }, + }, + { highWaterMark: 0 }, + ); + } + + get bodyUsed(): boolean { + return this.used || Boolean(this.body?.locked); + } + + async arrayBuffer(): Promise { + return (await this.consumeBytes()).buffer as ArrayBuffer; + } + + async blob(): Promise { + if (typeof globalThis.Blob !== "function") { + throw new TypeError("Blob is not available on this platform."); + } + const bytes = await this.consumeBytes(); + return new Blob([bytes.buffer as ArrayBuffer], { + type: this.headers.get("Content-Type") ?? "", + }); + } + + async json(): Promise { + return JSON.parse(await this.text()); + } + + async text(): Promise { + return new TextDecoder().decode(await this.consumeBytes()); + } + + async cancel(reason?: unknown): Promise { + await this.transitionToCancelled(reason); + } + + private async consumeBytes(): Promise { + this.throwIfCancelled(); + if (this.used || this.body?.locked) { + throw new TypeError("The response body has already been consumed."); + } + this.used = true; + if (!this.body) return new Uint8Array(); + + const reader = this.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const result = await reader.read(); + this.throwIfCancelled(); + const { done, value } = result; + if (done) break; + chunks.push(value); + total += value.byteLength; + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; + } + + private finishNormally(): void { + if (this.state !== "open") return; + this.state = "completed"; + disposeResponse(this.response); + this.lifecycle.dispose(); + } + + private async readNext( + reader: ReadableStreamDefaultReader, + ): Promise> { + this.throwIfCancelled(); + let onCancel: (() => void) | undefined; + const cancelled = new Promise((_resolve, reject) => { + onCancel = () => reject(new OpdsError("cancelled")); + this.cancellation.signal.addEventListener("abort", onCancel, { once: true }); + }); + try { + const result = await Promise.race([this.lifecycle.race(reader.read()), cancelled]); + this.throwIfCancelled(); + return result; + } catch (error) { + if (this.state === "cancelled") throw new OpdsError("cancelled"); + throw error; + } finally { + if (onCancel) this.cancellation.signal.removeEventListener("abort", onCancel); + } + } + + private throwIfCancelled(): void { + if (this.state === "cancelled") throw new OpdsError("cancelled"); + } + + private transitionToCancelled(reason?: unknown): Promise { + if (this.state === "cancelled") { + return this.cancellationCleanup ?? Promise.resolve(); + } + if (this.state !== "open" || disposedTransports.has(this.response)) { + return Promise.resolve(); + } + + this.state = "cancelled"; + this.used = true; + this.cancellation.abort(); + this.cancellationCleanup = this.abortReader(reason); + return this.cancellationCleanup; + } + + private fail(reason?: unknown): Promise { + if (this.state !== "open") return Promise.resolve(); + this.state = "failed"; + return this.abortReader(reason); + } + + private async abortReader(reason?: unknown): Promise { + abortResponseTransport(this.response); + this.lifecycle.dispose(); + try { + await this.reader?.cancel(reason); + } catch { + // The native transport is already aborted; stream cancellation is best effort. + } + } +} + +function getMediaType(response: Response): string { + return response.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; +} + +function removeDoctypeAndEntityReferences(body: string): string { + const withoutDoctype = body.replace(/\[]|\[[\s\S]*?\])*>/gi, ""); + return withoutDoctype.replace(/&(?!(?:amp|lt|gt|quot|apos);)[A-Za-z_][\w.:-]*;/g, ""); +} + +async function parseOpenSearch(body: string): Promise { + const errors: string[] = []; + const document = new DOMParser({ + errorHandler: { + warning: (message) => errors.push(message), + error: (message) => errors.push(message), + fatalError: (message) => errors.push(message), + }, + }).parseFromString(removeDoctypeAndEntityReferences(body), "application/xml"); + 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( + descriptor: Extract, + query: string, +) { + try { + const search = (await getSearch({ + href: descriptor.urlTemplate, + title: descriptor.title, + type: descriptor.type, + })) as Partial; + if (typeof search.search !== "function" || !Array.isArray(search.params)) { + throw new OpdsError("invalid-catalog"); + } + const names = new Set(search.params.map((param) => param.name)); + if (!names.has("query") && !names.has("searchTerms")) { + throw new OpdsError("invalid-catalog"); + } + const values = new Map([ + ["query", query], + ["searchTerms", query], + ]); + return search.search(new Map([[null, values]])); + } catch (error) { + if (error instanceof OpdsError) throw error; + throw new OpdsError("invalid-catalog"); + } +} + +export class OpdsClient { + constructor(private readonly platform: OpdsFetchPlatform) {} + + private async request( + url: string, + options: RequestOptions, + lifecycle: RequestLifecycle, + ): Promise { + const authOrigin = getAuthOrigin(options.credentials); + const confirmedInsecureOrigin = getConfirmedInsecureOrigin(options.catalogOrigin); + let current = checkUrl(url, confirmedInsecureOrigin); + + for (let redirects = 0; ; redirects += 1) { + lifecycle.throwIfAborted(); + const headers = getHeaders(current, options.accept, options.credentials, authOrigin); + let response: Response; + try { + response = await runPlatformFetch( + this.platform, + current.href, + { + headers, + redirect: "manual", + timeoutMs: REQUEST_TIMEOUT_MS, + responseType: options.responseType, + }, + lifecycle, + ); + } catch (error) { + throw lifecycle.mapError(error); + } + + const authenticationError = authError(response); + if (authenticationError) { + discardResponse(response); + throw authenticationError; + } + if (!REDIRECT_STATUSES.has(response.status)) { + if (!response.ok) { + discardResponse(response); + throw new OpdsError("unreachable"); + } + return { response, finalUrl: current.href }; + } + discardResponse(response); + if (redirects >= MAX_REDIRECTS) throw new OpdsError("invalid-catalog"); + + const location = response.headers.get("Location"); + if (!location) throw new OpdsError("invalid-catalog"); + let next: URL; + try { + next = checkUrl(new URL(location, current).href, confirmedInsecureOrigin); + } catch (error) { + if (error instanceof OpdsError) throw error; + throw new OpdsError("insecure-url"); + } + if (current.protocol === "https:" && next.protocol === "http:") { + throw new OpdsError("insecure-url"); + } + current = next; + } + } + + async open( + url: string, + credentials?: OpdsCredentials, + signal?: AbortSignal, + catalogOrigin?: string, + ): Promise { + const lifecycle = new RequestLifecycle(signal); + try { + const { response, finalUrl } = await this.request( + url, + { + accept: CATALOG_ACCEPT, + responseType: "text", + credentials, + catalogOrigin: catalogOrigin ?? credentials?.catalogOrigin, + }, + lifecycle, + ); + const contentType = getMediaType(response); + if (!CATALOG_MEDIA_TYPES.has(contentType)) { + discardResponse(response); + throw new OpdsError("invalid-catalog"); + } + const body = await readLimitedText(response, lifecycle); + try { + return parseOpdsDocument(body, contentType, finalUrl); + } catch (error) { + if (error instanceof OpdsError) throw error; + throw new OpdsError("invalid-catalog"); + } + } finally { + lifecycle.dispose(); + } + } + + async search( + descriptor: OpdsSearchDescriptor, + query: string, + credentials?: OpdsCredentials, + signal?: AbortSignal, + catalogOrigin?: string, + ): Promise { + const requestCatalogOrigin = catalogOrigin ?? credentials?.catalogOrigin; + if (descriptor.kind === "template") { + const searchUrl = canonicalizeAdvertisedSearchUrl( + await expandTemplate(descriptor, query), + requestCatalogOrigin, + ); + return this.open(searchUrl, credentials, signal, requestCatalogOrigin); + } + + const lifecycle = new RequestLifecycle(signal); + let searchUrl: string; + try { + const { response, finalUrl } = await this.request( + descriptor.descriptorUrl, + { + accept: OPENSEARCH_ACCEPT, + responseType: "text", + credentials, + catalogOrigin: requestCatalogOrigin, + }, + lifecycle, + ); + if (!OPENSEARCH_MEDIA_TYPES.has(getMediaType(response))) { + discardResponse(response); + throw new OpdsError("invalid-catalog"); + } + const search = await parseOpenSearch(await readLimitedText(response, lifecycle)); + if (!search.params.some((param) => param.name === "searchTerms" && !param.ns)) { + throw new OpdsError("invalid-catalog"); + } + try { + searchUrl = canonicalizeAdvertisedSearchUrl( + new URL(search.search(new Map([[null, new Map([["searchTerms", query]])]])), finalUrl) + .href, + finalUrl, + ); + } catch { + throw new OpdsError("invalid-catalog"); + } + } finally { + lifecycle.dispose(); + } + return this.open(searchUrl, credentials, signal, requestCatalogOrigin); + } + + async fetchAsset( + url: string, + catalogOrigin: string, + credentials?: OpdsCredentials, + signal?: AbortSignal, + ): Promise { + const normalizedCatalogOrigin = normalizeAllowedOrigin(catalogOrigin); + if ( + credentials && + normalizeAllowedOrigin(credentials.catalogOrigin) !== normalizedCatalogOrigin + ) { + throw new OpdsError("insecure-url"); + } + const lifecycle = new RequestLifecycle(signal); + try { + const { response } = await this.request( + url, + { + accept: "*/*", + responseType: "arraybuffer", + credentials, + catalogOrigin: normalizedCatalogOrigin, + }, + lifecycle, + ); + return new ManagedAssetResponse(response, lifecycle); + } catch (error) { + lifecycle.dispose(); + throw error; + } + } +} 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..2fdd1072c --- /dev/null +++ b/packages/core/src/opds/opds-cover-cache.test.ts @@ -0,0 +1,217 @@ +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; +} + +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 })); + 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()).toMatchObject({ entries: 1, sourceBytes: 4, urls: ["second"] }); + }); + + it("loads a dense FIFO window as earlier leases release capacity", async () => { + const loaded: string[] = []; + const cache = createOpdsCoverCache({ + load: async (url) => { + loaded.push(url); + return { uri: url, byteLength: 2 }; + }, + maxEntries: 2, + maxBytes: 4, + maxLoadBytes: 2, + }); + 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 () => { + 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"); + const third = cache.acquire("third"); + 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(); + 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 () => { + 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: 20, + maxBytes: 20, + maxLoadBytes: 1, + 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( + "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..70e90690b --- /dev/null +++ b/packages/core/src/opds/opds-cover-cache.ts @@ -0,0 +1,364 @@ +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 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({ + load, + maxEntries, + maxBytes, + maxLoadBytes = maxBytes, + maxConcurrentLoads = 4, + maxQueuedLoads = Math.max(1, maxEntries * 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; + /** Maximum number of distinct queued/loading covers. Duplicate URLs share one slot. */ + maxQueuedLoads?: number; +}) { + const entries = 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 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 removePending = (pending: PendingEntry) => { + if (pendingByUrl.get(pending.url) === pending) pendingByUrl.delete(pending.url); + }; + + 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 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); + } + }; + + 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; + if (entry.references === 0 && retiredEntries.delete(entry)) { + retiredBytes = Math.max(0, retiredBytes - entry.byteLength); + } + drainQueue(); + }, + }; + }; + + 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 = pendingByUrl.get(url); + if (!pending) { + 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(); + 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: () => {}, + }; + pendingByUrl.set(url, pending); + pendingQueue.push(pending); + drainQueue(); + } + pending.waiters += 1; + let leased = 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 entry = await Promise.race([pending.promise, cancelled]); + if (acquisitionGeneration !== generation) throw new Error("cancelled"); + const result = lease(entry); + leased = true; + return result; + } finally { + signal?.removeEventListener("abort", onAbort); + pending.waiters = Math.max(0, pending.waiters - 1); + 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 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; + } + } + entries.clear(); + sourceBytes = 0; + reservedBytes = 0; + }, + snapshot() { + return { + entries: entries.size, + sourceBytes, + urls: [...entries.keys()], + 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 new file mode 100644 index 000000000..13efa8882 --- /dev/null +++ b/packages/core/src/opds/opds-parser.test.ts @@ -0,0 +1,710 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +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 = ` + + Catalog + Books for everyone + + + + + + urn:isbn:9780000000001 + Book + Author + Press + en + 9780000000001 + 2026-08-16 + + <p onclick="steal()">A <em>safe</em> description.</p><script>steal()</script> + + + + +`; + +const OPDS2 = JSON.stringify({ + metadata: { + title: { fr: "Catalogue OPDS 2", en: "OPDS 2 Catalog" }, + subtitle: { fr: "Nouveaux livres", en: "New books" }, + }, + links: [ + { rel: "self", href: "feed.json", type: "application/opds+json" }, + { rel: "next", href: "pages/2.json", type: "application/opds+json" }, + { rel: ["previous"], href: "../previous.json", type: "application/opds+json" }, + { + rel: "search", + href: "search{?query}", + type: "application/opds+json", + title: "Search catalog", + templated: true, + }, + ], + navigation: [{ title: "Popular", href: "popular.json", type: "application/opds+json" }], + publications: [ + { + metadata: { + identifier: "urn:isbn:9780000000002", + title: { ja: "第二の本", en: "Second Book" }, + author: [{ name: { fr: "Premier auteur", en: "First Author" } }, "Second Author"], + publisher: [{ name: { fr: "Autre presse", en: "Other Press" } }, "Fallback Press"], + language: "fr", + published: "2025-01-02", + description: + '

Read this. Chapter About Details Bad

', + subject: [{ name: "Mystery" }, "Adventure"], + }, + images: [{ rel: "cover", href: "images/cover.png", type: "image/png" }], + links: [ + { + rel: ["http://opds-spec.org/acquisition", "alternate"], + href: "downloads/book.pdf", + type: "application/pdf", + }, + ], + }, + ], + groups: [ + { + metadata: { title: { ja: "特集", fr: "En vedette" } }, + navigation: [{ title: "Editors' picks", href: "groups/editors.json" }], + }, + ], + facets: [ + { + metadata: { title: { zh: "语言", fr: "Langue" } }, + links: [{ rel: "self", href: "facets/fr.json", title: "French" }], + }, + ], +}); + +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, + "application/atom+xml;profile=opds-catalog", + "https://catalog.test/root/feed.xml", + ); + + expect(feed).toMatchObject({ + title: "Catalog", + subtitle: "Books for everyone", + nextUrl: "https://catalog.test/root/page-2.xml", + previousUrl: "https://catalog.test/page-0.xml", + search: { + kind: "openSearch", + descriptorUrl: "https://catalog.test/root/search.xml", + title: "Search books", + }, + }); + expect(feed.facets).toEqual([ + { + title: "Genre", + links: [ + { + rel: ["http://opds-spec.org/facet"], + url: "https://catalog.test/root/facets/fiction.xml", + title: "Fiction", + type: "application/atom+xml;profile=opds-catalog", + }, + ], + }, + ]); + expect(feed.publications[0]).toMatchObject({ + id: "urn:isbn:9780000000001", + title: "Book", + authors: ["Author"], + publisher: "Press", + language: "en", + identifier: "9780000000001", + published: "2026-08-16", + subjects: ["Fiction"], + description: "

A safe description.

", + images: [{ url: "https://catalog.test/root/covers/book.jpg" }], + }); + expect(feed.publications[0]?.acquisitions).toEqual([ + expect.objectContaining({ + url: "https://catalog.test/root/files/book.epub", + format: "epub", + }), + expect.objectContaining({ + url: "https://catalog.test/root/files/book.weird", + format: null, + }), + ]); + }); + + it("validates and normalizes an OPDS 2 feed including nested collections", () => { + const feed = parseOpdsDocument( + OPDS2, + "application/opds+json; charset=utf-8", + "https://catalog.test/root/feed.json", + ); + + expect(feed).toMatchObject({ + title: "OPDS 2 Catalog", + subtitle: "New books", + navigation: [{ title: "Popular", url: "https://catalog.test/root/popular.json" }], + nextUrl: "https://catalog.test/root/pages/2.json", + previousUrl: "https://catalog.test/previous.json", + search: { + kind: "template", + urlTemplate: "https://catalog.test/root/search{?query}", + title: "Search catalog", + }, + }); + expect(feed.publications[0]).toMatchObject({ + id: "urn:isbn:9780000000002", + title: "Second Book", + authors: ["First Author", "Second Author"], + publisher: "Other Press", + language: "fr", + identifier: "urn:isbn:9780000000002", + published: "2025-01-02", + subjects: ["Mystery", "Adventure"], + description: + '

Read this. Chapter About Details Bad

', + images: [expect.objectContaining({ url: "https://catalog.test/root/images/cover.png" })], + acquisitions: [ + expect.objectContaining({ + url: "https://catalog.test/root/downloads/book.pdf", + format: "pdf", + }), + ], + }); + expect(feed.groups[0]).toMatchObject({ + title: "En vedette", + navigation: [ + { title: "Editors' picks", url: "https://catalog.test/root/groups/editors.json" }, + ], + }); + expect(feed.facets[0]).toEqual({ + title: "Langue", + links: [ + { + rel: ["self"], + url: "https://catalog.test/root/facets/fr.json", + title: "French", + }, + ], + }); + }); + + it("maps supported acquisition extensions and retains unknown formats", () => { + const body = JSON.stringify({ + metadata: { title: "Formats" }, + links: [{ rel: "self", href: "feed.json", type: "application/opds+json" }], + publications: [ + { + metadata: { title: "Format Book" }, + links: [ + { rel: "http://opds-spec.org/acquisition", href: "book.azw3" }, + { + rel: "http://opds-spec.org/acquisition", + href: "book-with-mime.azw3", + type: "application/vnd.amazon.ebook", + }, + { + rel: "http://opds-spec.org/acquisition", + href: "generic.zip", + type: "application/zip", + }, + { rel: "http://opds-spec.org/acquisition", href: "book.unknown" }, + ], + }, + ], + }); + + expect( + parseOpdsDocument( + body, + "application/opds+json", + "https://catalog.test/feed.json", + ).publications[0]?.acquisitions.map((item) => item.format), + ).toEqual(["azw3", "azw3", null, null]); + }); + + it.each(["acquisition", "borrow", "buy", "download", "preview", "subscribe"])( + "recognizes the OPDS 2 %s acquisition relation", + (rel) => { + const body = JSON.stringify({ + metadata: { title: "Relations" }, + links: [{ rel: "self", href: "feed.json" }], + publications: [ + { + metadata: { title: `${rel} Book` }, + links: [{ rel, href: `books/${rel}.epub`, type: "application/epub+zip" }], + }, + ], + }); + + expect( + parseOpdsDocument(body, "application/opds+json", "https://catalog.test/root/feed.json") + .publications[0]?.acquisitions, + ).toEqual([ + expect.objectContaining({ + url: `https://catalog.test/root/books/${rel}.epub`, + format: "epub", + }), + ]); + }, + ); + + it.each([ + "http://opds-spec.org/acquisition", + "http://opds-spec.org/acquisition/borrow", + "http://opds-spec.org/acquisition/open-access", + ])("recognizes the OPDS 1 acquisition relation %s in OPDS 2", (rel) => { + const body = JSON.stringify({ + metadata: { title: "Legacy Relations" }, + links: [{ rel: "self", href: "feed.json" }], + publications: [ + { + metadata: { title: "Legacy Book" }, + links: [{ rel, href: "books/legacy.pdf", type: "application/pdf" }], + }, + ], + }); + + expect( + parseOpdsDocument(body, "application/opds+json", "https://catalog.test/root/feed.json") + .publications[0]?.acquisitions, + ).toEqual([ + expect.objectContaining({ + url: "https://catalog.test/root/books/legacy.pdf", + format: "pdf", + }), + ]); + }); + + it("rejects a title-only OPDS 2 publication", () => { + const body = JSON.stringify({ + metadata: { title: "Catalog" }, + links: [{ rel: "self", href: "feed.json" }], + publications: [{ metadata: { title: "No way to read me" } }], + }); + + expect(() => + parseOpdsDocument(body, "application/opds+json", "https://catalog.test/feed.json"), + ).toThrow("Invalid OPDS 2 catalog"); + }); + + it("accepts and maps a publication with a valid reading order", () => { + const body = JSON.stringify({ + metadata: { title: "Web Publications" }, + links: [{ rel: "self", href: "feed.json" }], + publications: [ + { + metadata: { title: "Web Book" }, + readingOrder: [{ href: "chapters/1.html", type: "text/html", title: "Chapter One" }], + }, + ], + }); + + expect( + parseOpdsDocument(body, "application/opds+json", "https://catalog.test/root/feed.json") + .publications[0]?.readingOrder, + ).toEqual([ + { + rel: [], + url: "https://catalog.test/root/chapters/1.html", + type: "text/html", + title: "Chapter One", + }, + ]); + }); + + it.each([ + ["a non-array reading order", { href: "chapter.html" }], + ["an empty reading order", []], + ["a reading-order item without an href", [{ type: "text/html" }]], + ["a reading-order item with a non-string href", [{ href: 7, type: "text/html" }]], + ["a reading-order item with a non-string type", [{ href: "chapter.html", type: 7 }]], + ])("rejects a publication with %s", (_name, readingOrder) => { + const body = JSON.stringify({ + metadata: { title: "Web Publications" }, + links: [{ rel: "self", href: "feed.json" }], + publications: [{ metadata: { title: "Broken Web Book" }, readingOrder }], + }); + + expect(() => + parseOpdsDocument(body, "application/opds+json", "https://catalog.test/feed.json"), + ).toThrow("Invalid OPDS 2 catalog"); + }); + + it.each([ + ["application/opds+json", "{", "Invalid OPDS JSON document"], + ["application/opds+json", JSON.stringify({ metadata: {} }), "Invalid OPDS 2 catalog"], + ["application/opds+json", JSON.stringify([]), "Invalid OPDS 2 catalog"], + ["application/xml", "Broken</feed>", "Invalid OPDS XML document"], + ])("rejects invalid documents with stable errors", (contentType, body, message) => { + expect(() => parseOpdsDocument(body, contentType, "https://catalog.test/feed")).toThrow( + message, + ); + }); + + it.each([ + [ + "missing self link", + { + metadata: { title: "Catalog" }, + navigation: [{ title: "Books", href: "books" }], + }, + ], + [ + "missing catalog collection", + { + metadata: { title: "Catalog" }, + links: [{ rel: "self", href: "feed.json" }], + }, + ], + [ + "blank catalog collection", + { + metadata: { title: "Catalog" }, + links: [{ rel: "self", href: "feed.json" }], + publications: [], + }, + ], + [ + "group with both collection roles", + { + metadata: { title: "Catalog" }, + links: [{ rel: "self", href: "feed.json" }], + groups: [ + { + metadata: { title: "Mixed" }, + navigation: [{ title: "Books", href: "books" }], + publications: [{ metadata: { title: "Book" } }], + }, + ], + }, + ], + [ + "group without a collection role", + { + metadata: { title: "Catalog" }, + links: [{ rel: "self", href: "feed.json" }], + groups: [{ metadata: { title: "Empty" } }], + }, + ], + [ + "group without a title", + { + metadata: { title: "Catalog" }, + links: [{ rel: "self", href: "feed.json" }], + groups: [{ metadata: {}, navigation: [{ title: "Books", href: "books" }] }], + }, + ], + [ + "group with an invalid navigation link", + { + metadata: { title: "Catalog" }, + links: [{ rel: "self", href: "feed.json" }], + groups: [{ metadata: { title: "Broken" }, navigation: [{ title: "Books" }] }], + }, + ], + [ + "facet without a title", + { + metadata: { title: "Catalog" }, + links: [{ rel: "self", href: "feed.json" }], + navigation: [{ title: "Books", href: "books" }], + facets: [{ metadata: {}, links: [{ href: "fiction" }] }], + }, + ], + [ + "facet without links", + { + metadata: { title: "Catalog" }, + links: [{ rel: "self", href: "feed.json" }], + navigation: [{ title: "Books", href: "books" }], + facets: [{ metadata: { title: "Genre" } }], + }, + ], + [ + "facet with an invalid link", + { + metadata: { title: "Catalog" }, + links: [{ rel: "self", href: "feed.json" }], + navigation: [{ title: "Books", href: "books" }], + facets: [{ metadata: { title: "Genre" }, links: [{ title: "Fiction" }] }], + }, + ], + ])("rejects an OPDS 2 feed with %s", (_name, value) => { + expect(() => + parseOpdsDocument( + JSON.stringify(value), + "application/opds+json", + "https://catalog.test/feed.json", + ), + ).toThrow("Invalid OPDS 2 catalog"); + }); + + it("rejects a feed in an unrelated XML namespace", () => { + expect(() => + parseOpdsDocument( + '<feed xmlns="urn:not-atom"><title>Not Atom', + "application/atom+xml", + "https://catalog.test/feed.xml", + ), + ).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', + "application/atom+xml", + "https://catalog.test/feed.xml", + ); + + expect(feed).toMatchObject({ + title: "Legacy Catalog", + navigation: [{ title: "Books", url: "https://catalog.test/books.xml" }], + }); + }); + + it("rejects Atom-shaped children in an unrelated namespace", () => { + const body = `CatalogNot Atom`; + + expect(() => + parseOpdsDocument(body, "application/atom+xml", "https://catalog.test/feed.xml"), + ).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([ + [ + "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", + `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 + + urn:book:firstFirst + + + + + urn:book:secondSecond + + + +`; + + const feed = parseOpdsDocument(body, "application/atom+xml", "https://catalog.test/feed"); + expect(feed.groups[0]?.publications.map(({ id }) => id)).toEqual([ + "urn:book:first", + "urn:book:second", + ]); + }); + + it("does not read a real local file through an external XML entity", async () => { + const directory = await mkdtemp(join(tmpdir(), "readany-opds-xxe-")); + const marker = `READANY_XXE_MARKER_${Date.now()}`; + const markerPath = join(directory, "marker.txt"); + await writeFile(markerPath, marker, "utf8"); + + try { + const body = ` +]> + + Safe catalog + + Safe book + &xxe; + + +`; + + const feed = parseOpdsDocument(body, "application/atom+xml", "https://catalog.test/feed"); + expect(JSON.stringify(feed)).not.toContain(marker); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it("does not expand internal XML entities", () => { + const body = ` +]> + + Safe catalog + + Safe book + &internal; + + +`; + + const feed = parseOpdsDocument(body, "application/atom+xml", "https://catalog.test/feed"); + expect(JSON.stringify(feed)).not.toContain("INTERNAL_ENTITY_MARKER"); + }); + + it("sanitizes an Atom XHTML description without losing safe markup", () => { + const body = ` + XHTML catalog + + XHTML book +

Keep this

+ +
+
`; + + const feed = parseOpdsDocument(body, "application/atom+xml", "https://catalog.test/feed"); + expect(feed.publications[0]?.description).toBe("

Keep this

"); + }); +}); diff --git a/packages/core/src/opds/opds-parser.ts b/packages/core/src/opds/opds-parser.ts new file mode 100644 index 000000000..1e28c7fea --- /dev/null +++ b/packages/core/src/opds/opds-parser.ts @@ -0,0 +1,611 @@ +/// + +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, + OpdsFeed, + OpdsLink, + OpdsPublication, + OpdsSearchDescriptor, +} from "./opds-types"; + +const ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"; +const IMAGE_RELS = new Set([ + "cover", + "thumbnail", + "http://opds-spec.org/cover", + "http://opds-spec.org/image", + "http://opds-spec.org/thumbnail", + "http://opds-spec.org/image/thumbnail", +]); + +const FORMAT_BY_MEDIA_TYPE: Readonly> = { + "application/epub+zip": "epub", + "application/pdf": "pdf", + "application/x-pdf": "pdf", + "application/x-mobipocket-ebook": "mobi", + "application/vnd.amazon.ebook": "azw", + "application/x-fictionbook+xml": "fb2", + "application/x-cbz": "cbz", + "application/vnd.comicbook+zip": "cbz", + "text/plain": "txt", + "application/x-umd": "umd", +}; + +const SUPPORTED_EXTENSIONS = new Set([ + "epub", + "pdf", + "mobi", + "azw", + "azw3", + "cbz", + "fb2", + "fbz", + "txt", + "umd", +]); + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function requiredString(value: unknown): string { + if (typeof value !== "string") throw new Error("Invalid OPDS 2 catalog"); + return value; +} + +function isLocalizableString(value: unknown): boolean { + if (typeof value === "string") return value.length > 0; + if (!isRecord(value)) return false; + const entries = Object.entries(value); + return ( + entries.length > 0 && + entries.every( + ([language, text]) => language.length > 0 && typeof text === "string" && text.length > 0, + ) + ); +} + +function localizableString(value: unknown): string | undefined { + if (typeof value === "string") return value || undefined; + if (!isLocalizableString(value) || !isRecord(value)) return undefined; + const entries = Object.entries(value).sort(([left], [right]) => { + if (left.toLowerCase() === "en") return -1; + if (right.toLowerCase() === "en") return 1; + return left < right ? -1 : left > right ? 1 : 0; + }); + return entries[0]?.[1] as string | undefined; +} + +function requiredLocalizableString(value: unknown): string { + const normalized = localizableString(value); + if (!normalized) throw new Error("Invalid OPDS 2 catalog"); + return normalized; +} + +function normalizeRel(value: unknown): string[] { + if (typeof value === "string") return value.trim().split(/\s+/).filter(Boolean); + if (Array.isArray(value) && value.every((item) => typeof item === "string")) return value; + return []; +} + +function isAcquisitionRelation(rel: string): boolean { + return classifyOpdsAcquisitionRelation([rel]) !== undefined; +} + +function isAcquisitionLink(value: unknown): value is UnknownRecord { + return isRecord(value) && normalizeRel(value.rel).some(isAcquisitionRelation); +} + +function resolveUrl(href: string, documentUrl: string, templated = false): string { + try { + if (!templated) return new URL(href, documentUrl).href; + + const expressions: string[] = []; + const protectedHref = href.replace(/\{[^}]+}/g, (expression) => { + expressions.push(expression); + return `__OPDS_TEMPLATE_${expressions.length - 1}__`; + }); + let resolved = new URL(protectedHref, documentUrl).href; + expressions.forEach((expression, index) => { + resolved = resolved.replace(`__OPDS_TEMPLATE_${index}__`, expression); + }); + return resolved; + } catch { + throw new Error("Invalid OPDS catalog URL"); + } +} + +function mapLink(value: unknown, documentUrl: string): OpdsLink | undefined { + if (!isRecord(value)) return undefined; + const href = optionalString(value.href) ?? optionalString(value.url); + if (!href) return undefined; + const rel = normalizeRel(value.rel); + const link: OpdsLink = { + rel, + url: resolveUrl(href, documentUrl), + }; + const type = optionalString(value.type); + const title = optionalString(value.title); + if (type) link.type = type; + if (title) link.title = title; + return link; +} + +function asRecords(value: unknown): UnknownRecord[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function getMetadata(value: UnknownRecord): UnknownRecord { + return isRecord(value.metadata) ? value.metadata : {}; +} + +function normalizeNames(value: unknown): string[] { + const values = Array.isArray(value) ? value : value == null ? [] : [value]; + return values.flatMap((item) => { + if (typeof item === "string") return item ? [item] : []; + if (!isRecord(item)) return []; + const name = localizableString(item.name); + return name ? [name] : []; + }); +} + +function firstString(value: unknown): string | undefined { + if (typeof value === "string") return value || undefined; + if (Array.isArray(value)) return value.find((item): item is string => typeof item === "string"); + if (isRecord(value)) return optionalString(value.name); + return undefined; +} + +function normalizeSubjects(value: unknown): string[] { + const values = Array.isArray(value) ? value : value == null ? [] : [value]; + return values.flatMap((item) => { + if (typeof item === "string") return item ? [item] : []; + if (!isRecord(item)) return []; + const name = + localizableString(item.name) ?? optionalString(item.label) ?? optionalString(item.code); + return name ? [name] : []; + }); +} + +function getDescription(metadata: UnknownRecord, documentUrl: string): string | undefined { + const description = optionalString(metadata.description); + if (description) return sanitizeOpdsDescription(description, documentUrl); + + const content = metadata[SYMBOL.CONTENT]; + if (!isRecord(content)) return undefined; + const value = optionalString(content.value); + return value ? sanitizeOpdsDescription(value, documentUrl) : undefined; +} + +function getBookFormat(type: string | undefined, url: string): BookFormat | null { + let pathname: string; + try { + pathname = new URL(url).pathname; + } catch { + pathname = url.split(/[?#]/, 1)[0] ?? ""; + } + const extension = pathname.match(/\.([^.\/]+)$/)?.[1]?.toLowerCase() as BookFormat | undefined; + if (extension && SUPPORTED_EXTENSIONS.has(extension)) return extension; + + const mediaType = type?.split(";", 1)[0]?.trim().toLowerCase(); + return mediaType ? (FORMAT_BY_MEDIA_TYPE[mediaType] ?? null) : null; +} + +function mapAcquisition(value: unknown, documentUrl: string): OpdsAcquisition | undefined { + const link = mapLink(value, documentUrl); + if (!link || !isAcquisitionLink(value)) return undefined; + 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 { + if (!isRecord(value)) throw new Error("Invalid OPDS 2 catalog"); + const metadata = getMetadata(value); + const title = requiredLocalizableString(metadata.title); + const rawLinks = asRecords(value.links); + const acquisitions = rawLinks.flatMap((item) => { + const acquisition = mapAcquisition(item, documentUrl); + return acquisition ? [acquisition] : []; + }); + const imageValues = Array.isArray(value.images) + ? value.images + : rawLinks.filter((link) => normalizeRel(link.rel).some((rel) => IMAGE_RELS.has(rel))); + const images = imageValues.flatMap((item) => { + const image = mapLink(item, documentUrl); + return image ? [image] : []; + }); + const readingOrder = asRecords(value.readingOrder).flatMap((item) => { + const link = mapLink(item, documentUrl); + return link ? [link] : []; + }); + + const identifier = optionalString(metadata.identifier); + const publication: OpdsPublication = { + title, + authors: normalizeNames(metadata.author), + subjects: normalizeSubjects(metadata.subject), + images, + acquisitions, + readingOrder, + }; + const id = optionalString(value.id) ?? identifier; + const publisher = normalizeNames(metadata.publisher)[0]; + const language = firstString(metadata.language); + const published = optionalString(metadata.published); + const description = getDescription(metadata, documentUrl); + if (id) publication.id = id; + if (publisher) publication.publisher = publisher; + if (language) publication.language = language; + if (identifier) publication.identifier = identifier; + if (published) publication.published = published; + if (description) publication.description = description; + return publication; +} + +function findLink(links: UnknownRecord[], relName: string): UnknownRecord | undefined { + return links.find((link) => normalizeRel(link.rel).includes(relName)); +} + +function mapSearch(links: UnknownRecord[], documentUrl: string): OpdsSearchDescriptor | undefined { + const link = findLink(links, "search"); + if (!link) return undefined; + const href = optionalString(link.href); + if (!href) return undefined; + const type = optionalString(link.type); + const title = optionalString(link.title); + if (link.templated === true || href.includes("{")) { + return { + kind: "template", + urlTemplate: resolveUrl(href, documentUrl, true), + ...(title ? { title } : {}), + ...(type ? { type } : {}), + }; + } + if (type?.split(";", 1)[0]?.trim().toLowerCase() === "application/opensearchdescription+xml") { + return { + kind: "openSearch", + descriptorUrl: resolveUrl(href, documentUrl), + ...(title ? { title } : {}), + type, + }; + } + return undefined; +} + +function mapFeed(value: unknown, documentUrl: string): OpdsFeed { + if (!isRecord(value)) throw new Error("Invalid OPDS 2 catalog"); + const metadata = getMetadata(value); + const title = requiredLocalizableString(metadata.title); + const links = asRecords(value.links); + const next = findLink(links, "next"); + const previous = findLink(links, "previous"); + const nextHref = next ? optionalString(next.href) : undefined; + const previousHref = previous ? optionalString(previous.href) : undefined; + + const feed: OpdsFeed = { + title, + navigation: asRecords(value.navigation).map((item) => ({ + title: requiredString(item.title), + url: resolveUrl(requiredString(item.href), documentUrl), + })), + publications: asRecords(value.publications).map((publication) => + mapPublication(publication, documentUrl), + ), + groups: asRecords(value.groups).map((group) => mapFeed(group, documentUrl)), + facets: asRecords(value.facets).map((facet) => ({ + title: requiredLocalizableString(getMetadata(facet).title), + links: asRecords(facet.links).flatMap((item) => { + const link = mapLink(item, documentUrl); + return link ? [link] : []; + }), + })), + }; + const subtitle = localizableString(metadata.subtitle); + const search = mapSearch(links, documentUrl); + if (subtitle) feed.subtitle = subtitle; + if (nextHref) feed.nextUrl = resolveUrl(nextHref, documentUrl); + if (previousHref) feed.previousUrl = resolveUrl(previousHref, documentUrl); + if (search) feed.search = search; + return feed; +} + +function validateArrayProperty(value: UnknownRecord, name: string): void { + if (name in value && !Array.isArray(value[name])) throw new Error("Invalid OPDS 2 catalog"); +} + +function getArrayProperty(value: UnknownRecord, name: string): unknown[] { + validateArrayProperty(value, name); + const property = value[name]; + return Array.isArray(property) ? property : []; +} + +function validateLink(value: unknown): void { + if (!isRecord(value) || typeof value.href !== "string" || value.href.length === 0) { + throw new Error("Invalid OPDS 2 catalog"); + } + if ("type" in value && typeof value.type !== "string") { + throw new Error("Invalid OPDS 2 catalog"); + } + if ("title" in value && typeof value.title !== "string") { + throw new Error("Invalid OPDS 2 catalog"); + } + if ( + "rel" in value && + typeof value.rel !== "string" && + !(Array.isArray(value.rel) && value.rel.every((item) => typeof item === "string")) + ) { + throw new Error("Invalid OPDS 2 catalog"); + } +} + +function validateNavigation(value: UnknownRecord): void { + for (const item of getArrayProperty(value, "navigation")) { + validateLink(item); + if (!isRecord(item) || typeof item.title !== "string") + throw new Error("Invalid OPDS 2 catalog"); + } +} + +function validatePublication(value: unknown): void { + if (!isRecord(value) || !isRecord(value.metadata) || !isLocalizableString(value.metadata.title)) { + throw new Error("Invalid OPDS 2 catalog"); + } + const links = getArrayProperty(value, "links"); + for (const link of links) validateLink(link); + for (const image of getArrayProperty(value, "images")) validateLink(image); + const readingOrder = getArrayProperty(value, "readingOrder"); + for (const link of readingOrder) validateLink(link); + if (!links.some(isAcquisitionLink) && readingOrder.length === 0) { + throw new Error("Invalid OPDS 2 catalog"); + } +} + +function validateGroup(value: unknown): void { + if (!isRecord(value) || !isRecord(value.metadata) || !isLocalizableString(value.metadata.title)) { + throw new Error("Invalid OPDS 2 catalog"); + } + if ("groups" in value || "facets" in value) throw new Error("Invalid OPDS 2 catalog"); + for (const link of getArrayProperty(value, "links")) validateLink(link); + + const hasNavigation = "navigation" in value; + const hasPublications = "publications" in value; + if (hasNavigation === hasPublications) throw new Error("Invalid OPDS 2 catalog"); + if (hasNavigation) { + validateNavigation(value); + if (getArrayProperty(value, "navigation").length === 0) + throw new Error("Invalid OPDS 2 catalog"); + } else { + const publications = getArrayProperty(value, "publications"); + if (publications.length === 0) throw new Error("Invalid OPDS 2 catalog"); + for (const publication of publications) validatePublication(publication); + } +} + +function validateFeed(value: unknown): asserts value is UnknownRecord { + if (!isRecord(value) || !isRecord(value.metadata) || !isLocalizableString(value.metadata.title)) { + throw new Error("Invalid OPDS 2 catalog"); + } + for (const name of ["links", "navigation", "publications", "groups", "facets"]) { + validateArrayProperty(value, name); + } + const links = getArrayProperty(value, "links"); + for (const link of links) validateLink(link); + if (!links.some((link) => isRecord(link) && normalizeRel(link.rel).includes("self"))) { + throw new Error("Invalid OPDS 2 catalog"); + } + + const navigation = getArrayProperty(value, "navigation"); + const publications = getArrayProperty(value, "publications"); + const groups = getArrayProperty(value, "groups"); + if (navigation.length + publications.length + groups.length === 0) { + throw new Error("Invalid OPDS 2 catalog"); + } + + validateNavigation(value); + for (const publication of getArrayProperty(value, "publications")) { + validatePublication(publication); + } + for (const group of groups) validateGroup(group); + for (const facet of getArrayProperty(value, "facets")) { + if ( + !isRecord(facet) || + !isRecord(facet.metadata) || + !isLocalizableString(facet.metadata.title) + ) { + throw new Error("Invalid OPDS 2 catalog"); + } + const facetLinks = getArrayProperty(facet, "links"); + if (facetLinks.length === 0) throw new Error("Invalid OPDS 2 catalog"); + for (const link of facetLinks) validateLink(link); + } +} + +function removeDoctypeAndEntityReferences(body: string): string { + const withoutDoctype = body.replace(/\[]|\[[\s\S]*?\])*>/gi, ""); + return withoutDoctype.replace(/&(?!(?:amp|lt|gt|quot|apos);)[A-Za-z_][\w.:-]*;/g, ""); +} + +function getElementChildren(node: Node): Element[] { + return Array.from(node.childNodes).filter((child): child is Element => child.nodeType === 1); +} + +function hasOnlyNamespaceLessAtomStructure(root: Element): boolean { + const feedElements = new Set([ + "id", + "title", + "updated", + "author", + "link", + "category", + "contributor", + "generator", + "icon", + "logo", + "rights", + "subtitle", + "entry", + ]); + const entryElements = new Set([ + "id", + "title", + "updated", + "author", + "link", + "category", + "content", + "contributor", + "published", + "rights", + "source", + "summary", + ]); + const feedChildren = getElementChildren(root); + if ( + feedChildren.some( + (child) => feedElements.has(child.localName) && (child.namespaceURI || null) !== null, + ) + ) { + return false; + } + return feedChildren + .filter((child) => child.localName === "entry" && (child.namespaceURI || null) === null) + .every((entry) => + getElementChildren(entry).every( + (child) => !entryElements.has(child.localName) || (child.namespaceURI || null) === null, + ), + ); +} + +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 }; +} + +interface DirectAtomLink { + element: Element; + owner: "feed" | "entry"; +} + +function getDirectAtomLinks(root: Element): DirectAtomLink[] { + 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")) + .map((element) => ({ element, owner: "feed" as const })), + ...feedChildren + .filter((child) => belongsToFeed(child, "entry")) + .flatMap((entry) => + 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({ + errorHandler: { + warning: (message) => errors.push(message), + error: (message) => errors.push(message), + fatalError: (message) => errors.push(message), + }, + }).parseFromString(removeDoctypeAndEntityReferences(body), "application/xml"); + const root = document.documentElement; + const rootNamespace = root.namespaceURI || null; + const namespaceIsSupported = rootNamespace === null || rootNamespace === ATOM_NAMESPACE; + const namespaceLessChildrenAreCompatible = + rootNamespace !== null || hasOnlyNamespaceLessAtomStructure(root); + if ( + errors.length > 0 || + root.localName !== "feed" || + !namespaceIsSupported || + !namespaceLessChildrenAreCompatible + ) { + throw new Error("Invalid OPDS XML document"); + } + + 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 ( + (owner === "entry" && classifyOpdsAcquisitionRelation(rel) !== undefined) || + (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") + ); + }); + if (!hasOpdsSemantics) throw new Error("Invalid OPDS XML document"); + + try { + const normalized = getFeed(document as unknown as Document); + return mapFeed(normalized, documentUrl); + } catch (error) { + if (error instanceof Error && error.message === "Invalid OPDS XML document") throw error; + throw new Error("Invalid OPDS XML document"); + } +} + +function parseJson(body: string, documentUrl: string): OpdsFeed { + let value: unknown; + try { + value = JSON.parse(body); + } catch { + throw new Error("Invalid OPDS JSON document"); + } + validateFeed(value); + return mapFeed(value, documentUrl); +} + +export function parseOpdsDocument( + body: string, + contentType: string, + documentUrl: string, +): OpdsFeed { + const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType === "application/opds+json" || mediaType === "application/json") { + return parseJson(body, documentUrl); + } + return parseXml(body, 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-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; + } + }, + }; +} diff --git a/packages/core/src/opds/opds-sanitize.test.ts b/packages/core/src/opds/opds-sanitize.test.ts new file mode 100644 index 000000000..5a7768e60 --- /dev/null +++ b/packages/core/src/opds/opds-sanitize.test.ts @@ -0,0 +1,97 @@ +import { DOMParser } from "@xmldom/xmldom"; +import { describe, expect, it } from "vitest"; +import { sanitizeOpdsDescription } from "./opds-sanitize"; + +const ALLOWED_ELEMENTS = new Set(["p", "br", "em", "strong", "ul", "ol", "li", "blockquote", "a"]); + +function getSanitizedElements(output: string): Element[] { + const xml = `${output.replace(/
/g, "
")}
`; + const document = new DOMParser().parseFromString(xml, "application/xml"); + return Array.from(document.getElementsByTagName("*")).slice(1) as unknown as Element[]; +} + +describe("sanitizeOpdsDescription", () => { + it("retains only the safe description markup allowlist", () => { + const input = `
+

Hello
there reader

+
  • One
  • Two
+
  1. Three
+
Quoted
+
`; + + expect(sanitizeOpdsDescription(input)).toBe( + "\n

Hello
there reader

\n
  • One
  • Two
\n
  1. Three
\n
Quoted
\n ", + ); + }); + + it("removes executable and remotely embedded content", () => { + const input = + '

Safe tail

'; + + const result = sanitizeOpdsDescription(input); + expect(result).toBe("

Safe tail

"); + expect(result).not.toMatch(/script|style|iframe|img|onerror|evil\.test|alert/i); + }); + + it.each([ + ["relative", "chapters/1", "https://catalog.test/root/chapters/1"], + ["root relative", "/books/1", "https://catalog.test/books/1"], + ["fragment", "#details", "https://catalog.test/root/feed#details"], + ["http", "http://catalog.test/books/1", "http://catalog.test/books/1"], + ["https", "https://catalog.test/books/1", "https://catalog.test/books/1"], + ])("resolves a safe %s link", (_name, href, expected) => { + expect( + sanitizeOpdsDescription( + `Book`, + "https://catalog.test/root/feed", + ), + ).toBe(`Book`); + }); + + it.each([ + "javascript:alert(1)", + "JaVaScRiPt:alert(1)", + "javascript:alert(1)", + "java script:alert(1)", + "java script:alert(1)", + "data:text/html,evil", + "file:///etc/passwd", + "//evil.test", + ])("removes an unsafe link scheme: %s", (href) => { + expect(sanitizeOpdsDescription(`Book`, "https://catalog.test/feed")).toBe( + "Book", + ); + }); + + it("produces only allowlisted elements, attributes, and anchor protocols", () => { + const result = sanitizeOpdsDescription( + `

Safe
emstrong

+
  • one
  1. two
quote
+ safe link + unsafe link + + + svg linkx
`, + "https://catalog.test/root/feed", + ); + + const elements = getSanitizedElements(result); + expect(elements.length).toBeGreaterThan(0); + for (const element of elements) { + expect(ALLOWED_ELEMENTS.has(element.localName)).toBe(true); + for (const attribute of Array.from(element.attributes)) { + expect(element.localName).toBe("a"); + expect(["href", "target", "rel"]).toContain(attribute.name); + } + const href = element.getAttribute("href"); + if (href) expect(["http:", "https:"]).toContain(new URL(href).protocol); + } + expect(result).not.toMatch( + /script|style=|onclick|onmouseover|iframe|object|embed|img|svg|math|evil\.test/i, + ); + }); + + it("escapes plain text that resembles markup", () => { + expect(sanitizeOpdsDescription("2 < 3 & 5 > 4")).toBe("2 < 3 & 5 > 4"); + }); +}); diff --git a/packages/core/src/opds/opds-sanitize.ts b/packages/core/src/opds/opds-sanitize.ts new file mode 100644 index 000000000..3fe061abc --- /dev/null +++ b/packages/core/src/opds/opds-sanitize.ts @@ -0,0 +1,148 @@ +const ALLOWED_ELEMENTS = new Set(["p", "br", "em", "strong", "ul", "ol", "li", "blockquote", "a"]); + +const DROP_CONTENT_ELEMENTS = new Set([ + "script", + "style", + "iframe", + "object", + "embed", + "svg", + "math", + "video", + "audio", + "canvas", +]); + +const DROP_VOID_ELEMENTS = new Set(["img", "input", "link", "meta", "source", "track"]); +const VOID_ALLOWED_ELEMENTS = new Set(["br"]); + +function decodeEntities(value: string): string { + return value.replace(/&(#(?:x[\da-f]+|\d+)|amp|lt|gt|quot|apos);/gi, (_entity, name: string) => { + const normalized = name.toLowerCase(); + if (normalized === "amp") return "&"; + if (normalized === "lt") return "<"; + if (normalized === "gt") return ">"; + if (normalized === "quot") return '"'; + if (normalized === "apos") return "'"; + + const hexadecimal = normalized.startsWith("#x"); + const codePoint = Number.parseInt(normalized.slice(hexadecimal ? 2 : 1), hexadecimal ? 16 : 10); + if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return ""; + try { + return String.fromCodePoint(codePoint); + } catch { + return ""; + } + }); +} + +function escapeText(value: string): string { + return decodeEntities(value).replace(/&/g, "&").replace(//g, ">"); +} + +function escapeAttribute(value: string): string { + return escapeText(value).replace(/"/g, """); +} + +function getSafeHref(attributeSource: string, documentUrl?: string): string | undefined { + const match = attributeSource.match(/(?:^|\s)href\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/i); + const href = decodeEntities(match?.[1] ?? match?.[2] ?? match?.[3] ?? "").trim(); + const hasControlCharacter = Array.from(href).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }); + if (!href || href.startsWith("//") || href.includes("\\") || hasControlCharacter) { + return undefined; + } + + try { + const resolved = new URL(href, documentUrl); + return resolved.protocol === "http:" || resolved.protocol === "https:" + ? resolved.href + : undefined; + } catch { + return undefined; + } +} + +/** + * Sanitizes an untrusted OPDS HTML fragment without attaching it to a browser DOM. + */ +export function sanitizeOpdsDescription(input: string, documentUrl?: string): string { + const tokens = input.match(/|<\/?[A-Za-z][^>]*>|[^<]+| 0) { + if (closing && droppedStack[droppedStack.length - 1] === name) droppedStack.pop(); + else if (!closing && DROP_CONTENT_ELEMENTS.has(name)) droppedStack.push(name); + continue; + } + + if (DROP_VOID_ELEMENTS.has(name)) continue; + if (DROP_CONTENT_ELEMENTS.has(name)) { + if (!closing) droppedStack.push(name); + continue; + } + if (!ALLOWED_ELEMENTS.has(name)) continue; + + if (closing) { + if (VOID_ALLOWED_ELEMENTS.has(name)) continue; + const index = allowedStack.lastIndexOf(name); + if (index === -1) continue; + while (allowedStack.length > index) { + output.push(``); + } + continue; + } + + if (name === "a") { + const href = getSafeHref(token.slice(token.indexOf(name) + name.length), documentUrl); + output.push( + href + ? `` + : "", + ); + } else { + output.push(`<${name}>`); + } + if (!VOID_ALLOWED_ELEMENTS.has(name)) allowedStack.push(name); + } + + 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; +} diff --git a/packages/core/src/opds/opds-security.test.ts b/packages/core/src/opds/opds-security.test.ts new file mode 100644 index 000000000..690adb0f0 --- /dev/null +++ b/packages/core/src/opds/opds-security.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { classifyOpdsUrl } from "./opds-security"; + +describe("classifyOpdsUrl", () => { + it.each([ + "https://catalog.example/opds", + "https://203.0.113.7/opds", + "https://[2001:db8::7]/opds", + ])("allows HTTPS globally", (url) => { + expect(classifyOpdsUrl(url)).toEqual({ + allowed: true, + requiresInsecureConfirmation: false, + }); + }); + + it.each([ + "http://localhost:8080/opds", + "http://localhost./opds", + "http://calibre.local/opds", + "http://calibre.local./opds", + "http://127.0.0.1/opds", + "http://127.255.255.254/opds", + "http://10.0.0.2/opds", + "http://172.16.0.1/opds", + "http://172.31.255.254/opds", + "http://192.168.1.5:8080/opds", + "http://169.254.10.20/opds", + "http://2130706433/opds", + "http://0x7f000001/opds", + "http://[::1]/opds", + "http://[fc00::1]/opds", + "http://[fdff:ffff::1]/opds", + "http://[fe80::1]/opds", + "http://[febf:ffff::1]/opds", + "http://[::ffff:127.0.0.1]/opds", + "http://[::ffff:192.168.1.10]/opds", + ])("allows local HTTP with confirmation: %s", (url) => { + expect(classifyOpdsUrl(url)).toEqual({ + allowed: true, + requiresInsecureConfirmation: true, + }); + }); + + it.each([ + "http://catalog.example/opds", + "http://.local/opds", + "http://example.localhost/opds", + "http://localhost.example/opds", + "http://calibre.local.example/opds", + "http://127.0.0.1.example/opds", + "http://10.0.0.1.nip.io/opds", + "http://126.255.255.255/opds", + "http://128.0.0.1/opds", + "http://172.15.255.255/opds", + "http://172.32.0.0/opds", + "http://192.167.255.255/opds", + "http://192.169.0.0/opds", + "http://169.253.255.255/opds", + "http://169.255.0.0/opds", + "http://[fbff:ffff::1]/opds", + "http://[fec0::1]/opds", + "http://[2001:db8::1]/opds", + "http://[::ffff:8.8.8.8]/opds", + ])("rejects public HTTP and local-looking hostnames: %s", (url) => { + expect(classifyOpdsUrl(url)).toMatchObject({ + allowed: false, + requiresInsecureConfirmation: false, + }); + }); + + it.each([ + "https://user:password@catalog.example/opds", + "https://user@catalog.example/opds", + "http://user:password@127.0.0.1/opds", + ])("rejects embedded URL credentials: %s", (url) => { + expect(classifyOpdsUrl(url)).toMatchObject({ + allowed: false, + requiresInsecureConfirmation: false, + }); + }); + + it.each([ + "file:///etc/passwd", + "ftp://catalog.example/opds", + "data:application/atom+xml,%3Cfeed%3E", + "not a URL", + "http://[fe80::1%25eth0]/opds", + ])("rejects unsupported or malformed URLs: %s", (url) => { + expect(classifyOpdsUrl(url)).toMatchObject({ + allowed: false, + requiresInsecureConfirmation: false, + }); + }); +}); diff --git a/packages/core/src/opds/opds-security.ts b/packages/core/src/opds/opds-security.ts new file mode 100644 index 000000000..a0545b095 --- /dev/null +++ b/packages/core/src/opds/opds-security.ts @@ -0,0 +1,123 @@ +export interface OpdsUrlClassification { + allowed: boolean; + requiresInsecureConfirmation: boolean; + reason?: "credentials-not-allowed" | "unsupported-scheme" | "public-http" | "invalid-url"; +} + +function denied(reason: NonNullable): OpdsUrlClassification { + return { allowed: false, requiresInsecureConfirmation: false, reason }; +} + +function hasUserInfo(value: string): boolean { + const authority = /^[a-z][a-z\d+.-]*:\/\/([^/?#]*)/i.exec(value)?.[1]; + return authority?.includes("@") ?? false; +} + +function parseIpv4(hostname: string): number[] | undefined { + const parts = hostname.split("."); + if (parts.length !== 4) return undefined; + const octets = parts.map(Number); + return octets.every((octet, index) => /^\d+$/.test(parts[index] ?? "") && octet <= 255) + ? octets + : undefined; +} + +function isLocalIpv4(octets: number[]): boolean { + const [first, second] = octets; + return ( + first === 10 || + first === 127 || + (first === 172 && second !== undefined && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 169 && second === 254) + ); +} + +function parseIpv6(hostname: string): number[] | undefined { + const value = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (!value || value.includes("%") || value.split("::").length > 2) return undefined; + + const expandPart = (part: string): string[] | undefined => { + if (!part) return []; + const segments = part.split(":"); + const last = segments[segments.length - 1]; + if (last?.includes(".")) { + const ipv4 = parseIpv4(last); + if (!ipv4) return undefined; + segments.splice( + -1, + 1, + ((ipv4[0] ?? 0) * 256 + (ipv4[1] ?? 0)).toString(16), + ((ipv4[2] ?? 0) * 256 + (ipv4[3] ?? 0)).toString(16), + ); + } + return segments; + }; + + const [leftValue, rightValue] = value.split("::"); + const left = expandPart(leftValue ?? ""); + const right = expandPart(rightValue ?? ""); + if (!left || !right) return undefined; + const missing = 8 - left.length - right.length; + if ((value.includes("::") && missing < 1) || (!value.includes("::") && missing !== 0)) { + return undefined; + } + + const segments = [...left, ...Array.from({ length: missing }, () => "0"), ...right]; + if (segments.length !== 8 || segments.some((segment) => !/^[\da-f]{1,4}$/.test(segment))) { + return undefined; + } + return segments.map((segment) => Number.parseInt(segment, 16)); +} + +function isLocalIpv6(segments: number[]): boolean { + const first = segments[0] ?? 0; + const isLoopback = segments.slice(0, 7).every((segment) => segment === 0) && segments[7] === 1; + const isUniqueLocal = (first & 0xfe00) === 0xfc00; + const isLinkLocal = (first & 0xffc0) === 0xfe80; + const isIpv4Mapped = + segments.slice(0, 5).every((segment) => segment === 0) && segments[5] === 0xffff; + if (isIpv4Mapped) { + const high = segments[6] ?? 0; + const low = segments[7] ?? 0; + return isLocalIpv4([high >> 8, high & 0xff, low >> 8, low & 0xff]); + } + return isLoopback || isUniqueLocal || isLinkLocal; +} + +function isLocalHttpHost(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/\.$/, ""); + if ( + normalized === "localhost" || + (normalized.length > ".local".length && normalized.endsWith(".local")) + ) { + return true; + } + const ipv4 = parseIpv4(normalized); + if (ipv4) return isLocalIpv4(ipv4); + const ipv6 = parseIpv6(normalized); + return ipv6 ? isLocalIpv6(ipv6) : false; +} + +/** + * Applies a syntactic URL policy only. It deliberately does not resolve or pin DNS, and must not + * be described as protection against DNS rebinding for otherwise allowed HTTPS hostnames. + */ +export function classifyOpdsUrl(value: string): OpdsUrlClassification { + let url: URL; + try { + url = new URL(value); + } catch { + return denied("invalid-url"); + } + + if (hasUserInfo(value) || url.username || url.password) { + return denied("credentials-not-allowed"); + } + if (url.protocol === "https:") { + return { allowed: true, requiresInsecureConfirmation: false }; + } + if (url.protocol !== "http:") return denied("unsupported-scheme"); + if (!isLocalHttpHost(url.hostname)) return denied("public-http"); + return { allowed: true, requiresInsecureConfirmation: true }; +} diff --git a/packages/core/src/opds/opds-types.ts b/packages/core/src/opds/opds-types.ts new file mode 100644 index 000000000..b8e75eb1d --- /dev/null +++ b/packages/core/src/opds/opds-types.ts @@ -0,0 +1,86 @@ +import type { BookFormat } from "../types/book"; +import type { OpdsAcquisitionRelation } from "./opds-relations"; + +export interface OpdsLink { + rel: string[]; + url: string; + type?: string; + title?: string; +} + +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 { + id?: string; + title: string; + authors: string[]; + publisher?: string; + language?: string; + identifier?: string; + published?: string; + description?: string; + subjects: string[]; + images: OpdsLink[]; + acquisitions: OpdsAcquisition[]; + readingOrder: OpdsLink[]; +} + +export interface OpdsNavigationItem { + title: string; + url: string; +} + +export interface OpdsFacet { + title: string; + links: OpdsLink[]; +} + +export type OpdsSearchDescriptor = + | { + kind: "template"; + urlTemplate: string; + title?: string; + type?: string; + } + | { + kind: "openSearch"; + descriptorUrl: string; + title?: string; + type?: string; + }; + +export interface OpdsFeed { + title: string; + subtitle?: string; + navigation: OpdsNavigationItem[]; + publications: OpdsPublication[]; + groups: OpdsFeed[]; + facets: OpdsFacet[]; + nextUrl?: string; + previousUrl?: string; + search?: OpdsSearchDescriptor; +} + +export interface OpdsCredentials { + username: string; + password: string; + catalogOrigin: string; +} + +export type OpdsErrorCode = + | "unauthorized" + | "unsupported-auth" + | "insecure-url" + | "unreachable" + | "invalid-catalog" + | "cancelled" + | "too-large" + | "unsupported-acquisition" + | "download-failed" + | "asset-too-large" + | "download-in-progress" + | "import-failed"; 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/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 20ac14a37..f5ae7973a 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -3,6 +3,7 @@ export type { IDatabase, IWebSocket, FetchOptions, + PlatformFetchResponse, FileTransferOptions, FilePickerOptions, WebSocketOptions, diff --git a/packages/core/src/services/platform.ts b/packages/core/src/services/platform.ts index 8de89e345..b281194b3 100644 --- a/packages/core/src/services/platform.ts +++ b/packages/core/src/services/platform.ts @@ -29,6 +29,14 @@ export interface FetchOptions extends RequestInit { onDownloadProgress?: (loaded: number, total: number) => void; } +/** A platform response may expose transport cleanup separately from its body stream. */ +export interface PlatformFetchResponse extends Response { + /** Abort the native request backing this response, even when its body stream was already cancelled. */ + cancelTransport?: () => void; + /** Release signal listeners and other transport bookkeeping after normal body completion. */ + onDispose?: () => void; +} + export interface FileTransferOptions { headers?: Record; allowInsecure?: boolean; @@ -90,7 +98,7 @@ export interface IPlatformService { loadDatabase(path: string): Promise; // ---- Network (for scenarios requiring custom headers) ---- - fetch(url: string, options?: FetchOptions): Promise; + fetch(url: string, options?: FetchOptions): Promise; downloadFile?(url: string, filePath: string, options?: FileTransferOptions): Promise; uploadFile?(url: string, filePath: string, options?: FileTransferOptions): Promise; createWebSocket(url: string, options?: WebSocketOptions): Promise; @@ -109,6 +117,12 @@ export interface IPlatformService { kvRemoveItem(key: string): Promise; kvGetAllKeys(): Promise; + // ---- Secret Storage (device-local OS credential storage) ---- + // Secrets are intentionally separate from general KV persistence and are never synced. + secretGetItem?(key: string): Promise; + secretSetItem?(key: string, value: string): Promise; + secretRemoveItem?(key: string): Promise; + // ---- Clipboard ---- // Web: navigator.clipboard, RN: expo-clipboard copyToClipboard(content: string): Promise; 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..99d820b28 --- /dev/null +++ b/packages/core/src/utils/book-metadata.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it } from "vitest"; +import type { Book } from "../types"; +import { + applyBookMetadataFormUpdate, + buildBookMetadataUpdate, + hasMissingBookMetadataAutoFillTargets, + 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("preserves every populated saved value verbatim while filling saved blanks", () => { + const reviews = [{ id: "review-1", content: "Keep exactly", createdAt: 1, updatedAt: 2 }]; + + expect( + mergeBookMetadataSources( + { + title: " Saved Title ", + author: "", + publisher: " Saved Press ", + language: "en-US", + isbn: " ISBN 978-1-4028-9462-6 ", + publishDate: " 2020-4-3 ", + description: " Saved description ", + coverUrl: " covers/saved.webp ", + subjects: [" History ", "History"], + rating: 4, + reviews, + totalPages: 321, + totalChapters: 17, + }, + { + title: "Catalog title", + author: " Catalog author ", + publisher: "Catalog Press", + language: "fr-FR", + isbn: "9781402894626", + publishDate: "2024-8-6", + description: "Catalog description", + coverUrl: "covers/catalog.jpg", + subjects: ["Catalog"], + }, + ), + ).toEqual({ + title: " Saved Title ", + author: "Catalog author", + publisher: " Saved Press ", + language: "en-US", + isbn: " ISBN 978-1-4028-9462-6 ", + publishDate: " 2020-4-3 ", + description: " Saved description ", + coverUrl: " covers/saved.webp ", + subjects: [" History ", "History"], + rating: 4, + reviews, + totalPages: 321, + totalChapters: 17, + }); + }); + + it("accepts valid ISBNs but rejects generic UIDs and UUIDs from imported sources", () => { + expect(mergeBookMetadataSources(undefined, { isbn: "123456" })).toEqual({}); + expect(mergeBookMetadataSources(undefined, { isbn: "97814028946260" })).toEqual({}); + expect( + mergeBookMetadataSources(undefined, { + isbn: "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + }), + ).toEqual({}); + expect(mergeBookMetadataSources(undefined, { isbn: "urn:isbn:978-1-4028-9462-6" })).toEqual({ + isbn: "9781402894626", + }); + }); + + it("accepts only complete publication date formats and valid calendar dates", () => { + expect(mergeBookMetadataSources(undefined, { publishDate: "2024" })).toEqual({ + publishDate: "2024", + }); + expect(mergeBookMetadataSources(undefined, { publishDate: "2024-02" })).toEqual({ + publishDate: "2024-02", + }); + expect(mergeBookMetadataSources(undefined, { publishDate: "2024-02-29" })).toEqual({ + publishDate: "2024-02-29", + }); + expect(mergeBookMetadataSources(undefined, { publishDate: "2000-02-29" })).toEqual({ + publishDate: "2000-02-29", + }); + expect(mergeBookMetadataSources(undefined, { publishDate: "2020-4-3" })).toEqual({ + publishDate: "2020-04-03", + }); + expect(mergeBookMetadataSources(undefined, { publishDate: "2020/4/3" })).toEqual({ + publishDate: "2020-04-03", + }); + expect(mergeBookMetadataSources(undefined, { publishDate: "2020.4" })).toEqual({ + publishDate: "2020-04", + }); + }); + + it("rejects impossible, incomplete, and trailing-junk publication dates", () => { + for (const publishDate of [ + "2024-00", + "2024-13", + "2023-02-29", + "1900-02-29", + "2024-02-30", + "2024-04-31", + "2024/02-29", + "2024-02-29T12:00:00Z", + "2024-02-29junk", + "2024/02/29junk", + ]) { + expect(mergeBookMetadataSources(undefined, { publishDate })).toEqual({}); + } + }); + + it("falls through an invalid higher-priority publication date", () => { + expect( + mergeBookMetadataSources( + undefined, + { publishDate: "2023-02-29" }, + { publishDate: "2024-02-29" }, + ), + ).toEqual({ publishDate: "2024-02-29" }); + }); +}); + +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(""); +}); + +it("does not request autofill when publication metadata is complete and tags are empty", () => { + expect( + hasMissingBookMetadataAutoFillTargets({ + title: "Book", + author: "Author", + coverUrl: "covers/book.jpg", + publisher: "Publisher", + language: "en", + isbn: "9781234567890", + publishDate: "2024", + rating: null, + description: "Description", + reviews: [], + subjectsText: "History", + tagsText: "", + groupId: "", + }), + ).toBe(false); +}); + +it("requests autofill for a missing cover and never replaces an existing cover", () => { + const values = { + title: "Book", + author: "Author", + coverUrl: "", + publisher: "Publisher", + language: "en", + isbn: "9781234567890", + publishDate: "2024", + rating: null, + description: "Description", + reviews: [], + subjectsText: "History", + tagsText: "", + groupId: "", + }; + + expect(hasMissingBookMetadataAutoFillTargets(values)).toBe(true); + expect( + mergeMissingBookMetadataValues(values, { coverUrl: "covers/extracted.jpg" })?.coverUrl, + ).toBe("covers/extracted.jpg"); + expect( + mergeMissingBookMetadataValues( + { ...values, coverUrl: "covers/user.jpg" }, + { coverUrl: "covers/extracted.jpg" }, + ), + ).toBeNull(); +}); + +it("atomically exposes a just-entered edit to an in-flight metadata merge", () => { + const book = { + id: "book-1", + format: "epub", + filePath: "books/book-1.epub", + meta: { title: "Book", author: "Author" }, + progress: 0, + addedAt: 1, + } as Book; + const ref = { + current: { + title: "Book", + author: "Author", + coverUrl: "covers/book.jpg", + publisher: "", + language: "", + isbn: "", + publishDate: "", + rating: null, + description: "", + reviews: [], + subjectsText: "", + tagsText: "", + groupId: "", + }, + }; + let rendered = ref.current; + + applyBookMetadataFormUpdate( + ref, + (next) => { + rendered = next; + }, + (current) => ({ ...current, publisher: "User press" }), + ); + const repaired = mergeMissingBookMetadataValues(ref.current, { + publisher: "Extracted press", + language: "fr", + }); + const finalValues = repaired ?? ref.current; + const persisted = buildBookMetadataUpdate(book, finalValues); + + expect(rendered.publisher).toBe("User press"); + expect(persisted.meta.publisher).toBe("User press"); + expect(persisted.meta.language).toBe("fr"); +}); diff --git a/packages/core/src/utils/book-metadata.ts b/packages/core/src/utils/book-metadata.ts index fbca6c4db..a4e7079f8 100644 --- a/packages/core/src/utils/book-metadata.ts +++ b/packages/core/src/utils/book-metadata.ts @@ -20,6 +20,7 @@ export interface BookMetadataFormValues { export interface ExtractedBookMetadata { title?: string; author?: string; + coverUrl?: string; publisher?: string; language?: string; isbn?: string; @@ -28,6 +29,55 @@ export interface ExtractedBookMetadata { subjects?: string[]; } +export function mergeBookMetadataSources( + ...sources: Array | ExtractedBookMetadata | null | undefined> +): Partial { + const [saved, ...fillSources] = sources; + const result: Partial = saved ? { ...saved } : {}; + const publicationKeys: Array = [ + "title", + "author", + "coverUrl", + "publisher", + "language", + "isbn", + "publishDate", + "description", + "subjects", + ]; + + for (const key of publicationKeys) { + const value = result[key]; + const populated = Array.isArray(value) + ? value.some((item) => typeof item === "string" && item.trim()) + : typeof value === "string" && Boolean(value.trim()); + if (!populated) delete result[key]; + } + + 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 fillSources) { + 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 || "", @@ -48,13 +98,13 @@ export function createBookMetadataFormValues(book: Book): BookMetadataFormValues export function hasMissingBookMetadataAutoFillTargets(values: BookMetadataFormValues): boolean { return ( + !values.coverUrl.trim() || !values.publisher.trim() || !values.language.trim() || !values.isbn.trim() || !values.publishDate.trim() || !values.description.trim() || - !values.subjectsText.trim() || - !values.tagsText.trim() + !values.subjectsText.trim() ); } @@ -78,6 +128,7 @@ export function mergeMissingBookMetadataValues( fillText("title", extracted.title); fillText("author", extracted.author); + fillText("coverUrl", extracted.coverUrl); fillText("publisher", extracted.publisher); fillText("language", normalizeBookLanguage(extracted.language)); fillText("isbn", normalizeIsbn(extracted.isbn)); @@ -91,15 +142,24 @@ export function mergeMissingBookMetadataValues( next.subjectsText = subjectsText; changed = true; } - if (!next.tagsText.trim()) { - next.tagsText = subjectsText; - changed = true; - } } return changed ? next : null; } +export function applyBookMetadataFormUpdate( + valuesRef: { current: BookMetadataFormValues | null }, + setValues: (values: BookMetadataFormValues) => void, + update: BookMetadataFormValues | ((current: BookMetadataFormValues) => BookMetadataFormValues), +): BookMetadataFormValues | null { + const current = valuesRef.current; + if (!current && typeof update === "function") return null; + const next = typeof update === "function" ? update(current as BookMetadataFormValues) : update; + valuesRef.current = next; + setValues(next); + return next; +} + export function splitEditableList(value: string): string[] { const seen = new Set(); const items: string[] = []; @@ -137,23 +197,57 @@ function normalizeBookLanguage(value: unknown): string { return normalized; } -function normalizeIsbn(value: unknown): string { +export function normalizeIsbn(value: unknown): string { if (typeof value !== "string") return ""; - const match = value.match(/(?:97[89][-\s]?)?(?:\d[-\s]?){9,12}[\dXx]/); - return (match?.[0] ?? value).replace(/\s+/g, "").trim(); + const candidates = value.matchAll( + /(?:^|[^\dXx])((?:97[89](?:[-\s]*\d){10}|(?:\d[-\s]*){9}[\dXx]))(?![\dXx])/g, + ); + for (const match of candidates) { + const candidate = match[1]; + const compact = candidate.replace(/[-\s]/g, "").toUpperCase(); + if (isValidIsbn10(compact) || isValidIsbn13(compact)) return compact; + } + return ""; +} + +function isValidIsbn10(value: string): boolean { + if (!/^\d{9}[\dX]$/.test(value)) return false; + const sum = Array.from(value).reduce((total, char, index) => { + const digit = char === "X" ? 10 : Number(char); + return total + digit * (10 - index); + }, 0); + return sum % 11 === 0; +} + +function isValidIsbn13(value: string): boolean { + if (!/^97[89]\d{10}$/.test(value)) return false; + const sum = Array.from(value).reduce( + (total, char, index) => total + Number(char) * (index % 2 === 0 ? 1 : 3), + 0, + ); + return sum % 10 === 0; } function normalizePublishDate(value: unknown): string { if (typeof value !== "string") return ""; const trimmed = value.trim(); - const match = trimmed.match(/^(\d{4})(?:[-/.](\d{1,2})(?:[-/.](\d{1,2}))?)?/); + const match = trimmed.match(/^(\d{4})(?:([-/.])(\d{1,2})(?:\2(\d{1,2}))?)?$/); if (!match) return ""; const year = match[1]; - const month = match[2] ? match[2].padStart(2, "0") : ""; - const day = match[3] ? match[3].padStart(2, "0") : ""; - if (day && month) return `${year}-${month}-${day}`; - if (month) return `${year}-${month}`; - return year; + if (!match[3]) return year; + + const month = Number(match[3]); + if (month < 1 || month > 12) return ""; + const normalizedMonth = match[3].padStart(2, "0"); + if (!match[4]) return `${year}-${normalizedMonth}`; + + const numericYear = Number(year); + const isLeapYear = numericYear % 4 === 0 && (numericYear % 100 !== 0 || numericYear % 400 === 0); + const daysInMonth = [31, isLeapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + const day = Number(match[4]); + if (day < 1 || day > daysInMonth[month - 1]) return ""; + + return `${year}-${normalizedMonth}-${match[4].padStart(2, "0")}`; } function normalizeSubjects(values: unknown): string[] { diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 4c23e39cb..95dd1728a 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -51,12 +51,15 @@ export type { } from "./api"; export { encodeConfig, decodeConfig } from "./config-transfer"; export { + applyBookMetadataFormUpdate, buildBookMetadataUpdate, createEmptyBookReview, createBookMetadataFormValues, hasMissingBookMetadataAutoFillTargets, joinEditableList, + mergeBookMetadataSources, mergeMissingBookMetadataValues, + normalizeIsbn, normalizeRating, normalizeReviews, splitEditableList, diff --git a/packages/foliate-js/opds.js b/packages/foliate-js/opds.js index a97f5ab43..dc61cb078 100644 --- a/packages/foliate-js/opds.js +++ b/packages/foliate-js/opds.js @@ -40,6 +40,9 @@ const groupByArray = (arr, f) => { return map; }; +const getElementChildren = (node) => + Array.from(node.children ?? node.childNodes ?? []).filter((child) => child.nodeType === 1); + // https://www.rfc-editor.org/rfc/rfc7231#section-3.1.1 const parseMediaType = (str) => { if (!str) return null; @@ -77,7 +80,7 @@ const getContent = (el) => { const type = el.getAttribute("type") ?? "text"; const value = type === "xhtml" - ? el.innerHTML + ? (el.innerHTML ?? Array.from(el.childNodes ?? [], (child) => child.toString()).join("")) : type === "html" ? el.textContent.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&") : el.textContent; @@ -136,7 +139,7 @@ const getPerson = (person) => { export const getPublication = (entry) => { const filter = filterNS(useNS(entry.ownerDocument, NS.ATOM)); - const children = Array.from(entry.children); + const children = getElementChildren(entry); const filterDCEL = filterNS(NS.DC); const filterDCTERMS = filterNS(NS.DCTERMS); const filterDC = (x) => { @@ -147,6 +150,7 @@ export const getPublication = (entry) => { const links = children.filter(filter("link")).map(getLink); const linksByRel = groupByArray(links, (link) => link.rel); return { + id: children.find(filter("id"))?.textContent, metadata: { title: children.find(filter("title"))?.textContent ?? "", author: children.filter(filter("author")).map(getPerson), @@ -176,7 +180,7 @@ export const getPublication = (entry) => { export const getFeed = (doc) => { const ns = useNS(doc, NS.ATOM); const filter = filterNS(ns); - const children = Array.from(doc.documentElement.children); + const children = getElementChildren(doc.documentElement); const entries = children.filter(filter("entry")); const links = children.filter(filter("link")).map(getLink); const linksByRel = groupByArray(links, (link) => link.rel); @@ -184,7 +188,7 @@ export const getFeed = (doc) => { const groupedItems = new Map([[null, []]]); const groupLinkMap = new Map(); for (const entry of entries) { - const children = Array.from(entry.children); + const children = getElementChildren(entry); const links = children.filter(filter("link")).map(getLink); const linksByRel = groupByArray(links, (link) => link.rel); const isPub = [...linksByRel.keys()].some( @@ -253,7 +257,7 @@ export const getSearch = async (link) => { export const getOpenSearch = (doc) => { const defaultNS = doc.documentElement.namespaceURI; const filter = filterNS(defaultNS); - const children = Array.from(doc.documentElement.children); + const children = getElementChildren(doc.documentElement); const $$urls = children.filter(filter("Url")); const $url = $$urls.find((url) => isOPDSCatalog(url.getAttribute("type"))) ?? $$urls[0]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 560ef437a..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: @@ -550,6 +559,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + foliate-js: + specifier: workspace:* + version: link:../foliate-js i18next: specifier: ^25.8.13 version: 25.8.18(typescript@5.9.3) @@ -580,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: {} @@ -666,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==} @@ -1425,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'} @@ -1436,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'} @@ -1935,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 @@ -3902,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: @@ -4074,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==} @@ -4484,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'} @@ -4673,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'} @@ -5193,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==} @@ -5225,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==} @@ -5330,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==} @@ -5440,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'} @@ -6194,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==} @@ -6382,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'} @@ -6493,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'} @@ -6776,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: @@ -6799,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==} @@ -7476,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==} @@ -7623,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'} @@ -7759,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} @@ -7946,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==} @@ -8358,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==} @@ -8665,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==} @@ -8749,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==} @@ -8760,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==} @@ -8796,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 @@ -8878,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'} @@ -9242,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==} @@ -9261,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'} @@ -9273,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==} @@ -9376,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'} @@ -9392,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==} @@ -9508,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': {} @@ -10730,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 @@ -10740,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': @@ -11005,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': @@ -13532,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 @@ -13727,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 @@ -14191,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: {} @@ -14510,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: @@ -15074,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: @@ -15092,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 @@ -15173,6 +15467,8 @@ snapshots: dlv@1.1.3: {} + dom-accessibility-api@0.5.16: {} + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -15364,6 +15660,8 @@ snapshots: entities@7.0.1: {} + entities@8.0.0: {} + env-editor@0.4.2: {} env-paths@2.2.0: {} @@ -16356,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: @@ -16526,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: {} @@ -16683,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: {} @@ -16900,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: @@ -16920,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 @@ -18082,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 @@ -18263,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: @@ -18291,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: {} @@ -18383,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 @@ -18639,6 +18983,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@18.3.1: {} react-is@19.2.4: {} @@ -19215,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: {} @@ -19560,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: {} @@ -19663,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: @@ -19671,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: {} @@ -19755,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: @@ -19844,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 @@ -19963,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)) @@ -19987,6 +20355,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.2 + jsdom: 30.0.1 transitivePeerDependencies: - msw @@ -19996,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 @@ -20012,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 @@ -20020,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 @@ -20106,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 @@ -20117,6 +20512,8 @@ snapshots: xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} + xxhash-wasm@1.1.0: {} y18n@4.0.3: {}