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

- {draftActionResult.ok && parseDraftCreateResult(draftActionResult)?.ok ? ( + {draftActionResult.ok && + parseDraftCreateResult(draftActionResult)?.ok ? (