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 ? (