675ee78bc9
Re-importing a folder via the in-place option used to reopen, parse,
and partial-MD5 every file before its byHash entry could short-circuit
the import. Worse, the byHash short-circuit treated every hit as "user
dropped a fresh file matching a known book", so it refreshed
createdAt/updatedAt/downloadedAt and cleared filePath/deletedAt. For a
user re-scanning the same external folder, that quietly rewrote sort
order and wiped soft-delete state. And once the import returned, the
ingest pipeline still ran group / tag / upload work — including a
path-derived empty groupId that silently clobbered manual
GroupingModal assignments on every re-scan.
This change adds an explicit byFilePath fast path at the top of
`ingestFile` so a re-scan returns the existing library entry verbatim,
before any I/O AND before any downstream side effect:
byFilePath hit -> the same on-disk source is being re-scanned in
place; the right answer is no-op. Don't open the
file, don't touch any timestamps, don't re-cover,
don't run the group / tag / upload steps.
byHash hit -> a different source path resolves to a known book
(e.g. the user dropped a copy from elsewhere, or
a soft-deleted book is being revived); the
existing "refresh timestamps + clear deletedAt"
behavior in importBook is correct here and is
left intact.
Implementation:
- BookLookupIndex carries byHash / byMetaKey / byFilePath, with
byFilePath built only from non-deleted books that have an absolute
filePath. normalizeFilePathForIndex is the shared key function so
shouldImportInPlace and the index agree on case-insensitive
filesystems (macOS / iOS / Windows). osPlatform threads through
buildBookLookupIndex and BaseAppService.importBook so the renderer
and the importer compute the same key for the same file.
- ingestService.ingestFile gains a byFilePath fast path before its
importBook call: when `inPlace` was decided positive, the source
is a real on-disk path (not a PSE stream / URL / content URI),
and lookupIndex.byFilePath has a hit, return the existing Book
directly. Returning here — rather than inside importBook — is
deliberate: it skips the downstream group / tag / upload steps so
a re-scan can never silently overwrite a manual GroupingModal
assignment via a path-derived empty groupId.
- ingest-service.test.ts covers both halves: an in-place re-import
short-circuits importBook entirely (no call, existing object
returned, createdAt / updatedAt / groupId / groupName all
untouched); a copy-mode import (no external library folders) with
the same byFilePath entry still goes through importBook so dedup
falls back to byHash. import-metahash.test.ts retains the
BookLookupIndex builder test that deleted and url-backed books
are excluded from byFilePath.
Net effect: re-importing a folder of N already-imported books does
zero file opens, zero parses, zero MD5 passes, and leaves every book's
groupId / createdAt / deletedAt / cover untouched.
793 lines
29 KiB
TypeScript
793 lines
29 KiB
TypeScript
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<string, Book>();
|
|
const byMetaKey = new Map<string, Book[]>();
|
|
const byFilePath = new Map<string, Book>();
|
|
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<string> {
|
|
return ctx.fs.getBlobURL(`${ctx.localBooksDir}/${getCoverFilename(book)}`, 'None');
|
|
}
|
|
|
|
export async function getCachedImageUrl(ctx: CoverContext, pathOrUrl: string): Promise<string> {
|
|
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<string> {
|
|
return ctx.appPlatform === 'web'
|
|
? await getCoverImageBlobUrl(ctx, book)
|
|
: getCoverImageUrl(ctx, book);
|
|
}
|
|
|
|
function imageToArrayBuffer(
|
|
ctx: CoverContext,
|
|
imageUrl?: string,
|
|
imageFile?: string,
|
|
): Promise<ArrayBuffer> {
|
|
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<void> {
|
|
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<string | undefined> {
|
|
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<BookConfig>[] = [];
|
|
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<string, BookNote>();
|
|
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<void>;
|
|
generateCoverImageUrl: (book: Book) => Promise<string>;
|
|
/**
|
|
* 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<Book | null> {
|
|
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<BookConfig> = 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<BookConfig> = 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<BookConfig> = 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/<hash>/.
|
|
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<boolean> {
|
|
return (await resolveBookContentSource(fs, book)).kind !== 'missing';
|
|
}
|
|
|
|
export async function getBookFileSize(fs: FileSystem, book: Book): Promise<number | null> {
|
|
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<BookContent> {
|
|
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<string>,
|
|
book: Book,
|
|
): Promise<string | null> {
|
|
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<BookConfig> {
|
|
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<void> {
|
|
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<BookNav | null> {
|
|
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<void> {
|
|
await fs.writeFile(getBookNavFilename(book), 'Books', JSON.stringify(nav));
|
|
}
|
|
|
|
export async function fetchBookDetails(
|
|
fs: FileSystem,
|
|
book: Book,
|
|
downloadBookFn: (book: Book) => Promise<void>,
|
|
): Promise<BookDoc['metadata']> {
|
|
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<boolean> {
|
|
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<string>,
|
|
copyFile: (srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir) => Promise<void>,
|
|
saveFile: (
|
|
filename: string,
|
|
content: ArrayBuffer,
|
|
options?: { filePath?: string; mimeType?: string },
|
|
) => Promise<boolean>,
|
|
): Promise<boolean> {
|
|
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 });
|
|
}
|