diff --git a/packages/app-expo/src/lib/book/auto-metadata.ts b/packages/app-expo/src/lib/book/auto-metadata.ts
index 61549549c..3401fd472 100644
--- a/packages/app-expo/src/lib/book/auto-metadata.ts
+++ b/packages/app-expo/src/lib/book/auto-metadata.ts
@@ -1,12 +1,21 @@
-import { extractBookMetadata } from "@/lib/book/metadata-extractor";
+import {
+ createRangeReadableFile,
+ extractBookMetadataFromFile,
+} from "@/lib/book/metadata-extractor";
import { getPlatformService } from "@readany/core/services";
import type { Book } from "@readany/core/types";
import type { ExtractedBookMetadata } from "@readany/core/utils";
+import type { ExtractedMeta } from "./metadata-extractor";
-const MOBILE_DETAILS_METADATA_MAX_BYTES = 32 * 1024 * 1024;
+export type MobileExtractedBookMetadata = ExtractedBookMetadata &
+ Pick;
-export async function extractLocalBookMetadata(book: Book): Promise {
- if (book.syncStatus === "remote" || book.format !== "epub" || !book.filePath) return null;
+export async function extractLocalBookMetadata(
+ book: Book,
+): Promise {
+ if (book.syncStatus === "remote" || !isRepairableFormat(book.format) || !book.filePath) {
+ return null;
+ }
try {
const platform = getPlatformService();
@@ -15,21 +24,21 @@ export async function extractLocalBookMetadata(book: Book): Promise MOBILE_DETAILS_METADATA_MAX_BYTES) {
- console.warn(
- `[BookMetadata] Skip details metadata for large EPUB: ${book.meta.title} (${fileSize} bytes)`,
- );
- return null;
- }
+ if (fileSize == null) return null;
- const fileName = book.filePath.split("/").pop() || `${book.id}.epub`;
- return extractBookMetadata(await platform.readFile(filePath), book.format, fileName);
+ const fileName = book.filePath.split(/[\\/]/).pop() || `${book.id}.${book.format}`;
+ const rangeReadable = await createRangeReadableFile(filePath, fileSize);
+ return extractBookMetadataFromFile(rangeReadable, book.format, fileName);
} catch (error) {
console.warn("[BookMetadata] Failed to extract local metadata:", error);
return null;
}
}
+function isRepairableFormat(format: Book["format"]): boolean {
+ return format === "epub" || format === "mobi" || format === "azw" || format === "azw3";
+}
+
function isRelativeAppPath(path: string): boolean {
return (
!path.startsWith("/") &&
@@ -39,8 +48,8 @@ function isRelativeAppPath(path: string): boolean {
);
}
-async function getMobileFileSize(path: string): Promise {
+async function getMobileFileSize(path: string): Promise {
const LegacyFileSystem = await import("expo-file-system/legacy");
const info = await LegacyFileSystem.getInfoAsync(path);
- return info.exists && !info.isDirectory ? (info.size ?? 0) : 0;
+ return info.exists && !info.isDirectory ? (info.size ?? 0) : null;
}
diff --git a/packages/app-expo/src/lib/book/cover-storage.test.ts b/packages/app-expo/src/lib/book/cover-storage.test.ts
new file mode 100644
index 000000000..bb7bbbe1b
--- /dev/null
+++ b/packages/app-expo/src/lib/book/cover-storage.test.ts
@@ -0,0 +1,113 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const platform = vi.hoisted(() => ({
+ getAppDataDir: vi.fn(async () => "/app"),
+ joinPath: vi.fn(async (...parts: string[]) => parts.join("/")),
+ mkdir: vi.fn(async () => undefined),
+ writeFile: vi.fn(async () => undefined),
+ deleteFile: vi.fn(async () => undefined),
+}));
+
+vi.mock("@readany/core/services", () => ({ getPlatformService: () => platform }));
+import * as coverStorage from "./cover-storage";
+
+describe("mobile cover file extensions", () => {
+ const getCoverFileExtension = (
+ coverStorage as typeof coverStorage & {
+ getCoverFileExtension?: (bytes: Uint8Array, mimeType?: string | null) => string;
+ }
+ ).getCoverFileExtension;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("maps recognized image MIME types", () => {
+ expect(getCoverFileExtension).toBeTypeOf("function");
+ if (!getCoverFileExtension) return;
+
+ expect(getCoverFileExtension(new Uint8Array(), "image/webp")).toBe("webp");
+ expect(getCoverFileExtension(new Uint8Array(), "image/gif")).toBe("gif");
+ expect(getCoverFileExtension(new Uint8Array(), "image/png")).toBe("png");
+ expect(getCoverFileExtension(new Uint8Array(), "image/jpeg")).toBe("jpg");
+ });
+
+ it("sniffs image bytes when the MIME type is absent", () => {
+ expect(getCoverFileExtension).toBeTypeOf("function");
+ if (!getCoverFileExtension) return;
+
+ expect(
+ getCoverFileExtension(
+ new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]),
+ ),
+ ).toBe("webp");
+ expect(getCoverFileExtension(new TextEncoder().encode("GIF89a"))).toBe("gif");
+ expect(
+ getCoverFileExtension(new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
+ ).toBe("png");
+ expect(getCoverFileExtension(new Uint8Array([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpg");
+ });
+
+ it("deletes only a newly extracted cover when a custom cover wins during persistence", async () => {
+ const saveExtractedCoverIfStillMissing = (
+ coverStorage as typeof coverStorage & {
+ saveExtractedCoverIfStillMissing?: (
+ bookId: string,
+ bytes: Uint8Array,
+ mimeType: string | null,
+ getCurrentCoverUrl: () => string | undefined,
+ ) => Promise;
+ }
+ ).saveExtractedCoverIfStillMissing;
+ expect(saveExtractedCoverIfStillMissing).toBeTypeOf("function");
+ if (!saveExtractedCoverIfStillMissing) return;
+
+ let currentCoverUrl = "";
+ platform.writeFile.mockImplementationOnce(async () => {
+ currentCoverUrl = "covers/book-custom-user.webp";
+ });
+
+ await expect(
+ saveExtractedCoverIfStillMissing(
+ "book",
+ new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]),
+ null,
+ () => currentCoverUrl,
+ ),
+ ).resolves.toBeUndefined();
+ expect(platform.deleteFile).toHaveBeenCalledWith("/app/covers/book.webp");
+ expect(platform.deleteFile).not.toHaveBeenCalledWith("/app/covers/book-custom-user.webp");
+ });
+
+ it("cleans an extracted cover when custom selection completes after persistence", async () => {
+ const commitCustomCover = (
+ coverStorage as typeof coverStorage & {
+ commitCustomCover?: (
+ bookId: string,
+ customCoverUrl: string,
+ persist: (coverUrl: string) => Promise,
+ ) => Promise;
+ }
+ ).commitCustomCover;
+ expect(commitCustomCover).toBeTypeOf("function");
+ if (!commitCustomCover) return;
+
+ await expect(
+ coverStorage.saveExtractedCoverIfStillMissing(
+ "book",
+ new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50]),
+ null,
+ () => "",
+ ),
+ ).resolves.toBe("covers/book.webp");
+
+ const persisted: string[] = [];
+ await commitCustomCover("book", "covers/book-custom-user.png", async (coverUrl) => {
+ persisted.push(coverUrl);
+ });
+
+ expect(persisted).toEqual(["covers/book-custom-user.png"]);
+ expect(platform.deleteFile).toHaveBeenCalledWith("/app/covers/book.webp");
+ expect(platform.deleteFile).not.toHaveBeenCalledWith("/app/covers/book-custom-user.png");
+ });
+});
diff --git a/packages/app-expo/src/lib/book/cover-storage.ts b/packages/app-expo/src/lib/book/cover-storage.ts
new file mode 100644
index 000000000..ded5a734f
--- /dev/null
+++ b/packages/app-expo/src/lib/book/cover-storage.ts
@@ -0,0 +1,123 @@
+import { getPlatformService } from "@readany/core/services";
+
+const extractedCoverPaths = new Map>();
+
+export async function saveCoverBytesToAppData(
+ bookId: string,
+ coverBytes: Uint8Array,
+ coverMimeType?: string | null,
+): Promise {
+ const platform = getPlatformService();
+ const appData = await platform.getAppDataDir();
+ const coversDir = await platform.joinPath(appData, "covers");
+ try {
+ await platform.mkdir(coversDir);
+ } catch {
+ // Directory may already exist.
+ }
+
+ const extension = getCoverFileExtension(coverBytes, coverMimeType);
+ const relativePath = `covers/${bookId}.${extension}`;
+ const absolutePath = await platform.joinPath(appData, relativePath);
+ await platform.writeFile(absolutePath, coverBytes);
+ return relativePath;
+}
+
+export function getCoverFileExtension(
+ coverBytes: Uint8Array,
+ coverMimeType?: string | null,
+): string {
+ switch (coverMimeType?.toLowerCase().split(";", 1)[0]?.trim()) {
+ case "image/webp":
+ return "webp";
+ case "image/gif":
+ return "gif";
+ case "image/png":
+ return "png";
+ case "image/jpg":
+ case "image/jpeg":
+ return "jpg";
+ }
+
+ if (coverBytes[0] === 0xff && coverBytes[1] === 0xd8 && coverBytes[2] === 0xff) return "jpg";
+ if (
+ coverBytes[0] === 0x89 &&
+ coverBytes[1] === 0x50 &&
+ coverBytes[2] === 0x4e &&
+ coverBytes[3] === 0x47
+ ) {
+ return "png";
+ }
+ if (
+ coverBytes[0] === 0x47 &&
+ coverBytes[1] === 0x49 &&
+ coverBytes[2] === 0x46 &&
+ coverBytes[3] === 0x38
+ ) {
+ return "gif";
+ }
+ if (
+ coverBytes[0] === 0x52 &&
+ coverBytes[1] === 0x49 &&
+ coverBytes[2] === 0x46 &&
+ coverBytes[3] === 0x46 &&
+ coverBytes[8] === 0x57 &&
+ coverBytes[9] === 0x45 &&
+ coverBytes[10] === 0x42 &&
+ coverBytes[11] === 0x50
+ ) {
+ return "webp";
+ }
+ return "jpg";
+}
+
+export async function saveExtractedCoverIfStillMissing(
+ bookId: string,
+ coverBytes: Uint8Array,
+ coverMimeType: string | null | undefined,
+ getCurrentCoverUrl: () => string | undefined,
+): Promise {
+ if (getCurrentCoverUrl()?.trim()) return undefined;
+
+ const relativePath = await saveCoverBytesToAppData(bookId, coverBytes, coverMimeType);
+ trackExtractedCover(bookId, relativePath);
+ if (!getCurrentCoverUrl()?.trim()) return relativePath;
+
+ await deleteTrackedExtractedCover(bookId, relativePath);
+ return undefined;
+}
+
+export async function commitCustomCover(
+ bookId: string,
+ customCoverUrl: string,
+ persist: (coverUrl: string) => Promise,
+): Promise {
+ await persist(customCoverUrl);
+ const paths = extractedCoverPaths.get(bookId);
+ if (!paths) return;
+
+ for (const relativePath of [...paths]) {
+ if (relativePath !== customCoverUrl) {
+ await deleteTrackedExtractedCover(bookId, relativePath);
+ }
+ }
+}
+
+function trackExtractedCover(bookId: string, relativePath: string): void {
+ const paths = extractedCoverPaths.get(bookId) ?? new Set();
+ paths.add(relativePath);
+ extractedCoverPaths.set(bookId, paths);
+}
+
+async function deleteTrackedExtractedCover(bookId: string, relativePath: string): Promise {
+ try {
+ const platform = getPlatformService();
+ const appData = await platform.getAppDataDir();
+ await platform.deleteFile(await platform.joinPath(appData, relativePath));
+ const paths = extractedCoverPaths.get(bookId);
+ paths?.delete(relativePath);
+ if (paths?.size === 0) extractedCoverPaths.delete(bookId);
+ } catch (error) {
+ console.warn("[BookMetadata] Failed to clean up rejected extracted cover:", error);
+ }
+}
diff --git a/packages/app-expo/src/lib/book/imported-book-meta.test.ts b/packages/app-expo/src/lib/book/imported-book-meta.test.ts
new file mode 100644
index 000000000..f3c5e8401
--- /dev/null
+++ b/packages/app-expo/src/lib/book/imported-book-meta.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from "vitest";
+import * as importedBookMeta from "./imported-book-meta";
+
+const { buildImportedBookMeta } = importedBookMeta;
+
+describe("buildImportedBookMeta", () => {
+ it("persists rich extracted metadata", () => {
+ expect(
+ buildImportedBookMeta({
+ existing: undefined,
+ opds: undefined,
+ embedded: {
+ title: "Book",
+ author: "Author",
+ publisher: "Press",
+ language: "en-US",
+ isbn: "978 1 4028 9462 6",
+ publishDate: "2020-4-3",
+ description: "Summary",
+ subjects: ["History"],
+ coverUrl: "covers/1.jpg",
+ },
+ fallbackTitle: "file",
+ }),
+ ).toMatchObject({
+ title: "Book",
+ author: "Author",
+ publisher: "Press",
+ language: "en",
+ isbn: "9781402894626",
+ publishDate: "2020-04-03",
+ description: "Summary",
+ subjects: ["History"],
+ coverUrl: "covers/1.jpg",
+ });
+ });
+
+ it("preserves restored values and lets OPDS fill blanks before embedded metadata", () => {
+ expect(
+ buildImportedBookMeta({
+ existing: { title: "Edited", author: "", publisher: "Saved" },
+ opds: { title: "Catalog", author: "Catalog Author", publisher: "Catalog Press" },
+ embedded: { author: "Embedded Author", language: "fr" },
+ fallbackTitle: "file",
+ }),
+ ).toMatchObject({
+ title: "Edited",
+ author: "Catalog Author",
+ publisher: "Saved",
+ language: "fr",
+ });
+ });
+
+ it("retains saved rating, reviews, and counts when filling import metadata", () => {
+ const reviews = [
+ {
+ id: "review-1",
+ content: "Keep this review",
+ createdAt: 1,
+ updatedAt: 2,
+ },
+ ];
+
+ expect(
+ buildImportedBookMeta({
+ existing: {
+ title: "",
+ author: "",
+ rating: 4,
+ reviews,
+ totalPages: 320,
+ totalChapters: 12,
+ },
+ opds: { rating: undefined, reviews: undefined, totalPages: undefined },
+ embedded: { title: "Imported", author: "Author" },
+ fallbackTitle: "file",
+ }),
+ ).toMatchObject({
+ title: "Imported",
+ author: "Author",
+ rating: 4,
+ reviews,
+ totalPages: 320,
+ totalChapters: 12,
+ });
+ });
+
+ it("restores saved publication values byte-for-byte while catalog metadata fills blanks", () => {
+ expect(
+ buildImportedBookMeta({
+ existing: {
+ title: " Saved Mobile Title ",
+ author: "",
+ publisher: " Saved Mobile Press ",
+ language: "en-US",
+ isbn: " ISBN 978-1-4028-9462-6 ",
+ publishDate: " 2020-4-3 ",
+ description: " Saved mobile description ",
+ subjects: [" History ", "History"],
+ },
+ opds: { author: " Catalog author ", language: "fr-FR" },
+ embedded: { author: "Embedded author" },
+ fallbackTitle: "filename",
+ }),
+ ).toMatchObject({
+ title: " Saved Mobile Title ",
+ author: "Catalog author",
+ publisher: " Saved Mobile Press ",
+ language: "en-US",
+ isbn: " ISBN 978-1-4028-9462-6 ",
+ publishDate: " 2020-4-3 ",
+ description: " Saved mobile description ",
+ subjects: [" History ", "History"],
+ });
+ });
+
+ it("skips embedded cover persistence when saved or OPDS metadata owns the cover", () => {
+ const shouldPersistEmbeddedCover = (
+ importedBookMeta as typeof importedBookMeta & {
+ shouldPersistEmbeddedCover?: (
+ existing?: { coverUrl?: string },
+ imported?: { coverUrl?: string },
+ ) => boolean;
+ }
+ ).shouldPersistEmbeddedCover;
+ expect(shouldPersistEmbeddedCover).toBeTypeOf("function");
+ if (!shouldPersistEmbeddedCover) return;
+
+ expect(shouldPersistEmbeddedCover({ coverUrl: "covers/saved.jpg" }, undefined)).toBe(false);
+ expect(shouldPersistEmbeddedCover(undefined, { coverUrl: "https://catalog/cover.jpg" })).toBe(
+ false,
+ );
+ expect(shouldPersistEmbeddedCover({ coverUrl: " " }, { coverUrl: "" })).toBe(true);
+ });
+});
diff --git a/packages/app-expo/src/lib/book/imported-book-meta.ts b/packages/app-expo/src/lib/book/imported-book-meta.ts
new file mode 100644
index 000000000..7b1d6ea34
--- /dev/null
+++ b/packages/app-expo/src/lib/book/imported-book-meta.ts
@@ -0,0 +1,28 @@
+import type { BookMeta } from "@readany/core/types";
+import { mergeBookMetadataSources } from "@readany/core/utils";
+import type { ExtractedMeta } from "./metadata-extractor";
+
+export function shouldPersistEmbeddedCover(
+ existing?: Partial,
+ imported?: Partial,
+): boolean {
+ return !existing?.coverUrl?.trim() && !imported?.coverUrl?.trim();
+}
+
+export function buildImportedBookMeta(input: {
+ existing?: Partial;
+ opds?: Partial;
+ embedded?: Partial | (ExtractedMeta & { coverUrl?: string });
+ fallbackTitle: string;
+}): BookMeta {
+ const merged = mergeBookMetadataSources(input.existing, input.opds, input.embedded, {
+ title: input.fallbackTitle,
+ author: "",
+ });
+ return {
+ ...input.existing,
+ ...merged,
+ title: merged.title || input.existing?.title || "Untitled",
+ author: merged.author || input.existing?.author || "",
+ };
+}
diff --git a/packages/app-expo/src/lib/book/metadata-extractor.test.ts b/packages/app-expo/src/lib/book/metadata-extractor.test.ts
new file mode 100644
index 000000000..edc4166b9
--- /dev/null
+++ b/packages/app-expo/src/lib/book/metadata-extractor.test.ts
@@ -0,0 +1,294 @@
+import type { Book } from "@readany/core/types";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { extractLocalBookMetadata } from "./auto-metadata";
+import { extractBookMetadataFromFile } from "./metadata-extractor";
+
+type SparseSegment = { offset: number; bytes: Uint8Array };
+
+const LARGE_FILE_SIZE = 33 * 1024 * 1024;
+
+const mobileFile = vi.hoisted(() => ({
+ exists: true,
+ size: 33 * 1024 * 1024,
+ read: (_start: number, _end: number) => new Uint8Array(),
+}));
+
+const platform = vi.hoisted(() => ({
+ getAppDataDir: vi.fn(async () => "/app"),
+ joinPath: vi.fn(async (...parts: string[]) => parts.join("/")),
+ mkdir: vi.fn(async () => undefined),
+ writeFile: vi.fn(async () => undefined),
+ readFile: vi.fn(async () => {
+ throw new Error("large repair must not read the whole file");
+ }),
+}));
+
+vi.mock("@readany/core/services", () => ({ getPlatformService: () => platform }));
+vi.mock("@/lib/book/metadata-extractor", async () => import("./metadata-extractor"));
+vi.mock("expo-file-system/legacy", () => ({
+ EncodingType: { Base64: "base64" },
+ getInfoAsync: vi.fn(async () => ({
+ exists: mobileFile.exists,
+ isDirectory: false,
+ size: mobileFile.size,
+ })),
+ readAsStringAsync: vi.fn(async (_uri: string, options: { position: number; length: number }) =>
+ Buffer.from(mobileFile.read(options.position, options.position + options.length)).toString(
+ "base64",
+ ),
+ ),
+}));
+
+describe("range-readable book metadata extraction", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mobileFile.exists = true;
+ });
+
+ it("extracts metadata from an EPUB larger than 32 MiB using bounded slices", async () => {
+ const file = createLargeEpubFile();
+
+ await expect(extractBookMetadataFromFile(file, "epub", "large.epub")).resolves.toMatchObject({
+ title: "Large Book",
+ publisher: "Range Press",
+ subjects: ["History"],
+ });
+ expect(file.slice).toHaveBeenCalled();
+ expectBoundedReads(file.slice);
+ });
+
+ it.each(["mobi", "azw", "azw3"])(
+ "extracts %s metadata from a large file using bounded slices",
+ async (format) => {
+ const file = createLargeMobiFile();
+
+ await expect(
+ extractBookMetadataFromFile(file, format, `book.${format}`),
+ ).resolves.toMatchObject({
+ title: "Large MOBI",
+ author: "Author",
+ });
+ expect(file.slice).toHaveBeenCalled();
+ expectBoundedReads(file.slice);
+ },
+ );
+
+ it("routes large local EPUB Book Details repair through range reads", async () => {
+ const file = createLargeEpubFile();
+ mobileFile.size = file.size;
+ mobileFile.read = file.read;
+
+ await expect(
+ extractLocalBookMetadata({
+ id: "legacy-large",
+ filePath: "books/large.epub",
+ format: "epub",
+ syncStatus: "local",
+ meta: { title: "Saved title", author: "Saved author" },
+ progress: 0,
+ addedAt: 1,
+ } as Book),
+ ).resolves.toMatchObject({
+ title: "Large Book",
+ publisher: "Range Press",
+ coverBytes: expect.any(Uint8Array),
+ coverMimeType: "image/png",
+ });
+ expect(platform.readFile).not.toHaveBeenCalled();
+ expect(platform.writeFile).not.toHaveBeenCalled();
+ });
+
+ it("returns extracted text and cover bytes without persisting during extraction", async () => {
+ const file = createLargeEpubFile();
+ mobileFile.size = file.size;
+ mobileFile.read = file.read;
+
+ await expect(
+ extractLocalBookMetadata({
+ id: "cover-failure",
+ filePath: "books/large.epub",
+ format: "epub",
+ syncStatus: "local",
+ meta: { title: "Saved title", author: "Saved author" },
+ progress: 0,
+ addedAt: 1,
+ } as Book),
+ ).resolves.toMatchObject({
+ title: "Large Book",
+ publisher: "Range Press",
+ coverBytes: expect.any(Uint8Array),
+ });
+ expect(platform.writeFile).not.toHaveBeenCalled();
+ });
+
+ it.each(["mobi", "azw", "azw3"])(
+ "routes local %s Book Details repair through range reads",
+ async (format) => {
+ const file = createLargeMobiFile();
+ mobileFile.size = file.size;
+ mobileFile.read = file.read;
+
+ await expect(
+ extractLocalBookMetadata({
+ id: `legacy-${format}`,
+ filePath: `books/large.${format}`,
+ format,
+ syncStatus: "local",
+ meta: { title: "", author: "" },
+ progress: 0,
+ addedAt: 1,
+ } as Book),
+ ).resolves.toMatchObject({ title: "Large MOBI", author: "Author" });
+ expect(platform.readFile).not.toHaveBeenCalled();
+ },
+ );
+
+ it("leaves a missing local file untouched without trying a whole-file read", async () => {
+ mobileFile.exists = false;
+
+ await expect(
+ extractLocalBookMetadata({
+ id: "missing",
+ filePath: "books/missing.epub",
+ format: "epub",
+ syncStatus: "local",
+ meta: { title: "Saved title", author: "Saved author" },
+ progress: 0,
+ addedAt: 1,
+ } as Book),
+ ).resolves.toBeNull();
+ expect(platform.readFile).not.toHaveBeenCalled();
+ });
+});
+
+function expectBoundedReads(slice: ReturnType) {
+ for (const [start = 0, end = start] of slice.mock.calls as Array<[number?, number?]>) {
+ expect(end - start).toBeLessThanOrEqual(256 * 1024);
+ }
+}
+
+function createLargeEpubFile() {
+ const containerXml = encode(
+ '',
+ );
+ const opfXml = encode(
+ 'Large BookAuthorRange PressHistory ',
+ );
+ const coverBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
+ const entries = [
+ { name: "META-INF/container.xml", bytes: containerXml },
+ { name: "content.opf", bytes: opfXml },
+ { name: "cover.png", bytes: coverBytes },
+ ];
+ const segments: SparseSegment[] = [];
+ const directoryEntries: Uint8Array[] = [];
+ let localOffset = 0;
+
+ for (const entry of entries) {
+ const name = encode(entry.name);
+ const local = new Uint8Array(30 + name.length + entry.bytes.length);
+ const localView = new DataView(local.buffer);
+ localView.setUint32(0, 0x04034b50, true);
+ localView.setUint32(18, entry.bytes.length, true);
+ localView.setUint32(22, entry.bytes.length, true);
+ localView.setUint16(26, name.length, true);
+ local.set(name, 30);
+ local.set(entry.bytes, 30 + name.length);
+ segments.push({ offset: localOffset, bytes: local });
+
+ const central = new Uint8Array(46 + name.length);
+ const centralView = new DataView(central.buffer);
+ centralView.setUint32(0, 0x02014b50, true);
+ centralView.setUint32(20, entry.bytes.length, true);
+ centralView.setUint32(24, entry.bytes.length, true);
+ centralView.setUint16(28, name.length, true);
+ centralView.setUint32(42, localOffset, true);
+ central.set(name, 46);
+ directoryEntries.push(central);
+ localOffset += local.length;
+ }
+
+ const directory = concat(directoryEntries);
+ const eocd = new Uint8Array(22);
+ const directoryOffset = LARGE_FILE_SIZE - eocd.length - directory.length;
+ const eocdView = new DataView(eocd.buffer);
+ eocdView.setUint32(0, 0x06054b50, true);
+ eocdView.setUint16(8, entries.length, true);
+ eocdView.setUint16(10, entries.length, true);
+ eocdView.setUint32(12, directory.length, true);
+ eocdView.setUint32(16, directoryOffset, true);
+ segments.push({ offset: directoryOffset, bytes: directory });
+ segments.push({ offset: LARGE_FILE_SIZE - eocd.length, bytes: eocd });
+ return createSparseFile(LARGE_FILE_SIZE, segments);
+}
+
+function createLargeMobiFile() {
+ const pdbHeader = new Uint8Array(78);
+ new DataView(pdbHeader.buffer).setUint16(76, 2, false);
+
+ const recordTable = new Uint8Array(16);
+ const recordTableView = new DataView(recordTable.buffer);
+ recordTableView.setUint32(0, 256, false);
+ recordTableView.setUint32(8, 1024, false);
+
+ const record = new Uint8Array(768);
+ const view = new DataView(record.buffer);
+ record.set(encode("MOBI"), 16);
+ view.setUint32(20, 132, false);
+ view.setUint32(28, 65001, false);
+ view.setUint32(36, 8, false);
+ view.setUint32(84, 300, false);
+ view.setUint32(88, 10, false);
+ view.setUint32(108, 1, false);
+ view.setUint32(128, 0b1000000, false);
+ record.set(encode("EXTH"), 148);
+ view.setUint32(152, 26, false);
+ view.setUint32(156, 1, false);
+ view.setUint32(160, 100, false);
+ view.setUint32(164, 14, false);
+ record.set(encode("Author"), 168);
+ record.set(encode("Large MOBI"), 300);
+
+ return createSparseFile(LARGE_FILE_SIZE, [
+ { offset: 0, bytes: pdbHeader },
+ { offset: 78, bytes: recordTable },
+ { offset: 256, bytes: record },
+ ]);
+}
+
+function createSparseFile(size: number, segments: SparseSegment[]) {
+ const read = (start: number, end: number) => {
+ const result = new Uint8Array(Math.max(0, end - start));
+ for (const segment of segments) {
+ const overlapStart = Math.max(start, segment.offset);
+ const overlapEnd = Math.min(end, segment.offset + segment.bytes.length);
+ if (overlapEnd <= overlapStart) continue;
+ result.set(
+ segment.bytes.subarray(overlapStart - segment.offset, overlapEnd - segment.offset),
+ overlapStart - start,
+ );
+ }
+ return result;
+ };
+ return {
+ size,
+ read,
+ slice: vi.fn((start = 0, end = size) => ({
+ arrayBuffer: async () => read(start, end).buffer,
+ })),
+ };
+}
+
+function concat(chunks: Uint8Array[]): Uint8Array {
+ const result = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0));
+ let offset = 0;
+ for (const chunk of chunks) {
+ result.set(chunk, offset);
+ offset += chunk.length;
+ }
+ return result;
+}
+
+function encode(value: string): Uint8Array {
+ return new TextEncoder().encode(value);
+}
diff --git a/packages/app-expo/src/lib/book/metadata-extractor.ts b/packages/app-expo/src/lib/book/metadata-extractor.ts
index 360903b8a..8b8a86bbc 100644
--- a/packages/app-expo/src/lib/book/metadata-extractor.ts
+++ b/packages/app-expo/src/lib/book/metadata-extractor.ts
@@ -1,3 +1,4 @@
+import { normalizeIsbn } from "@readany/core/utils";
/**
* Book metadata + cover extraction for React Native (Expo).
*
@@ -852,15 +853,9 @@ function extractOpfIsbn(opfXml: string): string {
const identifierRegex = /<[^>]*identifier\b([^>]*)>([^<]*)<\/[^>]*identifier>/gi;
let match = identifierRegex.exec(opfXml);
while (match !== null) {
- const attrs = match[1] || "";
const value = (match[2] || "").trim();
- const scheme = getAttr(attrs, "opf:scheme") || getAttr(attrs, "scheme");
- if (
- scheme.toLowerCase() === "isbn" ||
- /(?:97[89][-\s]?)?(?:\d[-\s]?){9,12}[\dXx]/.test(value)
- ) {
- return value;
- }
+ const isbn = normalizeIsbn(value);
+ if (isbn) return isbn;
match = identifierRegex.exec(opfXml);
}
return "";
diff --git a/packages/app-expo/src/screens/BookDetailsScreen.tsx b/packages/app-expo/src/screens/BookDetailsScreen.tsx
index 4def2ea85..efbadee0c 100644
--- a/packages/app-expo/src/screens/BookDetailsScreen.tsx
+++ b/packages/app-expo/src/screens/BookDetailsScreen.tsx
@@ -9,6 +9,7 @@ import {
import { KeyboardAwareScrollView } from "@/components/ui/KeyboardAwareScrollView";
import { useResponsiveLayout } from "@/hooks/use-responsive-layout";
import { extractLocalBookMetadata } from "@/lib/book/auto-metadata";
+import { commitCustomCover, saveExtractedCoverIfStillMissing } from "@/lib/book/cover-storage";
import type { RootStackParamList } from "@/navigation/RootNavigator";
import { SettingsHeader } from "@/screens/settings/SettingsHeader";
import { useLibraryStore } from "@/stores/library-store";
@@ -27,6 +28,7 @@ import { getPlatformService } from "@readany/core/services";
import type { Book, BookReview } from "@readany/core/types";
import {
type BookMetadataFormValues,
+ applyBookMetadataFormUpdate,
buildBookMetadataUpdate,
createBookMetadataFormValues,
createEmptyBookReview,
@@ -296,6 +298,14 @@ export function BookDetailsScreen({ route }: Props) {
const hydratedBookIdRef = useRef(null);
const autoFilledBookIdRef = useRef(null);
const latestValuesRef = useRef(null);
+ const commitValues = useCallback(
+ (
+ update:
+ | BookMetadataFormValues
+ | ((current: BookMetadataFormValues) => BookMetadataFormValues),
+ ) => applyBookMetadataFormUpdate(latestValuesRef, setValues, update),
+ [],
+ );
useEffect(() => {
void loadBooks();
@@ -305,12 +315,9 @@ export function BookDetailsScreen({ route }: Props) {
if (!book) return;
if (hydratedBookIdRef.current === book.id) return;
hydratedBookIdRef.current = book.id;
- setValues(createBookMetadataFormValues(book));
- }, [book]);
-
- useEffect(() => {
- latestValuesRef.current = values;
- }, [values]);
+ const nextValues = createBookMetadataFormValues(book);
+ commitValues(nextValues);
+ }, [book, commitValues]);
useEffect(() => {
if (!book || !values) return;
@@ -319,20 +326,35 @@ export function BookDetailsScreen({ route }: Props) {
autoFilledBookIdRef.current = book.id;
let cancelled = false;
- void extractLocalBookMetadata(book).then((metadata) => {
+ void extractLocalBookMetadata(book).then(async (metadata) => {
if (cancelled || !metadata) return;
+ let extracted = metadata;
+ if (metadata.coverBytes?.length) {
+ try {
+ const coverUrl = await saveExtractedCoverIfStillMissing(
+ book.id,
+ metadata.coverBytes,
+ metadata.coverMimeType,
+ () => (cancelled ? "__cancelled__" : latestValuesRef.current?.coverUrl),
+ );
+ if (coverUrl) extracted = { ...metadata, coverUrl };
+ } catch (error) {
+ console.warn("[BookMetadata] Failed to persist extracted mobile cover:", error);
+ }
+ }
+ if (cancelled) return;
const nextValues = latestValuesRef.current
- ? mergeMissingBookMetadataValues(latestValuesRef.current, metadata)
+ ? mergeMissingBookMetadataValues(latestValuesRef.current, extracted)
: null;
if (!nextValues) return;
- setValues(nextValues);
+ commitValues(nextValues);
updateBook(book.id, buildBookMetadataUpdate(book, nextValues));
});
return () => {
cancelled = true;
};
- }, [book, updateBook, values]);
+ }, [book, commitValues, updateBook, values]);
useEffect(() => {
const raw = values?.coverUrl;
@@ -362,73 +384,75 @@ export function BookDetailsScreen({ route }: Props) {
const setField = useCallback(
(field: K, value: BookMetadataFormValues[K]) => {
- setValues((current) => {
- if (!current) return current;
- const next = { ...current, [field]: value };
- if (book) updateBook(book.id, buildBookMetadataUpdate(book, next));
- return next;
- });
+ const next = commitValues((current) => ({ ...current, [field]: value }));
+ if (book && next) updateBook(book.id, buildBookMetadataUpdate(book, next));
},
- [book, updateBook],
+ [book, commitValues, updateBook],
);
const persistCoverUrl = useCallback(
async (coverUrl: string) => {
- if (!book || !values) return;
- const nextValues = { ...values, coverUrl };
- setValues(nextValues);
+ if (!book) return;
+ const nextValues = commitValues((current) => ({ ...current, coverUrl }));
+ if (!nextValues) return;
await updateBook(book.id, buildBookMetadataUpdate(book, nextValues));
},
- [book, updateBook, values],
+ [book, commitValues, updateBook],
);
const setRating = useCallback(
(rating: number) => {
- if (!book || !values) return;
- const next = { ...values, rating: values.rating === rating ? null : rating };
- setValues(next);
+ if (!book) return;
+ const next = commitValues((current) => ({
+ ...current,
+ rating: current.rating === rating ? null : rating,
+ }));
+ if (!next) return;
updateBook(book.id, buildBookMetadataUpdate(book, next));
},
- [book, updateBook, values],
+ [book, commitValues, updateBook],
);
const addReview = useCallback(
(content: string) => {
- if (!book || !values) return;
+ if (!book) return;
const review = { ...createEmptyBookReview(), content };
- const next = { ...values, reviews: [...values.reviews, review] };
- setValues(next);
+ const next = commitValues((current) => ({
+ ...current,
+ reviews: [...current.reviews, review],
+ }));
+ if (!next) return;
updateBook(book.id, buildBookMetadataUpdate(book, next));
},
- [book, updateBook, values],
+ [book, commitValues, updateBook],
);
const updateReview = useCallback(
(reviewId: string, content: string) => {
- if (!book || !values) return;
- const next = {
- ...values,
- reviews: values.reviews.map((review) =>
+ if (!book) return;
+ const next = commitValues((current) => ({
+ ...current,
+ reviews: current.reviews.map((review) =>
review.id === reviewId ? { ...review, content } : review,
),
- };
- setValues(next);
+ }));
+ if (!next) return;
updateBook(book.id, buildBookMetadataUpdate(book, next));
},
- [book, updateBook, values],
+ [book, commitValues, updateBook],
);
const removeReview = useCallback(
(reviewId: string) => {
- if (!book || !values) return;
- const next = {
- ...values,
- reviews: values.reviews.filter((review) => review.id !== reviewId),
- };
- setValues(next);
+ if (!book) return;
+ const next = commitValues((current) => ({
+ ...current,
+ reviews: current.reviews.filter((review) => review.id !== reviewId),
+ }));
+ if (!next) return;
updateBook(book.id, buildBookMetadataUpdate(book, next));
},
- [book, updateBook, values],
+ [book, commitValues, updateBook],
);
const handleTextEditorDone = useCallback(
@@ -478,7 +502,7 @@ export function BookDetailsScreen({ route }: Props) {
const targetPath = await platform.joinPath(appData, relativePath);
const bytes = await platform.readFile(selected.uri);
await platform.writeFile(targetPath, bytes);
- await persistCoverUrl(relativePath);
+ await commitCustomCover(book.id, relativePath, persistCoverUrl);
Alert.alert(t("common.success", "成功"), t("library.detailsCoverSaved", "封面已保存"));
} catch (error) {
console.warn("[BookDetailsScreen] Failed to change cover:", error);
diff --git a/packages/app-expo/src/stores/library-store.ts b/packages/app-expo/src/stores/library-store.ts
index d9014c14c..9f6dea858 100644
--- a/packages/app-expo/src/stores/library-store.ts
+++ b/packages/app-expo/src/stores/library-store.ts
@@ -1,4 +1,7 @@
+import { saveCoverBytesToAppData } from "@/lib/book/cover-storage";
+import { buildImportedBookMeta, shouldPersistEmbeddedCover } from "@/lib/book/imported-book-meta";
import {
+ type ExtractedMeta,
createRangeReadableFile,
extractBookMetadata,
extractBookMetadataFromFile,
@@ -13,7 +16,14 @@ import {
import * as db from "@readany/core/db/database";
import { runWithDbRetry } from "@readany/core/db/write-retry";
import { getPlatformService } from "@readany/core/services";
-import type { Book, BookGroup, LibraryFilter, SortField, SortOrder } from "@readany/core/types";
+import type {
+ Book,
+ BookGroup,
+ BookMeta,
+ LibraryFilter,
+ SortField,
+ SortOrder,
+} from "@readany/core/types";
import { generateId } from "@readany/core/utils";
import { create } from "zustand";
import { debouncedSave, loadFromFS } from "./persist";
@@ -50,6 +60,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 +95,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 },
@@ -132,17 +148,6 @@ async function ensureAppSubDir(subDir: string): Promise {
}
}
-async function saveCoverToAppData(bookId: string, coverBlob: Blob): Promise {
- const platform = getPlatformService();
- await ensureAppSubDir("covers");
- const ext = coverBlob.type.includes("png") ? "png" : "jpg";
- const relativePath = `covers/${bookId}.${ext}`;
- const absPath = await resolveAppPath(relativePath);
- const arrayBuffer = await coverBlob.arrayBuffer();
- await platform.writeFile(absPath, new Uint8Array(arrayBuffer));
- return relativePath;
-}
-
function bytesToBase64(bytes: Uint8Array): string {
const chunkSize = 0x8000;
let binary = "";
@@ -397,12 +402,11 @@ async function restoreDeletedMobileBook(
...originalBook,
filePath: relativePath,
format: "epub",
- meta: {
- ...originalBook.meta,
- title: conversion.bookTitle || originalBook.meta.title || fileName.replace(/\.\w+$/i, ""),
- author: originalBook.meta.author || "",
- coverUrl: originalBook.meta.coverUrl,
- },
+ meta: buildImportedBookMeta({
+ existing: originalBook.meta,
+ embedded: { title: conversion.bookTitle },
+ fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled",
+ }),
deletedAt: undefined,
fileHash,
syncStatus: "local",
@@ -441,12 +445,9 @@ async function restoreDeletedMobileBook(
await platform.writeFile(await resolveAppPath(relativePath), conversion.epubBytes);
let coverUrl = originalBook.meta.coverUrl;
- if (conversion.coverBytes && conversion.coverBytes.length > 0) {
+ if (!coverUrl?.trim() && conversion.coverBytes && conversion.coverBytes.length > 0) {
try {
- await ensureAppSubDir("covers");
- const coverRelPath = `covers/${bookId}.jpg`;
- await platform.writeFile(await resolveAppPath(coverRelPath), conversion.coverBytes);
- coverUrl = coverRelPath;
+ coverUrl = await saveCoverBytesToAppData(bookId, conversion.coverBytes);
} catch (coverErr) {
console.warn(`[restoreDeletedMobileBook] UMD cover save failed: ${coverErr}`);
}
@@ -456,12 +457,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 +474,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 +484,14 @@ async function restoreDeletedMobileBook(
fileName,
fileSize,
});
- if (meta.title) title = meta.title;
- if (meta.author) author = meta.author;
-
- if (meta.coverBytes && meta.coverBytes.length > 0) {
- const mimeType = meta.coverMimeType || "image/jpeg";
- const coverExt = mimeType.includes("png") ? "png" : "jpg";
- await ensureAppSubDir("covers");
- const coverRelPath = `covers/${bookId}.${coverExt}`;
- await platform.writeFile(await resolveAppPath(coverRelPath), meta.coverBytes);
- coverUrl = coverRelPath;
+ if (!coverUrl?.trim() && meta.coverBytes && meta.coverBytes.length > 0) {
+ try {
+ coverUrl = await saveCoverBytesToAppData(bookId, meta.coverBytes, meta.coverMimeType);
+ } catch (coverErr) {
+ console.warn(`[restoreDeletedMobileBook] Cover save failed for ${fileName}:`, coverErr);
+ }
}
+ embeddedMeta = { ...meta, coverUrl };
} catch (metaErr) {
console.warn(`[restoreDeletedMobileBook] Metadata extraction failed for ${fileName}:`, metaErr);
}
@@ -504,12 +500,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",
@@ -882,6 +877,10 @@ export const useLibraryStore = create((set, get) => ({
})
: null;
const bookId = deletedMatch?.id ?? generateId();
+ const persistEmbeddedCover = shouldPersistEmbeddedCover(
+ deletedMatch?.meta,
+ fileInfo.metadata,
+ );
console.log(
`[importBooks] Importing: name=${fileName}, format=${format}, uri=${filePath}`,
@@ -941,17 +940,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 +988,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.
@@ -1050,31 +1049,30 @@ export const useLibraryStore = create((set, get) => ({
const absPath = await resolveAppPath(relativePath);
await platform.writeFile(absPath, conversion.epubBytes);
- let coverUrl: string | undefined;
- if (conversion.coverBytes && conversion.coverBytes.length > 0) {
+ let coverUrl = deletedMatch?.meta.coverUrl;
+ if (
+ persistEmbeddedCover &&
+ conversion.coverBytes &&
+ conversion.coverBytes.length > 0
+ ) {
try {
- await ensureAppSubDir("covers");
- const coverRelPath = `covers/${bookId}.jpg`;
- const coverAbsPath = await resolveAppPath(coverRelPath);
- await platform.writeFile(coverAbsPath, conversion.coverBytes);
- coverUrl = coverRelPath;
+ coverUrl = await saveCoverBytesToAppData(bookId, conversion.coverBytes);
} catch (coverErr) {
console.warn(`[importBooks] Failed to save UMD cover for ${fileName}:`, coverErr);
}
}
- const title = conversion.bookTitle || fileName.replace(/\.\w+$/i, "") || "Untitled";
- const author = conversion.author || "";
+ const completeMeta = buildImportedBookMeta({
+ existing: deletedMatch?.meta,
+ opds: fileInfo.metadata,
+ embedded: { title: conversion.bookTitle, author: conversion.author, coverUrl },
+ fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled",
+ });
const book: Book = {
id: bookId,
filePath: relativePath,
format: "umd",
- meta: {
- ...(deletedMatch?.meta ?? {}),
- title,
- author,
- coverUrl: coverUrl || deletedMatch?.meta.coverUrl,
- },
+ meta: completeMeta,
groupId: deletedMatch?.groupId,
progress: deletedMatch?.progress ?? 0,
currentCfi: deletedMatch?.currentCfi,
@@ -1112,7 +1110,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 +1139,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 = "";
- let coverUrl: string | undefined;
+ // Extract metadata and cover from book content.
+ let coverUrl = deletedMatch?.meta.coverUrl;
+ let embeddedMeta: (ExtractedMeta & { coverUrl?: string }) | undefined;
try {
console.log(`[importBooks] Extracting metadata for format=${format}...`);
@@ -1157,43 +1154,39 @@ export const useLibraryStore = create((set, get) => ({
console.log(
`[importBooks] Metadata result: title="${meta.title}", author="${meta.author}", hasCover=${!!meta.coverBytes}, coverSize=${meta.coverBytes?.length ?? 0}`,
);
- if (meta.title) title = meta.title;
- if (meta.author) author = meta.author;
-
// Save cover image to app data
- if (meta.coverBytes && meta.coverBytes.length > 0) {
+ if (persistEmbeddedCover && meta.coverBytes && meta.coverBytes.length > 0) {
try {
- const mimeType = meta.coverMimeType || "image/jpeg";
- const coverExt = mimeType.includes("png") ? "png" : "jpg";
- await ensureAppSubDir("covers");
- const coverRelPath = `covers/${bookId}.${coverExt}`;
- const coverAbsPath = await resolveAppPath(coverRelPath);
- console.log(`[importBooks] Saving cover to: ${coverAbsPath}`);
- const platform = getPlatformService();
- await platform.writeFile(coverAbsPath, meta.coverBytes);
- coverUrl = coverRelPath;
+ coverUrl = await saveCoverBytesToAppData(
+ bookId,
+ meta.coverBytes,
+ meta.coverMimeType,
+ );
+ console.log(`[importBooks] Saving cover to: ${coverUrl}`);
console.log(`[importBooks] Cover saved. coverUrl=${coverUrl}`);
} catch (coverErr) {
console.warn(`[importBooks] Failed to save cover for ${fileName}:`, coverErr);
}
}
+ embeddedMeta = { ...meta, coverUrl };
} catch (metaErr) {
console.warn(`[importBooks] Metadata extraction failed for ${fileName}:`, metaErr);
}
+ const completeMeta = buildImportedBookMeta({
+ existing: deletedMatch?.meta,
+ opds: fileInfo.metadata,
+ embedded: embeddedMeta,
+ fallbackTitle: fileName.replace(/\.\w+$/i, "") || "Untitled",
+ });
console.log(
- `[importBooks] Final book: title="${title}", author="${author}", coverUrl="${coverUrl}"`,
+ `[importBooks] Final book: title="${completeMeta.title}", author="${completeMeta.author}", coverUrl="${completeMeta.coverUrl}"`,
);
const book: Book = {
id: bookId,
filePath: relativePath,
format,
- meta: {
- ...(deletedMatch?.meta ?? {}),
- title,
- author,
- coverUrl: coverUrl || deletedMatch?.meta.coverUrl,
- },
+ meta: completeMeta,
groupId: deletedMatch?.groupId,
progress: deletedMatch?.progress ?? 0,
currentCfi: deletedMatch?.currentCfi,
diff --git a/packages/app/src/components/home/BookDetailsDialog.tsx b/packages/app/src/components/home/BookDetailsDialog.tsx
index 38041ff0a..5019689a6 100644
--- a/packages/app/src/components/home/BookDetailsDialog.tsx
+++ b/packages/app/src/components/home/BookDetailsDialog.tsx
@@ -25,12 +25,13 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { useResolvedSrc } from "@/hooks/use-resolved-src";
import { extractLocalBookMetadata } from "@/lib/book/auto-metadata";
-import { invoke } from "@tauri-apps/api/core";
+import { commitCustomCover, saveExtractedCoverIfStillMissing } from "@/lib/book/cover-storage";
import { useAppStore } from "@/stores/app-store";
import { useLibraryStore } from "@/stores/library-store";
import type { Book, BookReview } from "@readany/core/types";
import {
type BookMetadataFormValues,
+ applyBookMetadataFormUpdate,
buildBookMetadataUpdate,
cn,
createBookMetadataFormValues,
@@ -40,6 +41,7 @@ import {
mergeMissingBookMetadataValues,
splitEditableList,
} from "@readany/core/utils";
+import { invoke } from "@tauri-apps/api/core";
import type { TFunction } from "i18next";
import {
BookOpen,
@@ -58,7 +60,7 @@ import {
Wand2,
} from "lucide-react";
import type { ReactNode } from "react";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -282,12 +284,22 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo
const coverSrc = useResolvedSrc(values?.coverUrl);
const hydratedBookIdRef = useRef(null);
const autoFilledBookIdRef = useRef(null);
+ const latestValuesRef = useRef(null);
const autoSaveTimerRef = useRef(null);
+ const commitValues = useCallback(
+ (
+ update:
+ | BookMetadataFormValues
+ | ((current: BookMetadataFormValues) => BookMetadataFormValues),
+ ) => applyBookMetadataFormUpdate(latestValuesRef, setValues, update),
+ [],
+ );
useEffect(() => {
if (!open) {
hydratedBookIdRef.current = null;
autoFilledBookIdRef.current = null;
+ latestValuesRef.current = null;
setEditingBasics(false);
setEditingTitleField(null);
setEditingReviewId(null);
@@ -299,14 +311,15 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo
if (!book) return;
if (hydratedBookIdRef.current === book.id) return;
hydratedBookIdRef.current = book.id;
- setValues(createBookMetadataFormValues(book));
+ const nextValues = createBookMetadataFormValues(book);
+ commitValues(nextValues);
setEditingBasics(false);
setEditingTitleField(null);
setEditingReviewId(null);
setActiveTab("basic");
setDraftActionBusy(false);
setDraftActionResult(null);
- }, [book, open]);
+ }, [book, commitValues, open]);
useEffect(() => {
if (!open || !book || !values) return;
@@ -315,18 +328,34 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo
autoFilledBookIdRef.current = book.id;
let cancelled = false;
- void extractLocalBookMetadata(book).then((metadata) => {
+ void extractLocalBookMetadata(book).then(async (metadata) => {
if (cancelled || !metadata) return;
- setValues((current) => {
- if (!current) return current;
- return mergeMissingBookMetadataValues(current, metadata) ?? current;
- });
+ let extracted = metadata;
+ if (metadata.coverBlob) {
+ try {
+ const coverUrl = await saveExtractedCoverIfStillMissing(
+ book.id,
+ metadata.coverBlob,
+ () => (cancelled ? "__cancelled__" : latestValuesRef.current?.coverUrl),
+ );
+ if (coverUrl) extracted = { ...metadata, coverUrl };
+ } catch (error) {
+ console.warn("[BookMetadata] Failed to persist extracted desktop cover:", error);
+ }
+ }
+ if (cancelled) return;
+ const nextValues = latestValuesRef.current
+ ? mergeMissingBookMetadataValues(latestValuesRef.current, extracted)
+ : null;
+ if (!nextValues) return;
+ commitValues(nextValues);
+ updateBook(book.id, buildBookMetadataUpdate(book, nextValues));
});
return () => {
cancelled = true;
};
- }, [book, open, values]);
+ }, [book, commitValues, open, updateBook, values]);
const groupName = useMemo(() => {
const groupId = values?.groupId ?? book?.groupId;
@@ -367,7 +396,7 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo
field: K,
value: BookMetadataFormValues[K],
) => {
- setValues((current) => (current ? { ...current, [field]: value } : current));
+ commitValues((current) => ({ ...current, [field]: value }));
};
const persistCoverUrl = async (coverUrl: string) => {
@@ -509,7 +538,7 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo
await mkdir(coversDir, { recursive: true });
const relativePath = `covers/${book.id}-custom-${Date.now()}.${safeExt}`;
await copyFile(selected, await join(libraryRoot, relativePath));
- await persistCoverUrl(relativePath);
+ await commitCustomCover(book.id, relativePath, persistCoverUrl);
toast.success(t("library.detailsCoverSaved", "Cover saved"));
} catch (err) {
console.warn("[BookDetailsDialog] Failed to change cover:", err);
@@ -936,7 +965,8 @@ export function BookDetailsDialog({ book, open, onOpenChange }: BookDetailsDialo
? t("library.detailsDraftCreated", "Draft created successfully")
: t("library.detailsDraftCreateFailed", "Draft creation failed")}
- {draftActionResult.ok && parseDraftCreateResult(draftActionResult)?.ok ? (
+ {draftActionResult.ok &&
+ parseDraftCreateResult(draftActionResult)?.ok ? (