import { SystemSettings } from '@/types/settings'; import { FileSystem, AppPlatform, BaseDir, OsPlatform } from '@/types/system'; import { Book, BookConfig, BookContent, BookFormat, BookLookupIndex, BookNote, FIXED_LAYOUT_FORMATS, ImportBookOptions, } from '@/types/book'; import { getDir, getLocalBookFilename, getCoverFilename, getConfigFilename, getBookNavFilename, INIT_BOOK_CONFIG, formatTitle, formatAuthors, getPrimaryLanguage, getMetadataHash, } from '@/utils/book'; import type { BookNav } from '@/services/nav'; import { partialMD5, md5 } from '@/utils/md5'; import { getBaseFilename, getFilename } from '@/utils/path'; import { BookDoc, DocumentLoader } from '@/libs/document'; import { tryNativeParseEpub } from '@/utils/tauriEpubBridge'; import { tryNativeParseMobi } from '@/utils/tauriMobiBridge'; import { isPseStreamFileName, openPseStreamBook, parsePseStreamFileName } from './opds/pseStream'; import { DEFAULT_BOOK_SEARCH_CONFIG, DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS } from './constants'; import { isContentURI, isValidURL, makeSafeFilename } from '@/utils/misc'; import { deserializeConfig, serializeConfig, serializeRawConfig } from '@/utils/serializer'; import { ClosableFile } from '@/utils/file'; import { TxtToEpubConverter } from '@/utils/txt'; import { svg2png } from '@/utils/svg'; import { normalizeMetadataIsbn } from '@/utils/isbn'; import { BookFileNotFoundError } from './errors'; import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; import { isBookFileContentSource, resolveBookContentSource, type BookFileContentSource, } from './bookContent'; export function buildBookLookupIndex(books: Book[], osPlatform?: OsPlatform): BookLookupIndex { const byHash = new Map(); const byMetaKey = new Map(); const byFilePath = new Map(); for (const book of books) { byHash.set(book.hash, book); if (book.metaHash && !book.deletedAt) { const key = `${book.metaHash}:${book.format}`; const list = byMetaKey.get(key); if (list) list.push(book); else byMetaKey.set(key, [book]); } // In-place books carry the absolute source path on `filePath` (set by // importBook below). Indexing them here lets a re-import of the exact // same file short-circuit before touching disk or computing partialMD5. // Skip URL-backed entries (remote books) and tombstoned ones. if (book.filePath && !isValidURL(book.filePath) && !book.deletedAt) { const key = normalizeFilePathForIndex(book.filePath, osPlatform); if (key) byFilePath.set(key, book); } } return { byHash, byMetaKey, byFilePath }; } /** * Normalize an absolute file path into a stable map key for `byFilePath`. * * Mirrors the same rules `ingestService.shouldImportInPlace` uses to compare * paths against the user's in-place roots so both sides agree on whether a * given source file matches a previously-indexed book: * - Backslashes are normalized to `/`. * - Trailing slashes are stripped. * - On case-insensitive filesystems (macOS / iOS / Windows) the key is * lowercased. Linux / Android keep the original casing. * * Returns an empty string for non-string / falsy input so callers can do a * `if (key) map.set(key, …)` guard without an extra null check. */ export function normalizeFilePathForIndex(path: string, osPlatform?: OsPlatform): string { if (!path) return ''; const caseInsensitive = osPlatform === 'macos' || osPlatform === 'ios' || osPlatform === 'windows'; const n = path.replace(/\\/g, '/').replace(/\/+$/, ''); return caseInsensitive ? n.toLowerCase() : n; } export interface CoverContext { fs: FileSystem; appPlatform: AppPlatform; localBooksDir: string; } export function getCoverImageUrl(ctx: CoverContext, book: Book): string { return ctx.fs.getURL(`${ctx.localBooksDir}/${getCoverFilename(book)}`); } export async function getCoverImageBlobUrl(ctx: CoverContext, book: Book): Promise { return ctx.fs.getBlobURL(`${ctx.localBooksDir}/${getCoverFilename(book)}`, 'None'); } export async function getCachedImageUrl(ctx: CoverContext, pathOrUrl: string): Promise { const cachedKey = `img_${md5(pathOrUrl)}`; const cachePrefix = await ctx.fs.getPrefix('Cache'); const cachedPath = `${cachePrefix}/${cachedKey}`; if (await ctx.fs.exists(cachedPath, 'None')) { return await ctx.fs.getImageURL(cachedPath); } else { const file = await ctx.fs.openFile(pathOrUrl, 'None'); await ctx.fs.writeFile(cachedKey, 'Cache', await file.arrayBuffer()); return await ctx.fs.getImageURL(cachedPath); } } export async function generateCoverImageUrl(ctx: CoverContext, book: Book): Promise { return ctx.appPlatform === 'web' ? await getCoverImageBlobUrl(ctx, book) : getCoverImageUrl(ctx, book); } function imageToArrayBuffer( ctx: CoverContext, imageUrl?: string, imageFile?: string, ): Promise { return new Promise((resolve, reject) => { if (!imageUrl && !imageFile) { reject(new Error('No image URL or file provided')); return; } if (ctx.appPlatform === 'web' && imageUrl && imageUrl.startsWith('blob:')) { fetch(imageUrl) .then((response) => response.arrayBuffer()) .then(resolve) .catch(reject); } else if (ctx.appPlatform === 'tauri' && imageFile) { ctx.fs .openFile(imageFile, 'None') .then((file) => file.arrayBuffer()) .then(resolve) .catch(reject); } else if (ctx.appPlatform === 'tauri' && imageUrl) { tauriFetch(imageUrl, { method: 'GET' }) .then((response) => response.arrayBuffer()) .then(resolve) .catch(reject); } else { reject(new Error('Unsupported platform or missing image data')); } }); } export async function updateCoverImage( ctx: CoverContext, book: Book, imageUrl?: string, imageFile?: string, ): Promise { if (imageUrl === '_blank') { await ctx.fs.removeFile(getCoverFilename(book), 'Books'); } else if (imageUrl || imageFile) { const arrayBuffer = await imageToArrayBuffer(ctx, imageUrl, imageFile); await ctx.fs.writeFile(getCoverFilename(book), 'Books', arrayBuffer); } } // --- Book Merge --- /** * Merge duplicate book entries that share the same metaHash and format as `book`. * Finds all other matching books in the array, selects the base config with the * largest reading progress page number, merges booknotes from all configs * (deduplicating by id, latest updatedAt wins), soft-deletes duplicates * (sets deletedAt), and cleans up their directories. * * @returns The merged config as a JSON string, or undefined if no duplicates were found. */ export async function mergeBooks( fs: FileSystem, books: Book[], book: Book, lookupIndex?: BookLookupIndex, ): Promise { if (!book.metaHash) return undefined; const metaKey = `${book.metaHash}:${book.format}`; const duplicates = lookupIndex ? (lookupIndex.byMetaKey.get(metaKey) ?? []).filter((b) => !b.deletedAt && b !== book) : books.filter( (b) => b.metaHash === book.metaHash && b.format === book.format && !b.deletedAt && b !== book, ); if (duplicates.length === 0) return undefined; const allCandidates = [book, ...duplicates]; const configs: Partial[] = []; for (const candidate of allCandidates) { const configPath = getConfigFilename(candidate); if (await fs.exists(configPath, 'Books')) { try { const str = (await fs.readFile(configPath, 'Books', 'text')) as string; configs.push(JSON.parse(str)); } catch { /* ignore corrupt configs */ } } } let mergedConfigData: string | undefined; if (configs.length > 0) { const base = configs.reduce((best, cfg) => { const bestPage = best.progress?.[0] ?? 0; const cfgPage = cfg.progress?.[0] ?? 0; return cfgPage > bestPage ? cfg : best; }); const noteMap = new Map(); for (const cfg of configs) { for (const note of cfg.booknotes ?? []) { const existing = noteMap.get(note.id); if (!existing || (note.updatedAt || 0) > (existing.updatedAt || 0)) { noteMap.set(note.id, note); } } } base.booknotes = [...noteMap.values()]; mergedConfigData = serializeRawConfig(base); } for (const dup of duplicates) { dup.deletedAt = Date.now(); const dupDir = getDir(dup); if (await fs.exists(dupDir, 'Books')) { await fs.removeDir(dupDir, 'Books', true); } } return mergedConfigData; } // --- Book Import --- /** * Options consumed by bookService.importBook. Extends the user-facing * ImportBookOptions with the required AppService callbacks that are bound by * the AppService wrapper. */ export interface ImportBookInternalOptions extends ImportBookOptions { saveBookConfig: (book: Book, config: BookConfig) => Promise; generateCoverImageUrl: (book: Book) => Promise; /** * Used by the in-place fast path to normalize the source path the same way * `buildBookLookupIndex` does. Optional: when omitted the fast path falls * back to a case-sensitive comparison, which is still safe (it will simply * miss matches that differ only in casing on macOS / iOS / Windows and the * import will proceed down the slow path as before). */ osPlatform?: OsPlatform; } export async function importBook( fs: FileSystem, // file might be: // 1.1 absolute path for local file on Desktop // 1.2 /private/var inbox file path on iOS // 2. remote url // 3. content provider uri // 4. File object from browsers file: string | File, books: Book[], options: ImportBookInternalOptions, ): Promise { const { saveBookConfig: saveBookConfigFn, generateCoverImageUrl: generateCoverImageUrlFn, saveBook = true, saveCover = true, overwrite = false, transient = false, inPlace = false, lookupIndex, osPlatform, } = options; const isPseStream = typeof file === 'string' && isPseStreamFileName(file); try { let loadedBook: BookDoc; let format: BookFormat; let filename: string; let fileobj: File | undefined; // When the Rust EPUB parser succeeds it gives us the partialMD5 for free, // so we can short-circuit the JS hashing pass below. let nativeHash: string | undefined; let usedNativeParser = false; if (transient && typeof file !== 'string') { throw new Error('Transient import is only supported for file paths'); } try { if (isPseStream) { const data = parsePseStreamFileName(file as string); ({ book: loadedBook, format } = await openPseStreamBook(data)); filename = file as string; } else { if (typeof file === 'string') { fileobj = await fs.openFile(file, 'None'); filename = fileobj.name || getFilename(file); } else { fileobj = file; filename = file.name; } if (/\.txt$/i.test(filename)) { const txt2epub = new TxtToEpubConverter(); ({ file: fileobj } = await txt2epub.convert({ file: fileobj })); } if (!fileobj || fileobj.size === 0) { throw new Error('Invalid or empty book file'); } // Q1 fast path: when running under Tauri with a real file // path, let Rust contribute the mechanical parts of the // import work — partialMD5 over the file, the downscaled // cover, and (for EPUB) the raw OPF bytes. Metadata // extraction itself runs through foliate-js so the import // path produces the same `Book.metadata` shape the reader // path does (`refines` chains / ONIX5 / language maps / EPUB // `belongs-to-collection` for EPUB; PalmDB UID identifier // for MOBI), without any `DocumentLoader.open()` overhead — // the importer never reads sections / toc / fixed-layout // detection, so spending CPU on a zip central-directory // scan, nav/ncx inflate, or PDB record-table walk would be // pure waste here. // // Both bridges are no-ops on web / non-eligible paths, so // the cost when neither matches is just two cheap regex // tests. let nativeBookDoc: BookDoc | undefined; let nativeFormat: BookFormat | undefined; if (typeof file === 'string' && !/\.txt$/i.test(filename)) { const nativeEpub = await tryNativeParseEpub(file); if (nativeEpub) { nativeBookDoc = nativeEpub.bookDoc; nativeFormat = 'EPUB' as BookFormat; nativeHash = nativeEpub.partialMd5; } else { const nativeMobi = await tryNativeParseMobi(file, fileobj); if (nativeMobi) { nativeBookDoc = nativeMobi.bookDoc; nativeFormat = nativeMobi.format; nativeHash = nativeMobi.partialMd5; } } } if (nativeBookDoc && nativeFormat) { loadedBook = nativeBookDoc; format = nativeFormat; usedNativeParser = true; } else { ({ book: loadedBook, format } = await new DocumentLoader(fileobj).open()); } } if (!loadedBook) { throw new Error('Unsupported or corrupted book file'); } normalizeMetadataIsbn(loadedBook.metadata); const metadataTitle = formatTitle(loadedBook.metadata.title); if (!metadataTitle || !metadataTitle.trim() || metadataTitle === filename) { loadedBook.metadata.title = getBaseFilename(filename); } } catch (error) { throw new Error(`Failed to open the book file: ${(error as Error).message || error}`); } const hash = isPseStream ? md5(file as string) : usedNativeParser ? nativeHash! : await partialMD5(fileobj!); const metaHash = getMetadataHash(loadedBook.metadata); let existingBook = lookupIndex ? lookupIndex.byHash.get(hash) : books.find((b) => b.hash === hash); let metaHashMatch = false; let oldBookDir: string | undefined; if (existingBook) { if (!transient) { existingBook.deletedAt = null; } existingBook.createdAt = Date.now(); existingBook.updatedAt = Date.now(); } // Aggregate all books with same metaHash and format, deduplicating into one entry let bestConfigData: string | undefined; if (!transient && metaHash) { if (!existingBook) { const metaKey = `${metaHash}:${format}`; const firstMatch = lookupIndex ? (lookupIndex.byMetaKey.get(metaKey) ?? []).find((b) => !b.deletedAt) : books.find((b) => b.metaHash === metaHash && b.format === format && !b.deletedAt); if (firstMatch) { oldBookDir = getDir(firstMatch); existingBook = firstMatch; metaHashMatch = true; existingBook.createdAt = Date.now(); existingBook.updatedAt = Date.now(); } } if (existingBook) { bestConfigData = await mergeBooks(fs, books, existingBook, lookupIndex); } } const primaryLanguage = getPrimaryLanguage(loadedBook.metadata.language); const book: Book = { hash, format, metaHash, title: formatTitle(loadedBook.metadata.title), sourceTitle: formatTitle(loadedBook.metadata.title), primaryLanguage, author: formatAuthors(loadedBook.metadata.author, primaryLanguage), metadata: loadedBook.metadata, createdAt: existingBook ? existingBook.createdAt : Date.now(), uploadedAt: existingBook ? existingBook.uploadedAt : null, deletedAt: transient ? Date.now() : null, downloadedAt: Date.now(), updatedAt: Date.now(), }; // update series info from metadata if (book.metadata?.belongsTo?.series) { const belongsTo = book.metadata.belongsTo.series; const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo; if (series) { book.metadata.series = formatTitle(series.name); book.metadata.seriesIndex = parseFloat(series.position || '0'); if (series.total) book.metadata.seriesTotal = parseInt(series.total, 10); } } // update book metadata when reimporting the same book if (existingBook && metaHashMatch) { // MetaHash match (different file, same book): override metadata and hash existingBook.hash = hash; existingBook.format = book.format; existingBook.metaHash = metaHash; existingBook.title = book.title; existingBook.sourceTitle = book.sourceTitle; existingBook.author = book.author; existingBook.primaryLanguage = book.primaryLanguage; existingBook.metadata = book.metadata; existingBook.uploadedAt = null; existingBook.downloadedAt = Date.now(); } else if (existingBook) { // Same file hash: preserve user edits existingBook.format = book.format; existingBook.metaHash = metaHash; existingBook.title = existingBook.title.trim() ? existingBook.title.trim() : book.title; existingBook.sourceTitle = existingBook.sourceTitle ?? book.sourceTitle; existingBook.author = existingBook.author ?? book.author; existingBook.primaryLanguage = existingBook.primaryLanguage ?? book.primaryLanguage; existingBook.metadata = book.metadata; existingBook.downloadedAt = Date.now(); } if (!(await fs.exists(getDir(book), 'Books'))) { await fs.createDir(getDir(book), 'Books'); } const bookFilename = getLocalBookFilename(book); const willWriteBookFile = saveBook && !transient && !inPlace && !!fileobj && (!(await fs.exists(bookFilename, 'Books')) || overwrite); if (willWriteBookFile && fileobj) { if (/\.txt$/i.test(filename)) { await fs.writeFile(bookFilename, 'Books', fileobj); } else if (typeof file === 'string' && isContentURI(file)) { await fs.copyFile(file, 'None', bookFilename, 'Books'); } else if (typeof file === 'string' && !isValidURL(file)) { try { // try to copy the file directly first in case of large files to avoid memory issues // on desktop when reading recursively from selected directory the direct copy will fail // due to permission issues, then fallback to read and write files await fs.copyFile(file, 'None', bookFilename, 'Books'); } catch { await fs.writeFile(bookFilename, 'Books', await fileobj.arrayBuffer()); } } else { await fs.writeFile(bookFilename, 'Books', fileobj); } } if (saveCover && (!(await fs.exists(getCoverFilename(book), 'Books')) || overwrite)) { let cover = await loadedBook.getCover(); if (cover?.type === 'image/svg+xml') { try { console.log('Converting SVG cover to PNG...'); cover = await svg2png(cover); } catch {} } if (cover) { const coverBytes = await cover.arrayBuffer(); await fs.writeFile(getCoverFilename(book), 'Books', coverBytes); } } // Never overwrite the config file only when it's not existed if (!existingBook) { await saveBookConfigFn(book, INIT_BOOK_CONFIG); books.push(book); if (lookupIndex) { lookupIndex.byHash.set(book.hash, book); if (book.metaHash) { const key = `${book.metaHash}:${book.format}`; const list = lookupIndex.byMetaKey.get(key); if (list) list.push(book); else lookupIndex.byMetaKey.set(key, [book]); } } } else if (metaHashMatch && oldBookDir && oldBookDir !== getDir(book)) { // Migrate config from old directory to new directory, updating bookHash and metaHash // Use aggregated best config when available from deduplication if (bestConfigData) { const config: Partial = JSON.parse(bestConfigData); config.bookHash = hash; config.metaHash = metaHash; await fs.writeFile(getConfigFilename(book), 'Books', serializeRawConfig(config)); } else { const oldConfigPath = `${oldBookDir}/config.json`; if (await fs.exists(oldConfigPath, 'Books')) { const configData = (await fs.readFile(oldConfigPath, 'Books', 'text')) as string; const config: Partial = JSON.parse(configData); config.bookHash = hash; config.metaHash = metaHash; await fs.writeFile(getConfigFilename(book), 'Books', serializeRawConfig(config)); } else { await saveBookConfigFn(book, INIT_BOOK_CONFIG); } } // Clean up old directory if (await fs.exists(oldBookDir, 'Books')) { await fs.removeDir(oldBookDir, 'Books', true); } } else if (bestConfigData) { // Exact hash match with duplicates removed — adopt the best config const config: Partial = JSON.parse(bestConfigData); config.bookHash = hash; config.metaHash = metaHash; await fs.writeFile(getConfigFilename(book), 'Books', serializeRawConfig(config)); } // update file links with url or path or content uri if (isPseStream) { book.url = file as string; if (existingBook) existingBook.url = file as string; } else if (typeof file === 'string') { if (isValidURL(file)) { book.url = file; if (existingBook) existingBook.url = file; } else if (transient || inPlace) { // transient: source file is loaded directly, never persisted in Books/. // inPlace: source file is inside the user's library root and we read it // there directly instead of duplicating it under Books//. book.filePath = file; if (existingBook) existingBook.filePath = file; } } // Now that `filePath` is set, keep the path index in sync so later files // in the same batch (and the next call into importBook) can hit the // in-place fast path. Only persistent in-place imports go into the // index — transient previews are short-lived and never persisted to // the library so indexing them would just leak references. if (lookupIndex && inPlace && !transient && typeof file === 'string') { const indexedBook = existingBook || book; if (indexedBook.filePath) { const key = normalizeFilePathForIndex(indexedBook.filePath, osPlatform); if (key) lookupIndex.byFilePath.set(key, indexedBook); } } book.coverImageUrl = await generateCoverImageUrlFn(book); const f = file as ClosableFile; if (f && f.close) { await f.close(); } return existingBook || book; } catch (error) { console.error('Error importing book:', error); throw error; } } // --- Book Content & Config --- export async function isBookAvailable(fs: FileSystem, book: Book): Promise { return (await resolveBookContentSource(fs, book)).kind !== 'missing'; } export async function getBookFileSize(fs: FileSystem, book: Book): Promise { const source = await resolveBookContentSource(fs, book); if (source.kind !== 'managed' && source.kind !== 'external') { return null; } const file = await fs.openFile(source.path, source.base); const size = file.size; const f = file as ClosableFile; if (f && f.close) { await f.close(); } return size; } async function openBookFileContent( fs: FileSystem, book: Book, ): Promise<{ source: BookFileContentSource; file: File; }> { const source = await resolveBookContentSource(fs, book); if (!isBookFileContentSource(source)) { throw new BookFileNotFoundError(); } return { source, file: await fs.openFile(source.path, source.base) }; } export async function loadBookContent(fs: FileSystem, book: Book): Promise { const { file } = await openBookFileContent(fs, book); return { book, file }; } /** * Best-effort resolution of an absolute, on-disk filesystem path for a book. * * Returns null when the book is not stored on disk (e.g. in-memory blob, * remote URL) or the path cannot be resolved. The returned path is * suitable for handing to native (Rust) commands that read the file * directly via std::fs. */ export async function resolveNativeBookFilePath( fs: FileSystem, resolveFilePath: (path: string, base: BaseDir) => Promise, book: Book, ): Promise { try { const source = await resolveBookContentSource(fs, book); if (source.kind !== 'managed' && source.kind !== 'external') return null; const fp = await resolveFilePath(source.path, source.base); if (!fp) return null; return fp.startsWith('file://') ? decodeURI(fp.slice('file://'.length)) : fp; } catch { return null; } } export async function loadBookConfig( fs: FileSystem, book: Book, settings: SystemSettings, ): Promise { const globalViewSettings = { ...settings.globalViewSettings, ...(FIXED_LAYOUT_FORMATS.has(book.format) ? DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS : {}), }; try { let str = '{}'; if (await fs.exists(getConfigFilename(book), 'Books')) { str = (await fs.readFile(getConfigFilename(book), 'Books', 'text')) as string; } return deserializeConfig(str, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); } catch { return deserializeConfig('{}', globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); } } export async function saveBookConfig( fs: FileSystem, book: Book, config: BookConfig, settings?: SystemSettings, ): Promise { let serializedConfig: string; if (settings) { const globalViewSettings = { ...settings.globalViewSettings, ...(FIXED_LAYOUT_FORMATS.has(book.format) ? DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS : {}), }; serializedConfig = serializeConfig(config, globalViewSettings, DEFAULT_BOOK_SEARCH_CONFIG); } else { serializedConfig = serializeRawConfig(config); } await fs.writeFile(getConfigFilename(book), 'Books', serializedConfig); } export async function loadBookNav(fs: FileSystem, book: Book): Promise { try { const path = getBookNavFilename(book); if (!(await fs.exists(path, 'Books'))) return null; const str = (await fs.readFile(path, 'Books', 'text')) as string; const parsed = JSON.parse(str) as BookNav; if (!parsed || typeof parsed.version !== 'number') return null; return parsed; } catch { return null; } } export async function saveBookNav(fs: FileSystem, book: Book, nav: BookNav): Promise { await fs.writeFile(getBookNavFilename(book), 'Books', JSON.stringify(nav)); } export async function fetchBookDetails( fs: FileSystem, book: Book, downloadBookFn: (book: Book) => Promise, ): Promise { const fp = getLocalBookFilename(book); if (!(await fs.exists(fp, 'Books')) && book.uploadedAt) { await downloadBookFn(book); } const { file } = await loadBookContent(fs, book); const bookDoc = (await new DocumentLoader(file).open()).book; const f = file as ClosableFile; if (f && f.close) { await f.close(); } return bookDoc.metadata; } /** * Refresh metadata for a single book by re-opening and re-parsing its file. * Updates series info, language, and other metadata fields without modifying * user-edited titles or reading progress. * Returns true if the metadata was successfully refreshed. */ export async function refreshBookMetadata(fs: FileSystem, book: Book): Promise { const { file } = await loadBookContent(fs, book); const { book: bookDoc } = await new DocumentLoader(file).open(); if (!bookDoc) return false; book.metadata = bookDoc.metadata; book.metaHash = getMetadataHash(bookDoc.metadata); const primaryLanguage = getPrimaryLanguage(bookDoc.metadata.language); if (primaryLanguage) { book.primaryLanguage = primaryLanguage; } // Update series info from metadata if (book.metadata?.belongsTo?.series) { const belongsTo = book.metadata.belongsTo.series; const series = Array.isArray(belongsTo) ? belongsTo[0] : belongsTo; if (series) { book.metadata.series = formatTitle(series.name); book.metadata.seriesIndex = parseFloat(series.position || '0'); if (series.total) book.metadata.seriesTotal = parseInt(series.total, 10); } } return true; } export async function exportBook( fs: FileSystem, book: Book, resolveFilePath: (path: string, base: BaseDir) => Promise, copyFile: (srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir) => Promise, saveFile: ( filename: string, content: ArrayBuffer, options?: { filePath?: string; mimeType?: string }, ) => Promise, ): Promise { const { source, file } = await openBookFileContent(fs, book); const content = await file.arrayBuffer(); const filename = `${makeSafeFilename(book.title)}.${book.format.toLowerCase()}`; const mimeType = file.type || 'application/octet-stream'; if (source.kind === 'url') { return await saveFile(filename, content, { mimeType }); } let filePath = await resolveFilePath(source.path, source.base); if (getFilename(filePath) !== filename) { await copyFile(source.path, source.base, filename, 'Temp'); filePath = await resolveFilePath(filename, 'Temp'); } return await saveFile(filename, content, { filePath, mimeType }); }