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