ff605e000d
* feat(library): in-place import with cloud sync and symmetric local delete
Adds an `inPlace` option to importBook so a source file inside a
registered external library folder is referenced directly via
`book.filePath` instead of being copied into Books/<hash>/. Sidecars
(cover, config, nav) still live under Books/<hash>/.
ingestService routes through shouldImportInPlace, which marks an
import in-place when the absolute source path lives under any of
`settings.externalLibraryFolders` and is NOT inside a per-root
`Books/` subtree. The Readest data dir (`customRootDir`) is
intentionally excluded — that directory is Readest's home and
should freely hold hash copies; in-place is for user-registered
roots (Duokan, Calibre, Moon+ Reader, an iCloud mirror, …).
Cloud sync treats in-place books as first-class:
- uploadBook reads bytes from (book.filePath, 'None') when set.
The cloud key is unchanged, so a peer downloading the book
lands it under Books/<hash>/ as a normal hash copy.
- useBooksSync strips `book.filePath` before pushing — it is a
device-local path that is meaningless on any other device.
- ingestService no longer skips upload for in-place books;
autoUpload / forceUpload behave like any other book. Only
transient imports opt out.
- deleteBook 'local'/'both' now physically removes the source
file at book.filePath (base 'None'). Local-delete semantics
are symmetric with hash-copy books: the local copy is gone,
the cloud backup remains, a future pull restores under
Books/<hash>/. removeFile errors are swallowed.
New `SystemSettings.externalLibraryFolders?: string[]` (no UI yet;
registration entrypoint lands in a follow-up). Added to
BACKUP_SETTINGS_BLACKLIST alongside `localBooksDir` /
`customRootDir` so device-local paths don't ride cloud backups.
Tests: cloud-service, ingest-service, and backup-settings suites
cover in-place delete, multi-root matching, per-root `Books/`
guard, and the backup-strip.
* feat(library): one-tap "read in place" toggle in folder import
Surface the in-place / copy choice as a single "Read books in place (don't copy)" checkbox in the Import-from-Folder dialog. When the user opts in, the chosen directory is registered in `settings.externalLibraryFolders` and ingestService's `shouldImportInPlace` will route the books straight to importBook with `inPlace: true` — no copy into Books/<hash>/, sync still works, local delete still removes the source file (the symmetry was set up in the previous in-place commit).
User experience:
- First-time users hit the toggle once per library folder. The choice is also persisted to localStorage so subsequent dialog opens default to whatever they picked last.
- Repeat imports from a folder that's already registered as an external library folder force the toggle ON and disable it, with a help line explaining that imports from this folder are always in-place. The check is exact-string (after path normalization) so registering /Users/me/Duokan only locks the toggle for that exact path — picking /Users/me/Downloads after Duokan still shows the toggle in its normal state.
- URL-ingress / drag-drop replays go through `runFolderImport` without the dialog and default `readInPlace: false`. They still benefit from in-place automatically when the dropped path lives under an already-registered root, because that decision is made by `shouldImportInPlace` based on settings, not by the dialog flag.
Mechanics:
- ImportFromFolderResult gains `readInPlace: boolean`. ImportFromFolderDialog gains an `initialReadInPlace` prop (seeded from the new `readest:lastImportFolderReadInPlace` localStorage key) and an `isRegisteredExternalRoot` predicate it uses to render the locked / unlocked toggle.
- runFolderImport calls a new `registerExternalLibraryFolder` helper that appends the chosen directory to `settings.externalLibraryFolders` and persists settings, but only when `result.readInPlace` is true. `isRegisteredExternalRoot` does the inverse lookup the dialog needs. Both helpers normalize paths the same way `shouldImportInPlace` does so the predicate matches the ingest layer.
- The new feature has no effect for users who never flip the toggle: `externalLibraryFolders` stays empty, the path-prefix check in `shouldImportInPlace` returns false for every import, and books continue to be copied into Books/<hash>/ exactly as before.
Self-healing for externally-removed in-place books:
Once the dialog lets users opt their library into in-place mode, the source file becomes a piece of state Readest doesn't control — another app may rewrite it (e.g. Duokan persisting reading progress into the epub), the user may move it in Finder, or an external drive may unmount between sessions. Previously, clicking such a book would navigate into the reader, fail inside loadBookContent's `fs.openFile(book.filePath, 'None')` with a low-level IO error, flash an "Unable to open book" toast, and auto-bounce back to the library — leaving the stale library record in place so the next tap reproduces the same dance.
BookshelfItem.handleBookClick now probes availability before navigating, but only for purely-local in-place books (`book.filePath && !book.uploadedAt && !book.deletedAt`). If `appService.isBookAvailable` returns false — which for in-place books means the recorded `book.filePath` no longer exists at the OS level — we dispatch `delete-books` for that hash and show an info toast explaining the removal, instead of opening the reader.
Scope is intentionally narrow:
- Cloud-synced books still flow through `makeBookAvailable`'s on-demand download path; missing local copies trigger a re-download, not a deletion.
- Hash-copy books (no `filePath` set) are not probed: a missing Books/<hash>/ file under normal use signals a bug or filesystem corruption, not user intent, and silently dropping the record would hide the real problem.
- The dispatched delete-books event reuses the existing Bookshelf deletion path, so sidecar metadata and selection state are cleaned up the same way as a user-initiated delete. For in-place books that path doesn't touch any file outside Books/<hash>/, so the now-missing source location (or whatever the user did with it externally) is left alone — symmetric with 165f15a6.
* fix(library): centralize book content resolution
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
571 lines
21 KiB
TypeScript
571 lines
21 KiB
TypeScript
import type { Configuration, ZipWriter } from '@zip.js/zip.js';
|
|
import { AppService } from '@/types/system';
|
|
import { EXTS } from '@/libs/document';
|
|
import { isTauriAppPlatform } from '@/services/environment';
|
|
import { Book, BookConfig, BookNote } from '@/types/book';
|
|
import { SystemSettings } from '@/types/settings';
|
|
import { getLibraryFilename } from '@/utils/book';
|
|
import { stampBookConfigSchema } from '@/utils/serializer';
|
|
import { configureZip } from '@/utils/zip';
|
|
|
|
/** Book file extensions for identifying book files in backup directories. */
|
|
const BOOK_EXTS = new Set(Object.values(EXTS));
|
|
|
|
/** Root-level zip entry name for the backed-up global settings snapshot. */
|
|
export const SETTINGS_BACKUP_FILENAME = 'settings.json';
|
|
|
|
/**
|
|
* Options controlling what a backup zip includes.
|
|
*/
|
|
export interface BackupOptions {
|
|
/**
|
|
* Include account credentials (sync tokens, passwords, API keys) in the
|
|
* settings snapshot. The backup zip is unencrypted, so this is opt-in and
|
|
* defaults to false.
|
|
*/
|
|
includeCredentials?: boolean;
|
|
}
|
|
|
|
/**
|
|
* SystemSettings dot-paths excluded from backups. Each is either tied to
|
|
* this device (and meaningless to restore elsewhere) or sync/migration
|
|
* bookkeeping that would corrupt state if restored stale. Restore keeps
|
|
* the current device's value for every path here — see issue #4098.
|
|
*/
|
|
export const BACKUP_SETTINGS_BLACKLIST = [
|
|
// Device filesystem paths — invalid on another device / OS.
|
|
'localBooksDir',
|
|
'customRootDir',
|
|
'externalLibraryFolders',
|
|
'savedBookCoverForLockScreenPath',
|
|
// Per-device identity — restoring causes sync identity / HLC collisions.
|
|
'replicaDeviceId',
|
|
'kosync.deviceId',
|
|
// Sync cursors — stale values make sync skip pulls or re-push everything.
|
|
'lastSyncedAtBooks',
|
|
'lastSyncedAtConfigs',
|
|
'lastSyncedAtNotes',
|
|
'lastSyncedAtReplicas',
|
|
'readwise.lastSyncedAt',
|
|
'hardcover.lastSyncedAt',
|
|
// Transient runtime state — book keys may not exist post-restore; screen
|
|
// brightness is live device state.
|
|
'lastOpenBooks',
|
|
'screenBrightness',
|
|
// Schema versioning — restore keeps the current device's value so its
|
|
// migrations are not skipped.
|
|
'version',
|
|
'migrationVersion',
|
|
] as const;
|
|
|
|
/**
|
|
* Credential dot-paths stripped from backups unless `includeCredentials`
|
|
* is set. OPDS catalog credentials live inside the `opdsCatalogs` array
|
|
* and are handled separately in `sanitizeSettingsForBackup`.
|
|
*/
|
|
export const BACKUP_SETTINGS_CREDENTIAL_FIELDS = [
|
|
'kosync.username',
|
|
'kosync.userkey',
|
|
'kosync.password',
|
|
'readwise.accessToken',
|
|
'hardcover.accessToken',
|
|
'aiSettings.aiGatewayApiKey',
|
|
'aiSettings.openrouterApiKey',
|
|
] as const;
|
|
|
|
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
|
|
/** Delete a dot-path key from a deep object; no-op when the path is absent. */
|
|
const deletePath = (obj: Record<string, unknown>, path: string): void => {
|
|
const parts = path.split('.');
|
|
let cur: Record<string, unknown> = obj;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const next = cur[parts[i]!];
|
|
if (!isPlainObject(next)) return;
|
|
cur = next;
|
|
}
|
|
delete cur[parts[parts.length - 1]!];
|
|
};
|
|
|
|
/**
|
|
* Produce a copy of SystemSettings safe to write into a backup zip:
|
|
* strips device-specific / sync-bookkeeping fields always, and account
|
|
* credentials unless `includeCredentials` is set. Input is not mutated.
|
|
*/
|
|
export function sanitizeSettingsForBackup(
|
|
settings: SystemSettings,
|
|
options: BackupOptions = {},
|
|
): SystemSettings {
|
|
const clone = structuredClone(settings) as SystemSettings & Record<string, unknown>;
|
|
for (const path of BACKUP_SETTINGS_BLACKLIST) {
|
|
deletePath(clone, path);
|
|
}
|
|
if (!options.includeCredentials) {
|
|
for (const path of BACKUP_SETTINGS_CREDENTIAL_FIELDS) {
|
|
deletePath(clone, path);
|
|
}
|
|
if (Array.isArray(clone.opdsCatalogs)) {
|
|
clone.opdsCatalogs = clone.opdsCatalogs.map((catalog) => {
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const { username: _username, password: _password, ...rest } = catalog;
|
|
return rest;
|
|
});
|
|
}
|
|
}
|
|
return clone;
|
|
}
|
|
|
|
/** Recursively merge `source` onto `target`; objects merge, scalars/arrays replace. */
|
|
const deepMerge = (
|
|
target: Record<string, unknown>,
|
|
source: Record<string, unknown>,
|
|
): Record<string, unknown> => {
|
|
const out: Record<string, unknown> = { ...target };
|
|
for (const [key, value] of Object.entries(source)) {
|
|
const existing = out[key];
|
|
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
out[key] = deepMerge(existing, value);
|
|
} else {
|
|
out[key] = value;
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
/**
|
|
* Merge a restored settings snapshot onto the current device settings.
|
|
* Blacklisted fields are absent from the snapshot, so the current
|
|
* device's values for them are preserved. Neither input is mutated.
|
|
*/
|
|
export function mergeRestoredSettings(
|
|
current: SystemSettings,
|
|
backup: Partial<SystemSettings>,
|
|
): SystemSettings {
|
|
return deepMerge(
|
|
current as unknown as Record<string, unknown>,
|
|
backup as unknown as Record<string, unknown>,
|
|
) as unknown as SystemSettings;
|
|
}
|
|
|
|
/**
|
|
* Merge two BookConfigs: uses the config with higher reading progress as base,
|
|
* then merges booknotes from both (deduplicating by id, latest updatedAt wins).
|
|
*/
|
|
export function mergeBookConfigs(
|
|
current: Partial<BookConfig>,
|
|
backup: Partial<BookConfig>,
|
|
): Partial<BookConfig> {
|
|
const currentPage = current.progress?.[0] ?? 0;
|
|
const backupPage = backup.progress?.[0] ?? 0;
|
|
|
|
// Use the config with higher progress as base
|
|
const base = backupPage > currentPage ? { ...backup } : { ...current };
|
|
|
|
// Merge booknotes from both configs
|
|
const noteMap = new Map<string, BookNote>();
|
|
for (const note of current.booknotes ?? []) {
|
|
noteMap.set(note.id, note);
|
|
}
|
|
for (const note of backup.booknotes ?? []) {
|
|
const existing = noteMap.get(note.id);
|
|
if (!existing || (note.updatedAt || 0) > (existing.updatedAt || 0)) {
|
|
noteMap.set(note.id, note);
|
|
}
|
|
}
|
|
base.booknotes = [...noteMap.values()];
|
|
|
|
return stampBookConfigSchema(base);
|
|
}
|
|
|
|
/**
|
|
* Merge two Book metadata records: uses the one with higher updatedAt as base,
|
|
* then reconciles timestamps. Only marks as deleted if BOTH sides agree.
|
|
*/
|
|
export function mergeBookMetadata(current: Book, backup: Book): Book {
|
|
const base = backup.updatedAt > current.updatedAt ? { ...backup } : { ...current };
|
|
base.updatedAt = Math.max(current.updatedAt, backup.updatedAt);
|
|
base.createdAt = Math.min(current.createdAt, backup.createdAt);
|
|
// Only deleted if BOTH sides agree
|
|
base.deletedAt =
|
|
current.deletedAt && backup.deletedAt ? Math.max(current.deletedAt, backup.deletedAt) : null;
|
|
return base;
|
|
}
|
|
|
|
/** A book restored from a backup whose local copy had been soft-deleted. */
|
|
export interface RevivedBook {
|
|
/** The live library record, mutated in place by `reviveRestoredBooks`. */
|
|
book: Book;
|
|
/** The book's metadata as stored in the backup. */
|
|
backup: Book;
|
|
}
|
|
|
|
/**
|
|
* Fix up books revived from a backup — present (not deleted) in the backup
|
|
* but soft-deleted in the current library — see issue #4098.
|
|
*
|
|
* Their files were just re-extracted, so `downloadedAt` / `coverDownloadedAt`
|
|
* are taken from the backup record (the local deletion had cleared them).
|
|
*
|
|
* `updatedAt` is bumped so the restore out-ranks the cloud's deletion
|
|
* tombstone in the next sync's last-writer-wins merge. A single uniform
|
|
* offset is applied to every revived book, so their relative `updatedAt`
|
|
* order — and thus the library's "Updated" sort — is preserved exactly.
|
|
* `syncedAt` is cleared so the next push re-uploads them and corrects the
|
|
* cloud rows. Mutates the `book` of each entry in place.
|
|
*/
|
|
export function reviveRestoredBooks(revived: RevivedBook[], now: number = Date.now()): void {
|
|
if (revived.length === 0) return;
|
|
let maxUpdatedAt = 0;
|
|
for (const { book } of revived) {
|
|
if (book.updatedAt > maxUpdatedAt) maxUpdatedAt = book.updatedAt;
|
|
}
|
|
// offset >= 1 guarantees every book out-ranks its (un-bumped) cloud copy
|
|
// while a single shared offset keeps their relative order intact.
|
|
const offset = Math.max(1, now - maxUpdatedAt);
|
|
for (const { book, backup } of revived) {
|
|
book.updatedAt += offset;
|
|
book.syncedAt = null;
|
|
book.downloadedAt = backup.downloadedAt ?? book.downloadedAt ?? now;
|
|
book.coverDownloadedAt = backup.coverDownloadedAt ?? book.coverDownloadedAt ?? now;
|
|
}
|
|
}
|
|
|
|
/** Library metadata files to skip from the directory scan. */
|
|
const LIBRARY_META_FILES = new Set([
|
|
'library.json',
|
|
'library.json.bak',
|
|
'library_backup.json',
|
|
'library.db',
|
|
'library.db-shm',
|
|
'library.db-wal',
|
|
]);
|
|
|
|
function isLibraryMetaFile(path: string): boolean {
|
|
return LIBRARY_META_FILES.has(path);
|
|
}
|
|
|
|
type ProgressCallback = (current: number, total: number, filename: string) => void;
|
|
|
|
/**
|
|
* Shared logic: add all library entries to a ZipWriter.
|
|
*/
|
|
async function addBackupEntriesToZip(
|
|
writer: ZipWriter<unknown>,
|
|
appService: AppService,
|
|
options: BackupOptions,
|
|
onProgress?: ProgressCallback,
|
|
): Promise<void> {
|
|
const { Uint8ArrayReader } = await import('@zip.js/zip.js');
|
|
|
|
// Generate canonical library.json from the current storage backend
|
|
const books = await appService.loadLibraryBooks();
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
const libraryBooks = books.map(({ coverImageUrl, ...rest }) => rest);
|
|
const libraryJson = new TextEncoder().encode(JSON.stringify(libraryBooks, null, 2));
|
|
await writer.add(getLibraryFilename(), new Uint8ArrayReader(libraryJson));
|
|
|
|
// Add the global settings snapshot, sanitized of device-specific and
|
|
// (unless opted in) credential fields.
|
|
try {
|
|
const settings = await appService.loadSettings();
|
|
const sanitized = sanitizeSettingsForBackup(settings, options);
|
|
const settingsJson = new TextEncoder().encode(JSON.stringify(sanitized, null, 2));
|
|
await writer.add(SETTINGS_BACKUP_FILENAME, new Uint8ArrayReader(settingsJson));
|
|
} catch (error) {
|
|
console.warn('Skipping settings backup:', error);
|
|
}
|
|
|
|
// Add all book files, skipping library metadata files
|
|
const booksDir = await appService.resolveFilePath('', 'Books');
|
|
const files = await appService.readDirectory(booksDir, 'None');
|
|
const bookFiles = files.filter((f) => f.size > 0 && !isLibraryMetaFile(f.path));
|
|
const total = bookFiles.length;
|
|
|
|
for (let i = 0; i < bookFiles.length; i++) {
|
|
const file = bookFiles[i]!;
|
|
onProgress?.(i + 1, total, file.path);
|
|
try {
|
|
const content = await appService.readFile(file.path, 'Books', 'binary');
|
|
const data = new Uint8Array(content as ArrayBuffer);
|
|
await writer.add(file.path, new Uint8ArrayReader(data), { level: 0 });
|
|
} catch (error) {
|
|
console.warn(`Skipping file ${file.path}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
const ZIP_WRITE_CONFIG: Partial<Configuration> = {
|
|
useWebWorkers: true,
|
|
useCompressionStream: true,
|
|
chunkSize: 1 * 1024 * 1024, // 1MB chunks for streaming
|
|
};
|
|
|
|
/**
|
|
* Create a backup zip in memory, returning an ArrayBuffer.
|
|
* Used on web where streaming to a file is not available.
|
|
*/
|
|
export async function createBackupZip(
|
|
appService: AppService,
|
|
options: BackupOptions = {},
|
|
onProgress?: ProgressCallback,
|
|
): Promise<ArrayBuffer> {
|
|
await configureZip(ZIP_WRITE_CONFIG);
|
|
const { BlobWriter, ZipWriter } = await import('@zip.js/zip.js');
|
|
|
|
const blobWriter = new BlobWriter('application/zip');
|
|
const writer = new ZipWriter(blobWriter);
|
|
await addBackupEntriesToZip(writer, appService, options, onProgress);
|
|
await writer.close();
|
|
const blob = await blobWriter.getData();
|
|
return await blob.arrayBuffer();
|
|
}
|
|
|
|
/**
|
|
* Stream a backup zip directly to a file path on disk.
|
|
* Uses TransformStream so only chunks are held in memory at a time.
|
|
* Only available on Tauri (requires @tauri-apps/plugin-fs).
|
|
*/
|
|
export async function createBackupZipToFile(
|
|
appService: AppService,
|
|
filePath: string,
|
|
options: BackupOptions = {},
|
|
onProgress?: ProgressCallback,
|
|
): Promise<void> {
|
|
await configureZip(ZIP_WRITE_CONFIG);
|
|
const { ZipWriter } = await import('@zip.js/zip.js');
|
|
const { writeFile } = await import('@tauri-apps/plugin-fs');
|
|
|
|
const { readable, writable } = new TransformStream<Uint8Array>();
|
|
|
|
// Start streaming readable side to the file (runs concurrently)
|
|
const writePromise = writeFile(filePath, readable);
|
|
|
|
const writer = new ZipWriter(writable);
|
|
await addBackupEntriesToZip(writer, appService, options, onProgress);
|
|
await writer.close();
|
|
await writePromise;
|
|
}
|
|
|
|
/**
|
|
* Validate that zip entries contain a valid backup structure.
|
|
* Must contain library.json at the root level.
|
|
*/
|
|
export function validateBackupStructure(entryNames: string[]): boolean {
|
|
return entryNames.some((name) => name === getLibraryFilename());
|
|
}
|
|
|
|
/**
|
|
* Restore library from a zip backup, merging with existing data.
|
|
* - Override book files and cover images for existing books
|
|
* - Merge book config files (keep higher progress, merge notes)
|
|
* - Add new books not present in current library
|
|
* - Import orphan hash directories not listed in library.json
|
|
* - Restore global settings (settings.json), deep-merged onto current
|
|
*/
|
|
export async function restoreFromBackupZip(
|
|
appService: AppService,
|
|
zipBlob: Blob,
|
|
onProgress?: (current: number, total: number, filename: string) => void,
|
|
): Promise<{ booksAdded: number; booksUpdated: number; settingsRestored: boolean }> {
|
|
await configureZip();
|
|
const { BlobReader, ZipReader, Uint8ArrayWriter } = await import('@zip.js/zip.js');
|
|
|
|
const reader = new ZipReader(new BlobReader(zipBlob));
|
|
const entries = await reader.getEntries();
|
|
|
|
// Validate structure
|
|
const entryNames = entries.map((e) => e.filename);
|
|
if (!validateBackupStructure(entryNames)) {
|
|
await reader.close();
|
|
throw new Error('Invalid backup file: missing library.json');
|
|
}
|
|
|
|
// Filter to file entries only (directories don't have getData)
|
|
const fileEntries = entries.filter((e) => !e.directory);
|
|
|
|
// Read backup library.json
|
|
const libraryEntry = fileEntries.find((e) => e.filename === getLibraryFilename());
|
|
if (!libraryEntry) {
|
|
await reader.close();
|
|
throw new Error('Cannot read library.json from backup');
|
|
}
|
|
const libraryData = await libraryEntry.getData!(new Uint8ArrayWriter());
|
|
const backupBooks: Book[] = JSON.parse(new TextDecoder().decode(libraryData));
|
|
|
|
// Load current library
|
|
const currentBooks = await appService.loadLibraryBooks();
|
|
|
|
const currentBooksMap = new Map<string, Book>();
|
|
for (const book of currentBooks) {
|
|
currentBooksMap.set(book.hash, book);
|
|
}
|
|
|
|
// Collect orphan hash directories: in zip but not in library.json
|
|
const backupHashes = new Set(backupBooks.map((b) => b.hash));
|
|
const orphanHashes = new Set<string>();
|
|
for (const entry of fileEntries) {
|
|
const slashIdx = entry.filename.indexOf('/');
|
|
if (slashIdx < 0) continue;
|
|
const dir = entry.filename.slice(0, slashIdx);
|
|
if (dir && !backupHashes.has(dir)) {
|
|
orphanHashes.add(dir);
|
|
}
|
|
}
|
|
|
|
let booksAdded = 0;
|
|
let booksUpdated = 0;
|
|
const revivedBooks: RevivedBook[] = [];
|
|
const total = backupBooks.length + orphanHashes.size;
|
|
|
|
for (let i = 0; i < backupBooks.length; i++) {
|
|
const backupBook = backupBooks[i]!;
|
|
onProgress?.(i + 1, total, backupBook.title);
|
|
|
|
const existingBook = currentBooksMap.get(backupBook.hash);
|
|
const bookDir = backupBook.hash;
|
|
|
|
// Get all file entries for this book's directory
|
|
const bookFileEntries = fileEntries.filter((e) => e.filename.startsWith(`${bookDir}/`));
|
|
|
|
if (existingBook) {
|
|
// Update: override book file and cover, merge config
|
|
for (const entry of bookFileEntries) {
|
|
const data = await entry.getData!(new Uint8ArrayWriter());
|
|
|
|
if (entry.filename.endsWith('/config.json')) {
|
|
// Merge config
|
|
let currentConfig: Partial<BookConfig> = {};
|
|
try {
|
|
const str = (await appService.readFile(entry.filename, 'Books', 'text')) as string;
|
|
currentConfig = JSON.parse(str);
|
|
} catch {
|
|
/* use empty config if current doesn't exist */
|
|
}
|
|
|
|
const backupConfig: Partial<BookConfig> = JSON.parse(new TextDecoder().decode(data));
|
|
const mergedConfig = mergeBookConfigs(currentConfig, backupConfig);
|
|
await appService.writeFile(entry.filename, 'Books', JSON.stringify(mergedConfig));
|
|
} else {
|
|
// Override book file and cover image
|
|
await appService.writeFile(entry.filename, 'Books', data.buffer as ArrayBuffer);
|
|
}
|
|
}
|
|
|
|
// Merge book metadata (timestamps, deletedAt reconciliation). A book
|
|
// deleted locally but present in the backup is "revived" — collect it
|
|
// so its download state and updatedAt can be fixed up after the loop.
|
|
const wasRevived = !!existingBook.deletedAt && !backupBook.deletedAt;
|
|
Object.assign(existingBook, mergeBookMetadata(existingBook, backupBook));
|
|
if (wasRevived) revivedBooks.push({ book: existingBook, backup: backupBook });
|
|
booksUpdated++;
|
|
} else {
|
|
// Add new book: extract all files
|
|
if (!(await appService.exists(bookDir, 'Books'))) {
|
|
await appService.createDir(bookDir, 'Books');
|
|
}
|
|
for (const entry of bookFileEntries) {
|
|
const data = await entry.getData!(new Uint8ArrayWriter());
|
|
await appService.writeFile(entry.filename, 'Books', data.buffer as ArrayBuffer);
|
|
}
|
|
currentBooks.push(backupBook);
|
|
currentBooksMap.set(backupBook.hash, backupBook);
|
|
booksAdded++;
|
|
}
|
|
}
|
|
|
|
// Import orphan directories: hash dirs in zip not listed in library.json
|
|
let orphanIdx = 0;
|
|
for (const hash of orphanHashes) {
|
|
orphanIdx++;
|
|
if (currentBooksMap.has(hash)) continue;
|
|
onProgress?.(backupBooks.length + orphanIdx, total, hash);
|
|
const orphanEntries = fileEntries.filter((e) => e.filename.startsWith(`${hash}/`));
|
|
// Find the book file by extension
|
|
const bookEntry = orphanEntries.find((e) => {
|
|
const ext = e.filename.split('.').pop()?.toLowerCase() ?? '';
|
|
return BOOK_EXTS.has(ext);
|
|
});
|
|
if (!bookEntry) continue;
|
|
|
|
// Extract all files to the Books directory
|
|
if (!(await appService.exists(hash, 'Books'))) {
|
|
await appService.createDir(hash, 'Books');
|
|
}
|
|
for (const entry of orphanEntries) {
|
|
const data = await entry.getData!(new Uint8ArrayWriter());
|
|
await appService.writeFile(entry.filename, 'Books', data.buffer as ArrayBuffer);
|
|
}
|
|
|
|
// Import the book file from the extracted location
|
|
try {
|
|
const filePath = await appService.resolveFilePath(bookEntry.filename, 'Books');
|
|
const imported = await appService.importBook(filePath, currentBooks, { overwrite: true });
|
|
if (imported) {
|
|
currentBooksMap.set(imported.hash, imported);
|
|
booksAdded++;
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Failed to import orphan book from ${hash}:`, error);
|
|
}
|
|
}
|
|
|
|
// Make revived books out-rank the cloud's deletion tombstone in the
|
|
// next sync, without disturbing the library's "Updated" sort order.
|
|
reviveRestoredBooks(revivedBooks);
|
|
|
|
// Save merged library
|
|
await appService.saveLibraryBooks(currentBooks);
|
|
|
|
// Restore global settings if the backup carries them. Blacklisted
|
|
// fields are absent from the snapshot, so the current device keeps
|
|
// its own values for those after the deep merge.
|
|
let settingsRestored = false;
|
|
const settingsEntry = fileEntries.find((e) => e.filename === SETTINGS_BACKUP_FILENAME);
|
|
if (settingsEntry) {
|
|
try {
|
|
const data = await settingsEntry.getData!(new Uint8ArrayWriter());
|
|
const backupSettings: Partial<SystemSettings> = JSON.parse(new TextDecoder().decode(data));
|
|
const currentSettings = await appService.loadSettings();
|
|
await appService.saveSettings(mergeRestoredSettings(currentSettings, backupSettings));
|
|
settingsRestored = true;
|
|
} catch (error) {
|
|
console.warn('Failed to restore settings from backup:', error);
|
|
}
|
|
}
|
|
|
|
await reader.close();
|
|
|
|
return { booksAdded, booksUpdated, settingsRestored };
|
|
}
|
|
|
|
/**
|
|
* Create and save a backup zip file.
|
|
* On Tauri, streams directly to disk to avoid holding the entire zip in memory.
|
|
* On web, builds the zip in memory and triggers a download.
|
|
*/
|
|
export async function saveBackupFile(
|
|
appService: AppService,
|
|
filename: string,
|
|
options: BackupOptions = {},
|
|
onProgress?: ProgressCallback,
|
|
): Promise<boolean> {
|
|
if (isTauriAppPlatform()) {
|
|
// Tauri: stream directly to the chosen file path
|
|
const { save: saveDialog } = await import('@tauri-apps/plugin-dialog');
|
|
const ext = filename.split('.').pop() || 'zip';
|
|
const filePath = await saveDialog({
|
|
defaultPath: filename,
|
|
filters: [{ name: ext.toUpperCase(), extensions: [ext] }],
|
|
});
|
|
if (!filePath) return false;
|
|
await createBackupZipToFile(appService, filePath, options, onProgress);
|
|
return true;
|
|
} else {
|
|
// Web: build zip in memory then save
|
|
const zipData = await createBackupZip(appService, options, onProgress);
|
|
let filePath: string | undefined;
|
|
return appService.saveFile(filename, zipData, { filePath, mimeType: 'application/zip' });
|
|
}
|
|
}
|