refactor(sync): provider-agnostic file-sync engine with incremental WebDAV sync (#4784)
* refactor(sync): extract provider-agnostic layout paths Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): extract wire envelope module Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): extract pure merge module with law tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): add FileSyncProvider and LocalStore interfaces Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): FileSyncEngine orchestration over a provider Port WebDAVSync's per-book + library-wide sync onto FileSyncProvider + LocalStore. Behavior preserved; the #4756 metadata-reconciliation test is retargeted to drive the engine through a fake provider + store. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): move WebDAV client + connect settings under providers/webdav Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): WebDAVProvider implementing FileSyncProvider Wraps the WebDAV transport client, maps WebDAVRequestError to the neutral FileSyncError, and owns Tauri streaming upload/download. Adds a provider-conformance suite future backends can run against. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): shared appService-backed LocalStore bridge Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(reader): drive WebDAV sync through FileSyncEngine Construct a WebDAVProvider + shared LocalStore + engine once per hook; the inline buffered/streaming book-file loader collapses into the provider + store, so the hook no longer imports tauriUpload or the file path helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(settings): drive WebDAV library sync + browse through the provider WebDAVForm now builds a WebDAVProvider + shared LocalStore + engine and calls engine.syncLibrary; the ~170-line inline callback block (buffered/streaming loaders, URL+auth construction) is gone. WebDAVBrowsePane builds a provider for the engine-level deleteRemoteBookDir cleanup helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): remove WebDAV-specific sync module, WebDAV is now a provider Delete src/services/webdav (WebDAVSync/WebDAVPaths + the transitional client and connect-settings shims). The superseded webdav-metadata-sync test is replaced by engine-metadata-sync; webdav-delete now drives deleteRemoteBookDir through a WebDAVProvider and asserts FileSyncError. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): hydrate library before WebDAV Sync now to prevent clobber Sync now while the library store was unloaded (app launched into reader/ settings without mounting the Library view) merged the engine's addBookToLibrary / updateBookMetadata against an empty in-memory library, persisting a downloaded book or a metadata update as the entire library and wiping what was on disk. Pre-existing bug surfaced during the file-sync review. Hydrate the store in handleSyncNow and harden the store bridge with a load-if-unloaded guard (mirrors useLibraryStore.updateBooks). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): make listDirectory honor the FileSyncError contract listDirectory threw a plain Error (and let raw fetch failures escape), so WebDAVProvider flattened every list() failure to FileSyncError(UNKNOWN). Throw the same WebDAVRequestError taxonomy as the file-level helpers (AUTH_FAILED / NOT_FOUND / NETWORK) so the provider maps them correctly. Add list() cases to the provider-conformance suite. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(sync): cover streaming upload, discovery/download, and receive paths The metadata-sync gate only exercised the buffered metadata + config-merge paths. Add engine tests for streaming uploadStream (+ HEAD short-circuit + one-shot retry), remote-only discovery -> streaming download -> addBook, and the receive strategy (pull-only, no config or index writes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(sync): incremental WebDAV Sync now + bounded concurrency Sync now was a full walk of every book each run (675 round-trips even when nothing changed). Default to incremental: diff the local library against the shared library.json index per hash and only process books whose local copy is newer (or absent). book.updatedAt bumps on every progress/notes/metadata save (bookDataStore.saveConfig), so the index is a reliable per-book change marker. Remote-newer books pull their config in the reconcile pass so peer progress still propagates. A new 'Full Sync' toggle (default off) re-checks everything. Also run the reconcile / download / push phases over a bounded worker pool (default concurrency 4) instead of one book at a time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(sync): simplify Sync now toast to a single book count The completion toast built a multi-line success bullet list (downloaded / pulled / pushed / uploaded). Replace it with the same single-line info toast the native cloud sync uses: '{{count}} book(s) synced'. Add a booksSynced counter to the engine result (a Set of distinct hashes touched in any direction, since the per-action counters overlap under Full Sync). Failures still surface as a warning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): raise toasts above modals so they aren't hidden by open dialogs Toasts rendered at z-50, below the Settings dialog (z-110) and ModalPortal (z-120), so a toast dispatched from an open dialog (e.g. WebDAV 'Sync now') was buried. The documented overlay scale already places toast at 130; the component just hadn't followed it. Move the toast to z-[130] and extend the zIndexScale invariant test to guard TOAST > MODAL/SETTINGS and APP_LOCK > TOAST. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Book } from '@/types/book';
|
||||
import type { AppService } from '@/types/system';
|
||||
import type { SystemSettings } from '@/types/settings';
|
||||
import type { EnvConfigType } from '@/services/environment';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { createAppLocalStore } from '@/services/sync/file/appLocalStore';
|
||||
|
||||
/**
|
||||
* Regression test for the library-clobber data-loss path: when "Sync now"
|
||||
* runs while the library store hasn't loaded yet (the app launched straight
|
||||
* into the reader / settings, never mounting the Library view), the engine's
|
||||
* addBookToLibrary / updateBookMetadata used to merge against the EMPTY
|
||||
* in-memory library and persist a downloaded book (or a metadata update) as
|
||||
* the *entire* library, wiping everything already on disk. The store bridge
|
||||
* now hydrates from disk first.
|
||||
*/
|
||||
|
||||
const makeBook = (hash: string, overrides: Partial<Book> = {}): Book => ({
|
||||
hash,
|
||||
format: 'EPUB',
|
||||
title: `Book ${hash}`,
|
||||
sourceTitle: `Book ${hash}`,
|
||||
author: 'A',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
let savedLibrary: Book[] | null;
|
||||
let appService: AppService;
|
||||
let envConfig: EnvConfigType;
|
||||
|
||||
const onDisk = [makeBook('a'), makeBook('b')];
|
||||
|
||||
beforeEach(() => {
|
||||
savedLibrary = null;
|
||||
// Simulate the unloaded store: the user hasn't visited the Library view.
|
||||
useLibraryStore.setState({ library: [], libraryLoaded: false, hashIndex: new Map() });
|
||||
appService = {
|
||||
loadLibraryBooks: vi.fn(async () => onDisk.map((b) => ({ ...b }))),
|
||||
saveLibraryBooks: vi.fn(async (books: Book[]) => {
|
||||
savedLibrary = books;
|
||||
}),
|
||||
generateCoverImageUrl: vi.fn(async () => 'blob:cover'),
|
||||
} as unknown as AppService;
|
||||
envConfig = { getAppService: async () => appService } as unknown as EnvConfigType;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const makeStore = () =>
|
||||
createAppLocalStore({ appService, settings: {} as SystemSettings, envConfig });
|
||||
|
||||
describe('createAppLocalStore — library hydration (data-loss guard)', () => {
|
||||
test('addBookToLibrary keeps existing on-disk books when the store is unloaded', async () => {
|
||||
await makeStore().addBookToLibrary(makeBook('c'));
|
||||
|
||||
expect(appService.loadLibraryBooks).toHaveBeenCalledTimes(1);
|
||||
expect(savedLibrary).not.toBeNull();
|
||||
const hashes = savedLibrary!.map((b) => b.hash).sort();
|
||||
// The downloaded book is appended; a, b must survive (no clobber to [c]).
|
||||
expect(hashes).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('updateBookMetadata does not wipe the library when the store is unloaded', async () => {
|
||||
await makeStore().updateBookMetadata(makeBook('a', { title: 'New Title', updatedAt: 9 }));
|
||||
|
||||
expect(appService.loadLibraryBooks).toHaveBeenCalled();
|
||||
expect(savedLibrary).not.toBeNull();
|
||||
const hashes = savedLibrary!.map((b) => b.hash).sort();
|
||||
// Both books survive; a is updated in place, b is untouched.
|
||||
expect(hashes).toEqual(['a', 'b']);
|
||||
expect(savedLibrary!.find((b) => b.hash === 'a')!.title).toBe('New Title');
|
||||
});
|
||||
|
||||
test('addBookToLibrary merges against an already-loaded store without reloading', async () => {
|
||||
useLibraryStore.getState().setLibrary([makeBook('a'), makeBook('b')]);
|
||||
await makeStore().addBookToLibrary(makeBook('c'));
|
||||
|
||||
expect(appService.loadLibraryBooks).not.toHaveBeenCalled();
|
||||
expect(savedLibrary!.map((b) => b.hash).sort()).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Book, BookConfig, BookNote } from '@/types/book';
|
||||
import { FileSyncEngine } from '@/services/sync/file/engine';
|
||||
import type { FileSyncProvider } from '@/services/sync/file/provider';
|
||||
import type { LocalStore } from '@/services/sync/file/localStore';
|
||||
import type { RemoteBookConfig, RemoteLibraryIndex } from '@/services/sync/file/wire';
|
||||
|
||||
/**
|
||||
* Behavior-preservation gate for the WebDAV→FileSyncEngine port (originally
|
||||
* webdav-metadata-sync.test.ts for issue #4756). Instead of mocking the
|
||||
* WebDAV transport client, we drive `FileSyncEngine` with a fake
|
||||
* `FileSyncProvider` (routed by path) and a fake `LocalStore`, asserting the
|
||||
* same last-writer-wins reconciliation on `book.updatedAt` and the same
|
||||
* pull-merge-before-push discipline.
|
||||
*/
|
||||
|
||||
const makeLocalBook = (overrides: Partial<Book> = {}): Book => ({
|
||||
hash: 'h1',
|
||||
format: 'EPUB',
|
||||
title: 'Old Title',
|
||||
sourceTitle: 'Old Title',
|
||||
author: 'Old Author',
|
||||
createdAt: 1,
|
||||
updatedAt: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeRemoteIndex = (book: Book, updatedAt = book.updatedAt): RemoteLibraryIndex => ({
|
||||
schemaVersion: 1,
|
||||
updatedAt,
|
||||
books: [book],
|
||||
});
|
||||
|
||||
const makeNote = (id: string, updatedAt: number): BookNote => ({
|
||||
id,
|
||||
type: 'annotation',
|
||||
cfi: `cfi-${id}`,
|
||||
note: '',
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
const makeRemoteConfig = (overrides: Partial<RemoteBookConfig> = {}): RemoteBookConfig => ({
|
||||
schemaVersion: 1,
|
||||
bookHash: 'h1',
|
||||
config: { updatedAt: 100 },
|
||||
booknotes: [],
|
||||
writerDeviceId: 'mobile',
|
||||
writerVersion: 'readest-webdav-1',
|
||||
updatedAt: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A fake provider routed by path: readText resolves the index for
|
||||
* library.json and the supplied envelope for config.json. writeText captures
|
||||
* whichever artefact a test cares about. No streaming methods, so the engine
|
||||
* uses the buffered path (and the store's null loaders skip uploads).
|
||||
*/
|
||||
const makeProvider = (
|
||||
index: RemoteLibraryIndex | null,
|
||||
remoteConfig: RemoteBookConfig | null,
|
||||
capture: { index?: RemoteLibraryIndex | null; config?: RemoteBookConfig | null },
|
||||
): FileSyncProvider => ({
|
||||
rootPath: '/',
|
||||
readText: vi.fn(async (path: string) => {
|
||||
if (path.endsWith('library.json')) return index ? JSON.stringify(index) : null;
|
||||
if (path.endsWith('config.json')) return remoteConfig ? JSON.stringify(remoteConfig) : null;
|
||||
return null;
|
||||
}),
|
||||
readBinary: vi.fn(async () => new ArrayBuffer(8)),
|
||||
head: vi.fn(async () => null),
|
||||
list: vi.fn(async () => []),
|
||||
writeText: vi.fn(async (path: string, body: string) => {
|
||||
if (path.endsWith('library.json')) capture.index = JSON.parse(body) as RemoteLibraryIndex;
|
||||
if (path.endsWith('config.json')) capture.config = JSON.parse(body) as RemoteBookConfig;
|
||||
}),
|
||||
writeBinary: vi.fn(async () => {}),
|
||||
ensureDir: vi.fn(async () => {}),
|
||||
deleteDir: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const makeStore = (overrides: Partial<LocalStore> = {}): LocalStore => ({
|
||||
loadConfig: async (): Promise<BookConfig> => ({ updatedAt: 50, booknotes: [] }),
|
||||
saveBookConfig: async () => {},
|
||||
loadBookFile: async () => null,
|
||||
resolveLocalBookPath: async () => null,
|
||||
saveBookFile: async () => {},
|
||||
prepareLocalBookPath: async () => '/local/path',
|
||||
loadBookCover: async () => null,
|
||||
saveBookCover: async () => {},
|
||||
addBookToLibrary: async () => {},
|
||||
updateBookMetadata: async () => {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('FileSyncEngine metadata reconciliation (#4756)', () => {
|
||||
test('pulls newer remote metadata for a book the device already has', async () => {
|
||||
const local = makeLocalBook({ updatedAt: 100 });
|
||||
const remote = makeLocalBook({ title: 'New Title', author: 'New Author', updatedAt: 200 });
|
||||
const capture: { index?: RemoteLibraryIndex | null } = {};
|
||||
const provider = makeProvider(makeRemoteIndex(remote, 200), null, capture);
|
||||
|
||||
const updateBookMetadata = vi.fn(async (_book: Book) => {});
|
||||
const saveBookCover = vi.fn(async (_book: Book, _bytes: ArrayBuffer) => {});
|
||||
const store = makeStore({ updateBookMetadata, saveBookCover });
|
||||
|
||||
const engine = new FileSyncEngine(provider, store);
|
||||
const result = await engine.syncLibrary([local], {
|
||||
strategy: 'silent',
|
||||
syncBooks: false,
|
||||
deviceId: 'pc-device',
|
||||
});
|
||||
|
||||
expect(updateBookMetadata).toHaveBeenCalledTimes(1);
|
||||
const merged = updateBookMetadata.mock.calls[0]![0];
|
||||
expect(merged.title).toBe('New Title');
|
||||
expect(merged.author).toBe('New Author');
|
||||
expect(result.metadataUpdated).toBe(1);
|
||||
expect(saveBookCover).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(capture.index).not.toBeNull();
|
||||
const indexedBook = capture.index!.books.find((b) => b.hash === 'h1')!;
|
||||
expect(indexedBook.title).toBe('New Title');
|
||||
});
|
||||
|
||||
test('does not overwrite local metadata when the local copy is newer', async () => {
|
||||
const local = makeLocalBook({ title: 'Local Newer', updatedAt: 300 });
|
||||
const remote = makeLocalBook({ title: 'Remote Older', updatedAt: 200 });
|
||||
const capture: { index?: RemoteLibraryIndex | null } = {};
|
||||
const provider = makeProvider(makeRemoteIndex(remote, 200), null, capture);
|
||||
|
||||
const updateBookMetadata = vi.fn(async (_book: Book) => {});
|
||||
const store = makeStore({ updateBookMetadata });
|
||||
|
||||
const engine = new FileSyncEngine(provider, store);
|
||||
const result = await engine.syncLibrary([local], {
|
||||
strategy: 'silent',
|
||||
syncBooks: false,
|
||||
deviceId: 'pc-device',
|
||||
});
|
||||
|
||||
expect(updateBookMetadata).not.toHaveBeenCalled();
|
||||
expect(result.metadataUpdated).toBe(0);
|
||||
const indexedBook = capture.index!.books.find((b) => b.hash === 'h1')!;
|
||||
expect(indexedBook.title).toBe('Local Newer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileSyncEngine config merge before push (Sync now must not blind-overwrite)', () => {
|
||||
test('unions remote booknotes into the pushed config instead of clobbering them', async () => {
|
||||
// Local book is newer than the index, so incremental includes it in the
|
||||
// push set (the only case where merge-before-push matters); the metadata
|
||||
// pass stays a no-op since the index copy isn't newer.
|
||||
const local = makeLocalBook({ updatedAt: 200 });
|
||||
const capture: { config?: RemoteBookConfig | null } = {};
|
||||
const provider = makeProvider(
|
||||
makeRemoteIndex(makeLocalBook({ updatedAt: 100 }), 100),
|
||||
makeRemoteConfig({ config: { updatedAt: 100 }, booknotes: [makeNote('remote-note', 100)] }),
|
||||
capture,
|
||||
);
|
||||
const store = makeStore({
|
||||
loadConfig: async (): Promise<BookConfig> => ({
|
||||
updatedAt: 50,
|
||||
booknotes: [makeNote('local-note', 50)],
|
||||
}),
|
||||
});
|
||||
|
||||
const engine = new FileSyncEngine(provider, store);
|
||||
await engine.syncLibrary([local], { strategy: 'silent', syncBooks: false, deviceId: 'pc' });
|
||||
|
||||
expect(capture.config).not.toBeNull();
|
||||
const ids = capture.config!.booknotes.map((n) => n.id).sort();
|
||||
expect(ids).toEqual(['local-note', 'remote-note']);
|
||||
});
|
||||
|
||||
test('does not regress newer remote progress with an older local push', async () => {
|
||||
// Local newer than the index -> pushed under incremental. The remote
|
||||
// config is nonetheless ahead on progress, so the pull-merge must carry the
|
||||
// remote page, not regress it.
|
||||
const local = makeLocalBook({ updatedAt: 200 });
|
||||
const capture: { config?: RemoteBookConfig | null } = {};
|
||||
const provider = makeProvider(
|
||||
makeRemoteIndex(makeLocalBook({ updatedAt: 100 }), 100),
|
||||
makeRemoteConfig({ config: { updatedAt: 200, progress: [50, 100] }, booknotes: [] }),
|
||||
capture,
|
||||
);
|
||||
const store = makeStore({
|
||||
loadConfig: async (): Promise<BookConfig> => ({
|
||||
updatedAt: 50,
|
||||
progress: [10, 100],
|
||||
booknotes: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const engine = new FileSyncEngine(provider, store);
|
||||
await engine.syncLibrary([local], { strategy: 'silent', syncBooks: false, deviceId: 'pc' });
|
||||
|
||||
expect(capture.config!.config.progress).toEqual([50, 100]);
|
||||
});
|
||||
|
||||
test('send strategy keeps the blind push (local authoritative, no pull-merge)', async () => {
|
||||
const local = makeLocalBook({ updatedAt: 100 });
|
||||
const capture: { config?: RemoteBookConfig | null } = {};
|
||||
const provider = makeProvider(
|
||||
null,
|
||||
makeRemoteConfig({ config: { updatedAt: 200 }, booknotes: [makeNote('remote-note', 200)] }),
|
||||
capture,
|
||||
);
|
||||
const store = makeStore({
|
||||
loadConfig: async (): Promise<BookConfig> => ({
|
||||
updatedAt: 50,
|
||||
booknotes: [makeNote('local-note', 50)],
|
||||
}),
|
||||
});
|
||||
|
||||
const engine = new FileSyncEngine(provider, store);
|
||||
await engine.syncLibrary([local], { strategy: 'send', syncBooks: false, deviceId: 'pc' });
|
||||
|
||||
const ids = capture.config!.booknotes.map((n) => n.id);
|
||||
expect(ids).toEqual(['local-note']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { Book } from '@/types/book';
|
||||
import { FileSyncEngine } from '@/services/sync/file/engine';
|
||||
import type { FileSyncProvider } from '@/services/sync/file/provider';
|
||||
import type { LocalStore } from '@/services/sync/file/localStore';
|
||||
import type { RemoteBookConfig, RemoteLibraryIndex } from '@/services/sync/file/wire';
|
||||
|
||||
/**
|
||||
* Coverage for the engine paths the behavior-preservation gate
|
||||
* (engine-metadata-sync) does not execute: streaming upload + HEAD
|
||||
* short-circuit, remote-only discovery -> streaming download -> addBook, and
|
||||
* the receive (pull-only) strategy. These carry the OOM-avoidance and
|
||||
* idempotency value of the original WebDAV sync.
|
||||
*/
|
||||
|
||||
const makeBook = (hash: string, overrides: Partial<Book> = {}): Book => ({
|
||||
hash,
|
||||
format: 'EPUB',
|
||||
title: `Book ${hash}`,
|
||||
sourceTitle: `Book ${hash}`,
|
||||
author: 'A',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
type Captured = { writes: { path: string; body: string }[] };
|
||||
|
||||
const fakeProvider = (
|
||||
opts: Partial<FileSyncProvider> & { captured?: Captured } = {},
|
||||
): FileSyncProvider => ({
|
||||
rootPath: '/',
|
||||
readText: opts.readText ?? (async () => null),
|
||||
readBinary: opts.readBinary ?? (async () => new ArrayBuffer(8)),
|
||||
head: opts.head ?? (async () => null),
|
||||
list: opts.list ?? (async () => []),
|
||||
writeText:
|
||||
opts.writeText ??
|
||||
(async (path: string, body: string) => {
|
||||
opts.captured?.writes.push({ path, body });
|
||||
}),
|
||||
writeBinary: opts.writeBinary ?? (async () => {}),
|
||||
ensureDir: opts.ensureDir ?? (async () => {}),
|
||||
deleteDir: opts.deleteDir ?? (async () => {}),
|
||||
uploadStream: opts.uploadStream,
|
||||
downloadStream: opts.downloadStream,
|
||||
});
|
||||
|
||||
const fakeStore = (opts: Partial<LocalStore> = {}): LocalStore => ({
|
||||
loadConfig: opts.loadConfig ?? (async () => null),
|
||||
saveBookConfig: opts.saveBookConfig ?? (async () => {}),
|
||||
loadBookFile: opts.loadBookFile ?? (async () => null),
|
||||
resolveLocalBookPath: opts.resolveLocalBookPath ?? (async () => null),
|
||||
saveBookFile: opts.saveBookFile ?? (async () => {}),
|
||||
prepareLocalBookPath: opts.prepareLocalBookPath ?? (async () => '/local/dst'),
|
||||
loadBookCover: opts.loadBookCover ?? (async () => null),
|
||||
saveBookCover: opts.saveBookCover ?? (async () => {}),
|
||||
addBookToLibrary: opts.addBookToLibrary ?? (async () => {}),
|
||||
updateBookMetadata: opts.updateBookMetadata ?? (async () => {}),
|
||||
});
|
||||
|
||||
describe('FileSyncEngine.pushBookFile — streaming upload', () => {
|
||||
test('streams via provider.uploadStream when remote is missing', async () => {
|
||||
const uploadStream = vi.fn(async () => true);
|
||||
const provider = fakeProvider({ head: async () => null, uploadStream });
|
||||
const store = fakeStore({
|
||||
resolveLocalBookPath: async () => ({ path: '/local/x.epub', size: 100 }),
|
||||
});
|
||||
|
||||
const res = await new FileSyncEngine(provider, store).pushBookFile(makeBook('h1'));
|
||||
|
||||
expect(res).toEqual({ uploaded: true });
|
||||
expect(uploadStream).toHaveBeenCalledTimes(1);
|
||||
expect(uploadStream).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/Readest/books/h1/'),
|
||||
'/local/x.epub',
|
||||
);
|
||||
});
|
||||
|
||||
test('HEAD size match short-circuits without uploading', async () => {
|
||||
const uploadStream = vi.fn(async () => true);
|
||||
const provider = fakeProvider({ head: async () => ({ size: 100 }), uploadStream });
|
||||
const store = fakeStore({
|
||||
resolveLocalBookPath: async () => ({ path: '/local/x.epub', size: 100 }),
|
||||
});
|
||||
|
||||
const res = await new FileSyncEngine(provider, store).pushBookFile(makeBook('h1'));
|
||||
|
||||
expect(res).toEqual({ uploaded: false, reason: 'remote-matches' });
|
||||
expect(uploadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('retries the stream once before failing', async () => {
|
||||
const uploadStream = vi
|
||||
.fn<(remotePath: string, localPath: string) => Promise<boolean>>()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true);
|
||||
const provider = fakeProvider({ head: async () => null, uploadStream });
|
||||
const store = fakeStore({
|
||||
resolveLocalBookPath: async () => ({ path: '/local/x.epub', size: 100 }),
|
||||
});
|
||||
|
||||
const res = await new FileSyncEngine(provider, store).pushBookFile(makeBook('h1'));
|
||||
|
||||
expect(res).toEqual({ uploaded: true });
|
||||
expect(uploadStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileSyncEngine.syncLibrary — remote discovery + streaming download', () => {
|
||||
test('discovers a remote-only book, streams it down, and adds it to the library', async () => {
|
||||
const downloadStream = vi.fn(async () => true);
|
||||
const provider = fakeProvider({
|
||||
list: async (path: string) =>
|
||||
path.endsWith('/books')
|
||||
? [{ name: 'h2', path: '/Readest/books/h2', isDirectory: true }]
|
||||
: [
|
||||
{
|
||||
name: 'Remote.epub',
|
||||
path: '/Readest/books/h2/Remote.epub',
|
||||
isDirectory: false,
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
downloadStream,
|
||||
});
|
||||
const addBookToLibrary = vi.fn<(book: Book) => Promise<void>>(async () => {});
|
||||
const prepareLocalBookPath = vi.fn(async () => '/local/h2/Remote.epub');
|
||||
const store = fakeStore({ addBookToLibrary, prepareLocalBookPath });
|
||||
|
||||
const res = await new FileSyncEngine(provider, store).syncLibrary([], {
|
||||
strategy: 'silent',
|
||||
syncBooks: false,
|
||||
deviceId: 'd',
|
||||
});
|
||||
|
||||
expect(downloadStream).toHaveBeenCalledWith(
|
||||
'/Readest/books/h2/Remote.epub',
|
||||
'/local/h2/Remote.epub',
|
||||
);
|
||||
expect(addBookToLibrary).toHaveBeenCalledTimes(1);
|
||||
expect(addBookToLibrary.mock.calls[0]![0].hash).toBe('h2');
|
||||
expect(res.booksDownloaded).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileSyncEngine.syncLibrary — receive strategy is pull-only', () => {
|
||||
test('never writes (no config push, no index re-push) under receive', async () => {
|
||||
const captured: Captured = { writes: [] };
|
||||
const provider = fakeProvider({ captured });
|
||||
const store = fakeStore({ loadConfig: async () => ({ updatedAt: 1, booknotes: [] }) });
|
||||
|
||||
const res = await new FileSyncEngine(provider, store).syncLibrary([makeBook('h1')], {
|
||||
strategy: 'receive',
|
||||
syncBooks: true,
|
||||
deviceId: 'd',
|
||||
});
|
||||
|
||||
expect(captured.writes).toHaveLength(0);
|
||||
expect(res.configsUploaded).toBe(0);
|
||||
expect(res.filesUploaded).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
const makeIndex = (books: Book[]): RemoteLibraryIndex => ({
|
||||
schemaVersion: 1,
|
||||
updatedAt: 1,
|
||||
books,
|
||||
});
|
||||
|
||||
const makeEnvelope = (over: Partial<RemoteBookConfig> = {}): RemoteBookConfig => ({
|
||||
schemaVersion: 1,
|
||||
bookHash: 'h1',
|
||||
config: { updatedAt: 100 },
|
||||
booknotes: [],
|
||||
writerDeviceId: 'peer',
|
||||
writerVersion: 'readest-webdav-1',
|
||||
updatedAt: 100,
|
||||
...over,
|
||||
});
|
||||
|
||||
const configWrites = (captured: Captured) =>
|
||||
captured.writes.filter((w) => w.path.endsWith('config.json'));
|
||||
|
||||
describe('FileSyncEngine.syncLibrary — incremental diff (default)', () => {
|
||||
test('skips a book whose updatedAt matches the remote index', async () => {
|
||||
const captured: Captured = { writes: [] };
|
||||
const provider = fakeProvider({
|
||||
readText: async (p) =>
|
||||
p.endsWith('library.json')
|
||||
? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })]))
|
||||
: null,
|
||||
captured,
|
||||
});
|
||||
const store = fakeStore({ loadConfig: async () => ({ updatedAt: 1, booknotes: [] }) });
|
||||
|
||||
const res = await new FileSyncEngine(provider, store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 100 })],
|
||||
{
|
||||
strategy: 'silent',
|
||||
syncBooks: false,
|
||||
deviceId: 'd',
|
||||
},
|
||||
);
|
||||
|
||||
expect(configWrites(captured)).toHaveLength(0);
|
||||
expect(res.configsUploaded).toBe(0);
|
||||
expect(res.booksSynced).toBe(0);
|
||||
// The index itself is still re-pushed.
|
||||
expect(captured.writes.some((w) => w.path.endsWith('library.json'))).toBe(true);
|
||||
});
|
||||
|
||||
test('pushes a book that is newer locally than the index', async () => {
|
||||
const captured: Captured = { writes: [] };
|
||||
const provider = fakeProvider({
|
||||
readText: async (p) =>
|
||||
p.endsWith('library.json')
|
||||
? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })]))
|
||||
: null,
|
||||
captured,
|
||||
});
|
||||
const store = fakeStore({ loadConfig: async () => ({ updatedAt: 1, booknotes: [] }) });
|
||||
|
||||
const res = await new FileSyncEngine(provider, store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 200 })],
|
||||
{
|
||||
strategy: 'silent',
|
||||
syncBooks: false,
|
||||
deviceId: 'd',
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.configsUploaded).toBe(1);
|
||||
expect(res.booksSynced).toBe(1);
|
||||
expect(configWrites(captured)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('pulls config + metadata for a book newer in the index', async () => {
|
||||
const captured: Captured = { writes: [] };
|
||||
const provider = fakeProvider({
|
||||
readText: async (p) => {
|
||||
if (p.endsWith('library.json'))
|
||||
return JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 200, title: 'Remote' })]));
|
||||
if (p.endsWith('config.json'))
|
||||
return JSON.stringify(makeEnvelope({ config: { updatedAt: 200, progress: [9, 10] } }));
|
||||
return null;
|
||||
},
|
||||
captured,
|
||||
});
|
||||
const saveBookConfig = vi.fn(async () => {});
|
||||
const updateBookMetadata = vi.fn(async () => {});
|
||||
const store = fakeStore({
|
||||
loadConfig: async () => ({ updatedAt: 1, booknotes: [] }),
|
||||
saveBookConfig,
|
||||
updateBookMetadata,
|
||||
});
|
||||
|
||||
const res = await new FileSyncEngine(provider, store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 100 })],
|
||||
{
|
||||
strategy: 'silent',
|
||||
syncBooks: false,
|
||||
deviceId: 'd',
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.metadataUpdated).toBe(1);
|
||||
expect(res.configsDownloaded).toBe(1);
|
||||
expect(res.booksSynced).toBe(1);
|
||||
expect(saveBookConfig).toHaveBeenCalledTimes(1);
|
||||
// Remote is newer, so the book is NOT in the push set — no config.json PUT.
|
||||
expect(configWrites(captured)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('fullSync re-pushes an in-sync book', async () => {
|
||||
const captured: Captured = { writes: [] };
|
||||
const provider = fakeProvider({
|
||||
readText: async (p) =>
|
||||
p.endsWith('library.json')
|
||||
? JSON.stringify(makeIndex([makeBook('h1', { updatedAt: 100 })]))
|
||||
: null,
|
||||
captured,
|
||||
});
|
||||
const store = fakeStore({ loadConfig: async () => ({ updatedAt: 1, booknotes: [] }) });
|
||||
|
||||
const res = await new FileSyncEngine(provider, store).syncLibrary(
|
||||
[makeBook('h1', { updatedAt: 100 })],
|
||||
{
|
||||
strategy: 'silent',
|
||||
syncBooks: false,
|
||||
deviceId: 'd',
|
||||
fullSync: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.configsUploaded).toBe(1);
|
||||
expect(configWrites(captured)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileSyncEngine.syncLibrary — bounded concurrency', () => {
|
||||
const runWithConcurrency = async (concurrency: number | undefined, bookCount: number) => {
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
const loadConfig = async () => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
inFlight -= 1;
|
||||
return { updatedAt: 1, booknotes: [] };
|
||||
};
|
||||
const books = Array.from({ length: bookCount }, (_, i) =>
|
||||
makeBook(`h${i}`, { updatedAt: 100 }),
|
||||
);
|
||||
// No remote index -> every book is local-only -> all pushed.
|
||||
const provider = fakeProvider({ captured: { writes: [] } });
|
||||
const store = fakeStore({ loadConfig });
|
||||
const res = await new FileSyncEngine(provider, store).syncLibrary(books, {
|
||||
strategy: 'silent',
|
||||
syncBooks: false,
|
||||
deviceId: 'd',
|
||||
concurrency,
|
||||
});
|
||||
return { res, maxInFlight };
|
||||
};
|
||||
|
||||
test('caps in-flight work at the configured concurrency', async () => {
|
||||
const { res, maxInFlight } = await runWithConcurrency(3, 8);
|
||||
expect(res.configsUploaded).toBe(8);
|
||||
expect(res.booksSynced).toBe(8);
|
||||
expect(maxInFlight).toBe(3);
|
||||
});
|
||||
|
||||
test('defaults to 4 when concurrency is omitted', async () => {
|
||||
const { res, maxInFlight } = await runWithConcurrency(undefined, 8);
|
||||
expect(res.configsUploaded).toBe(8);
|
||||
expect(maxInFlight).toBe(4);
|
||||
});
|
||||
|
||||
test('concurrency 1 runs strictly sequentially', async () => {
|
||||
const { res, maxInFlight } = await runWithConcurrency(1, 4);
|
||||
expect(res.configsUploaded).toBe(4);
|
||||
expect(maxInFlight).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildBasePath,
|
||||
buildBookDirPath,
|
||||
buildBookConfigPath,
|
||||
buildLibraryPath,
|
||||
buildBookFilePath,
|
||||
buildBookCoverPath,
|
||||
ancestorsOf,
|
||||
normalizeRoot,
|
||||
} from '@/services/sync/file/layout';
|
||||
import type { Book } from '@/types/book';
|
||||
|
||||
const book = {
|
||||
hash: 'h1',
|
||||
format: 'EPUB',
|
||||
title: 'My Book',
|
||||
sourceTitle: 'My Book',
|
||||
author: 'A',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
} as Book;
|
||||
|
||||
describe('sync layout (frozen)', () => {
|
||||
test('book tree paths under <root>/Readest/books/<hash>', () => {
|
||||
expect(buildBasePath('/')).toBe('/Readest');
|
||||
expect(buildBasePath('/MyDav')).toBe('/MyDav/Readest');
|
||||
expect(buildBookDirPath('/', 'h1')).toBe('/Readest/books/h1');
|
||||
expect(buildBookConfigPath('/', 'h1')).toBe('/Readest/books/h1/config.json');
|
||||
expect(buildBookCoverPath('/', 'h1')).toBe('/Readest/books/h1/cover.png');
|
||||
expect(buildLibraryPath('/')).toBe('/Readest/library.json');
|
||||
});
|
||||
|
||||
test('book file path uses safe title + ext inside the hash dir', () => {
|
||||
expect(buildBookFilePath('/', book)).toBe('/Readest/books/h1/My Book.epub');
|
||||
});
|
||||
|
||||
test('normalizeRoot strips trailing slash, adds leading', () => {
|
||||
expect(normalizeRoot('')).toBe('/');
|
||||
expect(normalizeRoot('books/')).toBe('/books');
|
||||
expect(normalizeRoot('/a/b/')).toBe('/a/b');
|
||||
});
|
||||
|
||||
test('ancestorsOf walks parents top-down excluding the leaf', () => {
|
||||
expect(ancestorsOf('/a/b/c/file.json')).toEqual(['/a', '/a/b', '/a/b/c']);
|
||||
expect(ancestorsOf('/file.json')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
mergeNotes,
|
||||
mergeBookConfig,
|
||||
mergeBookMetadata,
|
||||
isRemoteBookMetadataNewer,
|
||||
} from '@/services/sync/file/merge';
|
||||
import type { Book, BookConfig, BookNote } from '@/types/book';
|
||||
import type { RemoteBookConfig } from '@/services/sync/file/wire';
|
||||
|
||||
const note = (id: string, updatedAt: number, deletedAt?: number): BookNote =>
|
||||
({
|
||||
id,
|
||||
type: 'annotation',
|
||||
cfi: `c-${id}`,
|
||||
note: '',
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
deletedAt,
|
||||
}) as BookNote;
|
||||
|
||||
const envelope = (over: Partial<RemoteBookConfig> = {}): RemoteBookConfig => ({
|
||||
schemaVersion: 1,
|
||||
bookHash: 'h1',
|
||||
config: { updatedAt: 100 },
|
||||
booknotes: [],
|
||||
writerDeviceId: 'd',
|
||||
writerVersion: 'readest-webdav-1',
|
||||
updatedAt: 100,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('mergeNotes (element-set CRDT)', () => {
|
||||
test('union keeps ids from both sides', () => {
|
||||
const out = mergeNotes([note('a', 1)], [note('b', 1)])
|
||||
.map((n) => n.id)
|
||||
.sort();
|
||||
expect(out).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('newer updatedAt wins', () => {
|
||||
const out = mergeNotes([note('a', 1)], [{ ...note('a', 5), note: 'remote' }]);
|
||||
expect(out.find((n) => n.id === 'a')!.note).toBe('remote');
|
||||
});
|
||||
|
||||
test('local-newer keeps local fields', () => {
|
||||
const out = mergeNotes(
|
||||
[{ ...note('a', 9), note: 'local' }],
|
||||
[{ ...note('a', 3), note: 'remote' }],
|
||||
);
|
||||
expect(out.find((n) => n.id === 'a')!.note).toBe('local');
|
||||
});
|
||||
|
||||
test('deletedAt tombstone wins on updatedAt tie', () => {
|
||||
const out = mergeNotes([note('a', 5)], [note('a', 5, 9)]);
|
||||
expect(out.find((n) => n.id === 'a')!.deletedAt).toBe(9);
|
||||
});
|
||||
|
||||
test('idempotent on identical input (id set + field values stable)', () => {
|
||||
const a = [note('a', 1), note('b', 2)];
|
||||
const once = mergeNotes(a, a);
|
||||
expect(once.map((n) => n.id).sort()).toEqual(['a', 'b']);
|
||||
const twice = mergeNotes(once, once);
|
||||
expect(twice.map((n) => n.id).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('commutative on the winning value per id', () => {
|
||||
const l = [note('a', 5)];
|
||||
const r = [{ ...note('a', 9), note: 'remote' }];
|
||||
const lr = mergeNotes(l, r).find((n) => n.id === 'a')!;
|
||||
const rl = mergeNotes(r, l).find((n) => n.id === 'a')!;
|
||||
// Whichever order, the strictly-newer side (updatedAt 9) supplies `note`.
|
||||
expect(lr.note).toBe('remote');
|
||||
expect(rl.note).toBe('remote');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeBookConfig (LWW scalars + CRDT notes)', () => {
|
||||
test('remote newer overrides scalars but unions notes', () => {
|
||||
const local: BookConfig = { updatedAt: 50, progress: [1, 10], booknotes: [note('l', 50)] };
|
||||
const r = envelope({
|
||||
config: { updatedAt: 100, progress: [5, 10] },
|
||||
booknotes: [note('r', 100)],
|
||||
});
|
||||
const { config, notes } = mergeBookConfig(local, r);
|
||||
expect(config.progress).toEqual([5, 10]);
|
||||
expect(notes.map((n) => n.id).sort()).toEqual(['l', 'r']);
|
||||
expect(config.booknotes!.map((n) => n.id).sort()).toEqual(['l', 'r']);
|
||||
});
|
||||
|
||||
test('local newer keeps local scalars, still unions notes', () => {
|
||||
const local: BookConfig = { updatedAt: 200, progress: [9, 10], booknotes: [note('l', 200)] };
|
||||
const r = envelope({
|
||||
config: { updatedAt: 100, progress: [1, 10] },
|
||||
booknotes: [note('r', 100)],
|
||||
});
|
||||
const { config, notes } = mergeBookConfig(local, r);
|
||||
expect(config.progress).toEqual([9, 10]);
|
||||
expect(notes.map((n) => n.id).sort()).toEqual(['l', 'r']);
|
||||
});
|
||||
|
||||
test('null/undefined remote scalars are dropped (never clobber local)', () => {
|
||||
const local: BookConfig = { updatedAt: 50, location: 'keepme', booknotes: [] };
|
||||
const r = envelope({
|
||||
config: { updatedAt: 100, location: undefined, xpointer: undefined },
|
||||
});
|
||||
const { config } = mergeBookConfig(local, r);
|
||||
expect(config.location).toBe('keepme');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeBookMetadata (LWW field subset)', () => {
|
||||
test('overlays only metadata fields, preserves local file/progress fields', () => {
|
||||
const local = {
|
||||
hash: 'h',
|
||||
title: 'L',
|
||||
author: 'L',
|
||||
sourceTitle: 'src',
|
||||
filePath: '/p',
|
||||
progress: [1, 2],
|
||||
updatedAt: 1,
|
||||
} as Book;
|
||||
const remote = { hash: 'h', title: 'R', author: 'R', updatedAt: 9 } as Book;
|
||||
const m = mergeBookMetadata(local, remote);
|
||||
expect(m.title).toBe('R');
|
||||
expect(m.author).toBe('R');
|
||||
expect(m.sourceTitle).toBe('src');
|
||||
expect(m.filePath).toBe('/p');
|
||||
expect(m.progress).toEqual([1, 2]);
|
||||
expect(m.updatedAt).toBe(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRemoteBookMetadataNewer', () => {
|
||||
test('strictly newer remote only', () => {
|
||||
expect(isRemoteBookMetadataNewer({ updatedAt: 1 } as Book, { updatedAt: 2 } as Book)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isRemoteBookMetadataNewer({ updatedAt: 2 } as Book, { updatedAt: 2 } as Book)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isRemoteBookMetadataNewer({ updatedAt: 3 } as Book, { updatedAt: 2 } as Book)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('tombstone on either side disqualifies', () => {
|
||||
expect(
|
||||
isRemoteBookMetadataNewer({ updatedAt: 1 } as Book, { updatedAt: 9, deletedAt: 9 } as Book),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isRemoteBookMetadataNewer({ updatedAt: 1, deletedAt: 1 } as Book, { updatedAt: 9 } as Book),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider';
|
||||
import { FileSyncError } from '@/services/sync/file/provider';
|
||||
import type { FileSyncProvider } from '@/services/sync/file/provider';
|
||||
import type { WebDAVSettings } from '@/types/settings';
|
||||
|
||||
/**
|
||||
* Contract every {@link FileSyncProvider} must satisfy, exercised here against
|
||||
* {@link createWebDAVProvider} over a `fetch` mock. When a second backend
|
||||
* (Drive, Dropbox, …) arrives, lift this into a shared helper and pass a
|
||||
* factory that builds the new provider over whatever transport it stubs.
|
||||
*/
|
||||
const runProviderConformance = (
|
||||
name: string,
|
||||
makeProvider: () => FileSyncProvider,
|
||||
mock: { ok: (status: number, body?: string | null, headers?: Record<string, string>) => void },
|
||||
) => {
|
||||
describe(`${name} — FileSyncProvider conformance`, () => {
|
||||
test('readText resolves null on 404', async () => {
|
||||
mock.ok(404, null);
|
||||
expect(await makeProvider().readText('/Readest/x.json')).toBeNull();
|
||||
});
|
||||
|
||||
test('readText maps 401 to FileSyncError AUTH_FAILED', async () => {
|
||||
mock.ok(401, '');
|
||||
const err = await makeProvider()
|
||||
.readText('/Readest/x.json')
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(FileSyncError);
|
||||
expect(err).toMatchObject({ code: 'AUTH_FAILED', status: 401 });
|
||||
});
|
||||
|
||||
test('readBinary resolves null on 404', async () => {
|
||||
mock.ok(404, null);
|
||||
expect(await makeProvider().readBinary('/Readest/x.bin')).toBeNull();
|
||||
});
|
||||
|
||||
test('head reads content-length, null on 404', async () => {
|
||||
mock.ok(200, null, { 'content-length': '512' });
|
||||
expect(await makeProvider().head('/Readest/x')).toEqual({ size: 512, etag: undefined });
|
||||
mock.ok(404, null);
|
||||
expect(await makeProvider().head('/Readest/x')).toBeNull();
|
||||
});
|
||||
|
||||
test('writeText succeeds on 201 Created', async () => {
|
||||
mock.ok(201, '');
|
||||
await expect(makeProvider().writeText('/Readest/x.json', '{}')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('deleteDir treats 404 as success', async () => {
|
||||
mock.ok(404, null);
|
||||
await expect(makeProvider().deleteDir('/Readest/books/gone')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('list maps 401 to FileSyncError AUTH_FAILED', async () => {
|
||||
mock.ok(401, '');
|
||||
const err = await makeProvider()
|
||||
.list('/Readest/books')
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(FileSyncError);
|
||||
expect(err).toMatchObject({ code: 'AUTH_FAILED', status: 401 });
|
||||
});
|
||||
|
||||
test('list maps 404 to FileSyncError NOT_FOUND', async () => {
|
||||
mock.ok(404, '');
|
||||
const err = await makeProvider()
|
||||
.list('/Readest/books')
|
||||
.catch((e) => e);
|
||||
expect(err).toBeInstanceOf(FileSyncError);
|
||||
expect(err).toMatchObject({ code: 'NOT_FOUND', status: 404 });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const settings: WebDAVSettings = {
|
||||
enabled: true,
|
||||
serverUrl: 'https://dav.example.com',
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
rootPath: '/',
|
||||
};
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
runProviderConformance('WebDAVProvider', () => createWebDAVProvider(settings), {
|
||||
ok: (status, body = '', headers) =>
|
||||
fetchMock.mockResolvedValueOnce(new Response(body, { status, headers })),
|
||||
});
|
||||
|
||||
describe('WebDAVProvider construction', () => {
|
||||
test('rootPath is normalised', () => {
|
||||
expect(createWebDAVProvider({ ...settings, rootPath: '/MyDav/' }).rootPath).toBe('/MyDav');
|
||||
});
|
||||
|
||||
test('streaming methods are absent off-Tauri (web fallback)', () => {
|
||||
const provider = createWebDAVProvider(settings);
|
||||
expect(provider.uploadStream).toBeUndefined();
|
||||
expect(provider.downloadStream).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
buildRemotePayload,
|
||||
parseRemotePayload,
|
||||
parseRemoteLibraryIndex,
|
||||
} from '@/services/sync/file/wire';
|
||||
import type { Book, BookConfig } from '@/types/book';
|
||||
|
||||
const book = {
|
||||
hash: 'h1',
|
||||
metaHash: 'm1',
|
||||
format: 'EPUB',
|
||||
title: 'T',
|
||||
author: 'A',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
} as Book;
|
||||
|
||||
// Cast through unknown so a device-local field (viewSettings) can be present
|
||||
// on the source config without fighting the BookConfig type — the point of
|
||||
// the test is that it never reaches the wire.
|
||||
const config = {
|
||||
updatedAt: 42,
|
||||
progress: [3, 10],
|
||||
location: 'loc',
|
||||
xpointer: 'xp',
|
||||
booknotes: [],
|
||||
viewSettings: { fontSize: 14 },
|
||||
} as unknown as BookConfig;
|
||||
|
||||
describe('wire envelope (frozen)', () => {
|
||||
test('buildRemotePayload trims to reading state + stable header', () => {
|
||||
const p = buildRemotePayload(book, config, 'dev-1');
|
||||
expect(p.schemaVersion).toBe(1);
|
||||
expect(p.writerVersion).toBe('readest-webdav-1');
|
||||
expect(p.writerDeviceId).toBe('dev-1');
|
||||
expect(p.bookHash).toBe('h1');
|
||||
expect(p.metaHash).toBe('m1');
|
||||
expect(p.config).toEqual({ progress: [3, 10], location: 'loc', xpointer: 'xp', updatedAt: 42 });
|
||||
// Device-local fields never travel.
|
||||
expect('viewSettings' in p.config).toBe(false);
|
||||
});
|
||||
|
||||
test('parseRemotePayload rejects null / non-JSON / wrong schema', () => {
|
||||
expect(parseRemotePayload(null)).toBeNull();
|
||||
expect(parseRemotePayload('not json')).toBeNull();
|
||||
expect(parseRemotePayload(JSON.stringify({ schemaVersion: 2 }))).toBeNull();
|
||||
const ok = parseRemotePayload(JSON.stringify(buildRemotePayload(book, config, 'd')));
|
||||
expect(ok?.bookHash).toBe('h1');
|
||||
});
|
||||
|
||||
test('parseRemoteLibraryIndex rejects null / malformed / wrong schema', () => {
|
||||
expect(parseRemoteLibraryIndex(null)).toBeNull();
|
||||
expect(parseRemoteLibraryIndex('{')).toBeNull();
|
||||
expect(parseRemoteLibraryIndex(JSON.stringify({ schemaVersion: 9, books: [] }))).toBeNull();
|
||||
const ok = parseRemoteLibraryIndex(
|
||||
JSON.stringify({ schemaVersion: 1, books: [book], updatedAt: 5 }),
|
||||
);
|
||||
expect(ok?.books).toHaveLength(1);
|
||||
expect(ok?.updatedAt).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { buildWebDAVConnectSettings } from '@/services/webdav/webdavConnectSettings';
|
||||
import { buildWebDAVConnectSettings } from '@/services/sync/providers/webdav/connectSettings';
|
||||
import type { WebDAVSettings } from '@/types/settings';
|
||||
|
||||
describe('buildWebDAVConnectSettings', () => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
deleteDirectory,
|
||||
WebDAVRequestError,
|
||||
type WebDAVConfig,
|
||||
} from '@/services/webdav/WebDAVClient';
|
||||
import { deleteRemoteBookDir } from '@/services/webdav/WebDAVSync';
|
||||
import { deleteDirectory, type WebDAVConfig } from '@/services/sync/providers/webdav/client';
|
||||
import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider';
|
||||
import { deleteRemoteBookDir } from '@/services/sync/file/engine';
|
||||
import { FileSyncError } from '@/services/sync/file/provider';
|
||||
import type { WebDAVSettings } from '@/types/settings';
|
||||
|
||||
/**
|
||||
@@ -127,15 +125,16 @@ describe('deleteDirectory', () => {
|
||||
|
||||
describe('deleteRemoteBookDir', () => {
|
||||
const HASH = 'abc123';
|
||||
const provider = createWebDAVProvider(settings);
|
||||
|
||||
test('successful DELETE returns ok=true', async () => {
|
||||
fetchMock.mockResolvedValueOnce(buildResponse(204));
|
||||
await expect(deleteRemoteBookDir(settings, HASH)).resolves.toEqual({ ok: true });
|
||||
await expect(deleteRemoteBookDir(provider, HASH)).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
test('non-auth failure (500) returns ok=false with reason populated', async () => {
|
||||
fetchMock.mockResolvedValueOnce(buildResponse(500));
|
||||
const result = await deleteRemoteBookDir(settings, HASH);
|
||||
const result = await deleteRemoteBookDir(provider, HASH);
|
||||
expect(result.ok).toBe(false);
|
||||
// The reason is the underlying error message; we don't pin its
|
||||
// exact wording (could be tweaked) but it must be a non-empty
|
||||
@@ -146,24 +145,23 @@ describe('deleteRemoteBookDir', () => {
|
||||
|
||||
test('AUTH_FAILED is rethrown so callers can short-circuit batches', async () => {
|
||||
fetchMock.mockResolvedValueOnce(buildResponse(401));
|
||||
await expect(deleteRemoteBookDir(settings, HASH)).rejects.toBeInstanceOf(WebDAVRequestError);
|
||||
await expect(deleteRemoteBookDir(provider, HASH)).rejects.toBeInstanceOf(FileSyncError);
|
||||
// 403 walks the same code path; assert the AUTH_FAILED tag
|
||||
// independently so a regression that lets one status leak
|
||||
// through but not the other still trips a test.
|
||||
fetchMock.mockResolvedValueOnce(buildResponse(403));
|
||||
await expect(deleteRemoteBookDir(settings, HASH)).rejects.toMatchObject({
|
||||
await expect(deleteRemoteBookDir(provider, HASH)).rejects.toMatchObject({
|
||||
code: 'AUTH_FAILED',
|
||||
});
|
||||
});
|
||||
|
||||
test('targets the correct per-hash directory under <rootPath>/Readest/books', async () => {
|
||||
// The remote layout is documented in WebDAVPaths.ts; this test
|
||||
// pins the contract so neither side can drift. A custom
|
||||
// rootPath is used to make sure buildBookDirPath honours it
|
||||
// (a literal '/' would mask a "join always uses leading-slash
|
||||
// base" regression).
|
||||
// The remote layout is documented in sync/file/layout.ts; this test
|
||||
// pins the contract so neither side can drift. A custom rootPath is
|
||||
// used to make sure buildBookDirPath honours it (a literal '/' would
|
||||
// mask a "join always uses leading-slash base" regression).
|
||||
fetchMock.mockResolvedValueOnce(buildResponse(204));
|
||||
await deleteRemoteBookDir({ ...settings, rootPath: '/MyDav' }, HASH);
|
||||
await deleteRemoteBookDir(createWebDAVProvider({ ...settings, rootPath: '/MyDav' }), HASH);
|
||||
const [url] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe('https://dav.example.com/MyDav/Readest/books/abc123');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { buildRequestUrl } from '@/services/webdav/WebDAVClient';
|
||||
import { buildRequestUrl } from '@/services/sync/providers/webdav/client';
|
||||
|
||||
describe('buildRequestUrl (encodePath)', () => {
|
||||
test('escapes spaces and unicode in each segment', () => {
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { WebDAVSettings } from '@/types/settings';
|
||||
import type { Book, BookConfig, BookNote } from '@/types/book';
|
||||
|
||||
/**
|
||||
* Regression tests for issue #4756: once a device already has a book in its
|
||||
* local library, the manual "Sync now" path never pulled the book's metadata
|
||||
* (title / author / cover) back from the shared `library.json`, and the final
|
||||
* index re-push clobbered the remote with the device's stale copy.
|
||||
*
|
||||
* These tests mock the WebDAVClient I/O primitives so we can drive
|
||||
* `syncLibrary` deterministically without a real server, and assert the
|
||||
* last-writer-wins reconciliation on `book.updatedAt`.
|
||||
*/
|
||||
|
||||
vi.mock('@/services/webdav/WebDAVClient', async (importActual) => {
|
||||
const actual = await importActual<typeof import('@/services/webdav/WebDAVClient')>();
|
||||
return {
|
||||
...actual,
|
||||
getFile: vi.fn(),
|
||||
getFileBinary: vi.fn(),
|
||||
headFile: vi.fn(),
|
||||
listDirectory: vi.fn(),
|
||||
putFile: vi.fn(),
|
||||
putFileBinary: vi.fn(),
|
||||
ensureDirectory: vi.fn(),
|
||||
deleteDirectory: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
getFile,
|
||||
getFileBinary,
|
||||
headFile,
|
||||
listDirectory,
|
||||
putFile,
|
||||
putFileBinary,
|
||||
ensureDirectory,
|
||||
} from '@/services/webdav/WebDAVClient';
|
||||
import {
|
||||
syncLibrary,
|
||||
type RemoteLibraryIndex,
|
||||
type RemoteBookConfig,
|
||||
} from '@/services/webdav/WebDAVSync';
|
||||
|
||||
const settings: WebDAVSettings = {
|
||||
enabled: true,
|
||||
serverUrl: 'https://dav.example.com',
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
rootPath: '/',
|
||||
};
|
||||
|
||||
const makeLocalBook = (overrides: Partial<Book> = {}): Book => ({
|
||||
hash: 'h1',
|
||||
format: 'EPUB',
|
||||
title: 'Old Title',
|
||||
sourceTitle: 'Old Title',
|
||||
author: 'Old Author',
|
||||
createdAt: 1,
|
||||
updatedAt: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeRemoteIndex = (book: Book, updatedAt = book.updatedAt): RemoteLibraryIndex => ({
|
||||
schemaVersion: 1,
|
||||
updatedAt,
|
||||
books: [book],
|
||||
});
|
||||
|
||||
/**
|
||||
* Route `getFile` by path: library.json → index JSON, config.json → the
|
||||
* supplied remote envelope (or null when none).
|
||||
*/
|
||||
const wireGetFile = (
|
||||
index: RemoteLibraryIndex | null,
|
||||
remoteConfig: RemoteBookConfig | null = null,
|
||||
) => {
|
||||
(getFile as ReturnType<typeof vi.fn>).mockImplementation(async (_client, path: string) => {
|
||||
if (path.endsWith('library.json')) return index ? JSON.stringify(index) : null;
|
||||
if (path.endsWith('config.json')) return remoteConfig ? JSON.stringify(remoteConfig) : null;
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
/** Capture the library index that was re-pushed at the end of the sync. */
|
||||
const capturePushedIndex = (): { value: RemoteLibraryIndex | null } => {
|
||||
const captured: { value: RemoteLibraryIndex | null } = { value: null };
|
||||
(putFile as ReturnType<typeof vi.fn>).mockImplementation(async (_client, path: string, body) => {
|
||||
if (path.endsWith('library.json')) captured.value = JSON.parse(body as string);
|
||||
});
|
||||
return captured;
|
||||
};
|
||||
|
||||
/** Capture the per-book config envelope pushed to <hash>/config.json. */
|
||||
const capturePushedConfig = (): { value: RemoteBookConfig | null } => {
|
||||
const captured: { value: RemoteBookConfig | null } = { value: null };
|
||||
(putFile as ReturnType<typeof vi.fn>).mockImplementation(async (_client, path: string, body) => {
|
||||
if (path.endsWith('config.json')) captured.value = JSON.parse(body as string);
|
||||
});
|
||||
return captured;
|
||||
};
|
||||
|
||||
const makeNote = (id: string, updatedAt: number): BookNote => ({
|
||||
id,
|
||||
type: 'annotation',
|
||||
cfi: `cfi-${id}`,
|
||||
note: '',
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
const makeRemoteConfig = (overrides: Partial<RemoteBookConfig> = {}): RemoteBookConfig => ({
|
||||
schemaVersion: 1,
|
||||
bookHash: 'h1',
|
||||
config: { updatedAt: 100 },
|
||||
booknotes: [],
|
||||
writerDeviceId: 'mobile',
|
||||
writerVersion: 'readest-webdav-1',
|
||||
updatedAt: 100,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(listDirectory as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
(getFileBinary as ReturnType<typeof vi.fn>).mockResolvedValue(new ArrayBuffer(8));
|
||||
(headFile as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||
(putFile as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
(putFileBinary as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
(ensureDirectory as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const baseOptions = () => ({
|
||||
strategy: 'silent' as const,
|
||||
syncBooks: false,
|
||||
deviceId: 'pc-device',
|
||||
loadConfig: async (): Promise<BookConfig> => ({ updatedAt: 50, booknotes: [] }),
|
||||
loadBookFile: async () => null,
|
||||
loadBookCover: async () => null,
|
||||
onProgress: () => {},
|
||||
});
|
||||
|
||||
describe('syncLibrary metadata reconciliation (#4756)', () => {
|
||||
test('pulls newer remote metadata for a book the device already has', async () => {
|
||||
const local = makeLocalBook({ updatedAt: 100 });
|
||||
const remote = makeLocalBook({
|
||||
title: 'New Title',
|
||||
author: 'New Author',
|
||||
updatedAt: 200,
|
||||
});
|
||||
wireGetFile(makeRemoteIndex(remote, 200));
|
||||
const pushedIndex = capturePushedIndex();
|
||||
|
||||
const updateBookMetadata = vi.fn(async (_book: Book) => {});
|
||||
const saveBookCover = vi.fn(async (_book: Book, _bytes: ArrayBuffer) => {});
|
||||
|
||||
const result = await syncLibrary(settings, [local], {
|
||||
...baseOptions(),
|
||||
updateBookMetadata,
|
||||
saveBookCover,
|
||||
});
|
||||
|
||||
// The device must learn the remote title/author.
|
||||
expect(updateBookMetadata).toHaveBeenCalledTimes(1);
|
||||
const merged = updateBookMetadata.mock.calls[0]![0];
|
||||
expect(merged.title).toBe('New Title');
|
||||
expect(merged.author).toBe('New Author');
|
||||
expect(result.metadataUpdated).toBe(1);
|
||||
|
||||
// The cover must be re-pulled so a changed cover propagates.
|
||||
expect(saveBookCover).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The re-pushed index must carry the newer metadata, not clobber it.
|
||||
expect(pushedIndex.value).not.toBeNull();
|
||||
const indexedBook = pushedIndex.value!.books.find((b) => b.hash === 'h1')!;
|
||||
expect(indexedBook.title).toBe('New Title');
|
||||
});
|
||||
|
||||
test('does not overwrite local metadata when the local copy is newer', async () => {
|
||||
const local = makeLocalBook({ title: 'Local Newer', updatedAt: 300 });
|
||||
const remote = makeLocalBook({ title: 'Remote Older', updatedAt: 200 });
|
||||
wireGetFile(makeRemoteIndex(remote, 200));
|
||||
const pushedIndex = capturePushedIndex();
|
||||
|
||||
const updateBookMetadata = vi.fn(async (_book: Book) => {});
|
||||
|
||||
const result = await syncLibrary(settings, [local], {
|
||||
...baseOptions(),
|
||||
updateBookMetadata,
|
||||
});
|
||||
|
||||
expect(updateBookMetadata).not.toHaveBeenCalled();
|
||||
expect(result.metadataUpdated).toBe(0);
|
||||
|
||||
// Local wins: the re-pushed index keeps the local title.
|
||||
const indexedBook = pushedIndex.value!.books.find((b) => b.hash === 'h1')!;
|
||||
expect(indexedBook.title).toBe('Local Newer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncLibrary config merge before push (Sync now must not blind-overwrite)', () => {
|
||||
test('unions remote booknotes into the pushed config instead of clobbering them', async () => {
|
||||
const local = makeLocalBook({ updatedAt: 100 });
|
||||
// Same updatedAt in the index so the metadata pass is a no-op — this
|
||||
// isolates the config/notes path.
|
||||
wireGetFile(
|
||||
makeRemoteIndex(makeLocalBook({ updatedAt: 100 }), 100),
|
||||
makeRemoteConfig({ config: { updatedAt: 100 }, booknotes: [makeNote('remote-note', 100)] }),
|
||||
);
|
||||
const pushedConfig = capturePushedConfig();
|
||||
|
||||
await syncLibrary(settings, [local], {
|
||||
...baseOptions(),
|
||||
loadConfig: async (): Promise<BookConfig> => ({
|
||||
updatedAt: 50,
|
||||
booknotes: [makeNote('local-note', 50)],
|
||||
}),
|
||||
});
|
||||
|
||||
// The pushed envelope must carry BOTH notes — a blind push would have
|
||||
// dropped the peer's note that this device never pulled.
|
||||
expect(pushedConfig.value).not.toBeNull();
|
||||
const ids = pushedConfig.value!.booknotes.map((n) => n.id).sort();
|
||||
expect(ids).toEqual(['local-note', 'remote-note']);
|
||||
});
|
||||
|
||||
test('does not regress newer remote progress with an older local push', async () => {
|
||||
const local = makeLocalBook({ updatedAt: 100 });
|
||||
wireGetFile(
|
||||
makeRemoteIndex(makeLocalBook({ updatedAt: 100 }), 100),
|
||||
makeRemoteConfig({ config: { updatedAt: 200, progress: [50, 100] }, booknotes: [] }),
|
||||
);
|
||||
const pushedConfig = capturePushedConfig();
|
||||
|
||||
await syncLibrary(settings, [local], {
|
||||
...baseOptions(),
|
||||
loadConfig: async (): Promise<BookConfig> => ({
|
||||
updatedAt: 50,
|
||||
progress: [10, 100],
|
||||
booknotes: [],
|
||||
}),
|
||||
});
|
||||
|
||||
// Remote progress is newer (updatedAt 200 > 50): the LWW merge must push
|
||||
// the remote's page, never regress it back to the local page.
|
||||
expect(pushedConfig.value!.config.progress).toEqual([50, 100]);
|
||||
});
|
||||
|
||||
test('send strategy keeps the blind push (local authoritative, no pull-merge)', async () => {
|
||||
const local = makeLocalBook({ updatedAt: 100 });
|
||||
wireGetFile(
|
||||
null,
|
||||
makeRemoteConfig({ config: { updatedAt: 200 }, booknotes: [makeNote('remote-note', 200)] }),
|
||||
);
|
||||
const pushedConfig = capturePushedConfig();
|
||||
|
||||
await syncLibrary(settings, [local], {
|
||||
...baseOptions(),
|
||||
strategy: 'send',
|
||||
loadConfig: async (): Promise<BookConfig> => ({
|
||||
updatedAt: 50,
|
||||
booknotes: [makeNote('local-note', 50)],
|
||||
}),
|
||||
});
|
||||
|
||||
// 'send' deliberately treats local as the source of truth — it must not
|
||||
// pull the remote note in.
|
||||
const ids = pushedConfig.value!.booknotes.map((n) => n.id);
|
||||
expect(ids).toEqual(['local-note']);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ const RSVP_CONTROLS = firstZ(
|
||||
read('src/app/reader/components/rsvp/RSVPStartDialog.tsx'),
|
||||
/z-\[(\d+)\]/,
|
||||
);
|
||||
const TOAST = firstZ(read('src/components/Toast.tsx'), /toast z-\[(\d+)\]/);
|
||||
const APP_LOCK = firstZ(read('src/components/AppLockScreen.tsx'), /z-\[(\d+)\]/);
|
||||
|
||||
describe('overlay z-index scale', () => {
|
||||
@@ -62,12 +63,21 @@ describe('overlay z-index scale', () => {
|
||||
expect(RSVP_OVERLAY).toBeGreaterThan(PAGE_FRAME);
|
||||
});
|
||||
|
||||
it('keeps the security lock screen on top of every modal', () => {
|
||||
it('raises toasts above every modal so they show over open dialogs', () => {
|
||||
// Regression: a sync-complete toast dispatched from the open Settings
|
||||
// dialog was buried because the toast sat at z-50, below Settings (110)
|
||||
// and ModalPortal (120).
|
||||
expect(TOAST).toBeGreaterThan(MODAL);
|
||||
expect(TOAST).toBeGreaterThan(SETTINGS);
|
||||
});
|
||||
|
||||
it('keeps the security lock screen on top of every modal and toast', () => {
|
||||
expect(APP_LOCK).toBeGreaterThan(MODAL);
|
||||
expect(APP_LOCK).toBeGreaterThan(TOAST);
|
||||
});
|
||||
|
||||
it('uses a compact scale with no four-digit z-index', () => {
|
||||
for (const value of [RSVP_OVERLAY, RSVP_CONTROLS, SETTINGS, MODAL, APP_LOCK]) {
|
||||
for (const value of [RSVP_OVERLAY, RSVP_CONTROLS, SETTINGS, MODAL, TOAST, APP_LOCK]) {
|
||||
expect(value).toBeLessThan(1000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,16 +8,10 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { debounce } from '@/utils/debounce';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import {
|
||||
pullBookConfig,
|
||||
pushBookConfig,
|
||||
pushBookCover,
|
||||
pushBookFile,
|
||||
} from '@/services/webdav/WebDAVSync';
|
||||
import { WebDAVRequestError } from '@/services/webdav/WebDAVClient';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { tauriUpload } from '@/utils/transfer';
|
||||
import { getCoverFilename, getLocalBookFilename } from '@/utils/book';
|
||||
import { FileSyncEngine } from '@/services/sync/file/engine';
|
||||
import { FileSyncError } from '@/services/sync/file/provider';
|
||||
import { createAppLocalStore } from '@/services/sync/file/appLocalStore';
|
||||
import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider';
|
||||
import { removeBookNoteOverlays } from '../utils/annotatorUtil';
|
||||
import { useWindowActiveChanged } from './useWindowActiveChanged';
|
||||
|
||||
@@ -162,6 +156,17 @@ export const useWebDAVSync = (bookKey: string) => {
|
||||
const allowPush = isReady && strategy !== 'receive';
|
||||
const allowPull = isReady && strategy !== 'send';
|
||||
|
||||
// One engine per (credentials, appService) — the provider owns the WebDAV
|
||||
// URL + auth and the streaming transport; the shared local-store bridge owns
|
||||
// the on-disk book/cover I/O. Rebuilt when settings change (cheap closures).
|
||||
const engine = useMemo(() => {
|
||||
const w = settings.webdav;
|
||||
if (!w?.enabled || !w?.serverUrl || !w?.username || !appService) return null;
|
||||
const provider = createWebDAVProvider(w);
|
||||
const store = createAppLocalStore({ appService, settings, envConfig });
|
||||
return new FileSyncEngine(provider, store);
|
||||
}, [settings, appService, envConfig]);
|
||||
|
||||
/**
|
||||
* Push the latest config (progress + booknotes) to the remote.
|
||||
* Skips while the user is previewing a deep-link target — the in-memory
|
||||
@@ -178,18 +183,18 @@ export const useWebDAVSync = (bookKey: string) => {
|
||||
|
||||
const config = getConfig(bookKey);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!config || !book) return;
|
||||
if (!config || !book || !engine) return;
|
||||
|
||||
try {
|
||||
const deviceId = ensureDeviceId();
|
||||
// We always push the full envelope; sub-toggles only gate _which_
|
||||
// fields the local writer applies on pull. This keeps the wire
|
||||
// schema stable across users with different toggle combinations.
|
||||
await pushBookConfig(settings.webdav!, book, config, deviceId);
|
||||
await engine.pushBookConfig(book, config, deviceId);
|
||||
dirtyRef.current = false;
|
||||
await updateLastSyncedAt(Date.now());
|
||||
} catch (e) {
|
||||
if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') {
|
||||
if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('WebDAV authentication failed. Reconnect in Settings.'),
|
||||
@@ -204,6 +209,7 @@ export const useWebDAVSync = (bookKey: string) => {
|
||||
getConfig,
|
||||
getBookData,
|
||||
ensureDeviceId,
|
||||
engine,
|
||||
settings.webdav,
|
||||
updateLastSyncedAt,
|
||||
_,
|
||||
@@ -224,68 +230,13 @@ export const useWebDAVSync = (bookKey: string) => {
|
||||
fileSyncedRef.current = true;
|
||||
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!book || !appService) return;
|
||||
if (!book || !engine) return;
|
||||
|
||||
try {
|
||||
const result = await pushBookFile(
|
||||
settings.webdav!,
|
||||
book,
|
||||
async () => {
|
||||
// Buffered fallback: read the local book file off disk lazily
|
||||
// so we only do the expensive ArrayBuffer materialisation when
|
||||
// the HEAD probe says we actually need to upload. Used on web
|
||||
// targets where streaming PUTs aren't available.
|
||||
// In-place imports keep their bytes outside Books/<hash>/, so
|
||||
// resolve to (book.filePath, 'None') when the field is set —
|
||||
// mirrors the same fallback in cloudService.uploadBook so
|
||||
// syncBooks treats in-place books as first-class.
|
||||
const fp = book.filePath ?? getLocalBookFilename(book);
|
||||
const base = book.filePath ? 'None' : 'Books';
|
||||
if (!(await appService.exists(fp, base))) return null;
|
||||
const file = await appService.openFile(fp, base);
|
||||
const bytes = await file.arrayBuffer();
|
||||
return { bytes, size: bytes.byteLength };
|
||||
},
|
||||
// Tauri-only: stream the book file straight from disk to the
|
||||
// server via Rust-side `upload_file`, never letting the bytes
|
||||
// land in the JS heap. Without this, opening a multi-hundred-
|
||||
// megabyte PDF / scanned book would buffer the whole file into
|
||||
// V8 just to PUT it, blowing the renderer's heap and freezing
|
||||
// the reader to a blank screen mid-open. Same flow as the
|
||||
// library Sync now path in WebDAVForm.
|
||||
isTauriAppPlatform()
|
||||
? async () => {
|
||||
const fp = book.filePath ?? getLocalBookFilename(book);
|
||||
const base = book.filePath ? 'None' : 'Books';
|
||||
if (!(await appService.exists(fp, base))) return null;
|
||||
const file = await appService.openFile(fp, base);
|
||||
const size = file.size;
|
||||
// Release the FD before streaming so the Tauri side can
|
||||
// re-open the path for the PUT without contending.
|
||||
const closable = file as { close?: () => Promise<void> };
|
||||
if (closable.close) await closable.close();
|
||||
const dst = await appService.resolveFilePath(fp, base);
|
||||
return {
|
||||
size,
|
||||
upload: async (remoteUrl, headers) => {
|
||||
try {
|
||||
await tauriUpload(
|
||||
remoteUrl,
|
||||
dst,
|
||||
'PUT',
|
||||
undefined,
|
||||
headers as unknown as Map<string, string>,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('WD per-book push: tauriUpload failed', book.hash, e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
// The engine prefers the provider's streaming upload (Tauri) and falls
|
||||
// back to a buffered PUT on web — both resolve the local bytes/path via
|
||||
// the shared local store, so this hook no longer owns that plumbing.
|
||||
const result = await engine.pushBookFile(book);
|
||||
if (result.uploaded) {
|
||||
await updateLastSyncedAt(Date.now());
|
||||
}
|
||||
@@ -294,7 +245,7 @@ export const useWebDAVSync = (bookKey: string) => {
|
||||
// open retries — otherwise a transient hiccup would mark this
|
||||
// book "synced" for the rest of the session.
|
||||
fileSyncedRef.current = false;
|
||||
if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') {
|
||||
if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('WebDAV authentication failed. Reconnect in Settings.'),
|
||||
@@ -303,7 +254,7 @@ export const useWebDAVSync = (bookKey: string) => {
|
||||
console.warn('WD book file push failed', e);
|
||||
}
|
||||
}
|
||||
}, [allowPush, settings.webdav, getBookData, bookKey, appService, updateLastSyncedAt, _]);
|
||||
}, [allowPush, settings.webdav, getBookData, bookKey, engine, updateLastSyncedAt, _]);
|
||||
|
||||
/**
|
||||
* Push the local cover image to the remote, independent of
|
||||
@@ -324,21 +275,15 @@ export const useWebDAVSync = (bookKey: string) => {
|
||||
coverSyncedRef.current = true;
|
||||
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!book || !appService) return;
|
||||
if (!book || !engine) return;
|
||||
|
||||
try {
|
||||
await pushBookCover(settings.webdav!, book.hash, async () => {
|
||||
const fp = getCoverFilename(book);
|
||||
if (!(await appService.exists(fp, 'Books'))) return null;
|
||||
const file = await appService.openFile(fp, 'Books');
|
||||
const bytes = await file.arrayBuffer();
|
||||
return { bytes, size: bytes.byteLength };
|
||||
});
|
||||
await engine.pushBookCover(book);
|
||||
} catch (e) {
|
||||
// Reset the lock so a manual "Sync now" or a subsequent open
|
||||
// can retry, mirroring `pushBookFileNow`'s recovery model.
|
||||
coverSyncedRef.current = false;
|
||||
if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') {
|
||||
if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('WebDAV authentication failed. Reconnect in Settings.'),
|
||||
@@ -347,7 +292,7 @@ export const useWebDAVSync = (bookKey: string) => {
|
||||
console.warn('WD book cover push failed', e);
|
||||
}
|
||||
}
|
||||
}, [allowPush, settings.webdav, getBookData, bookKey, appService, _]);
|
||||
}, [allowPush, getBookData, bookKey, engine, _]);
|
||||
|
||||
/**
|
||||
* Pull, merge, and persist. Uses the same per-config / per-note merge
|
||||
@@ -367,10 +312,10 @@ export const useWebDAVSync = (bookKey: string) => {
|
||||
|
||||
const config = getConfig(bookKey);
|
||||
const book = getBookData(bookKey)?.book;
|
||||
if (!config || !book) return false;
|
||||
if (!config || !book || !engine) return false;
|
||||
|
||||
try {
|
||||
const result = await pullBookConfig(settings.webdav!, book, config);
|
||||
const result = await engine.pullBookConfig(book, config);
|
||||
lastPulledAtRef.current = Date.now();
|
||||
if (!result.applied || !result.mergedConfig) return false;
|
||||
|
||||
@@ -416,7 +361,7 @@ export const useWebDAVSync = (bookKey: string) => {
|
||||
await updateLastSyncedAt(Date.now());
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') {
|
||||
if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('WebDAV authentication failed. Reconnect in Settings.'),
|
||||
@@ -435,6 +380,7 @@ export const useWebDAVSync = (bookKey: string) => {
|
||||
getViewsById,
|
||||
setConfig,
|
||||
saveConfig,
|
||||
engine,
|
||||
envConfig,
|
||||
settings,
|
||||
updateLastSyncedAt,
|
||||
|
||||
@@ -118,7 +118,7 @@ export const Toast = () => {
|
||||
toastMessage && (
|
||||
<div
|
||||
className={clsx(
|
||||
'toast z-50 w-auto max-w-screen-sm transition-all duration-300',
|
||||
'toast z-[130] w-auto max-w-screen-sm transition-all duration-300',
|
||||
toastClassMap[toastType],
|
||||
isVisible ? 'scale-100 opacity-100' : 'scale-95 opacity-0',
|
||||
)}
|
||||
|
||||
@@ -25,10 +25,11 @@ import {
|
||||
listDirectory,
|
||||
normalizeRootPath,
|
||||
WebDAVEntry,
|
||||
WebDAVRequestError,
|
||||
} from '@/services/webdav/WebDAVClient';
|
||||
import { buildBasePath, WEBDAV_BOOKS_DIR } from '@/services/webdav/WebDAVPaths';
|
||||
import { deleteRemoteBookDir } from '@/services/webdav/WebDAVSync';
|
||||
} from '@/services/sync/providers/webdav/client';
|
||||
import { buildBasePath, SYNC_BOOKS_DIR } from '@/services/sync/file/layout';
|
||||
import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider';
|
||||
import { deleteRemoteBookDir } from '@/services/sync/file/engine';
|
||||
import { FileSyncError } from '@/services/sync/file/provider';
|
||||
import { WebDAVSettings } from '@/types/settings';
|
||||
import { Book } from '@/types/book';
|
||||
import { SettingLabel } from '../primitives';
|
||||
@@ -61,6 +62,10 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
// limit. Memoise so we don't recompute on every keystroke.
|
||||
const savedRoot = useMemo(() => normalizeRootPath(settings.rootPath || '/'), [settings.rootPath]);
|
||||
|
||||
// Provider for the engine-level cleanup helper (orphan GC). The browse
|
||||
// pane is WebDAV-specific UI, so it builds the WebDAV provider directly.
|
||||
const provider = useMemo(() => createWebDAVProvider(settings), [settings]);
|
||||
|
||||
// Absolute path of the per-book hash directories. When the user has
|
||||
// drilled into this exact path, each row's `entry.name` is a content
|
||||
// hash and we can swap in the human-readable book title from the
|
||||
@@ -69,7 +74,7 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
// can be string-compared against `currentPath` without further
|
||||
// normalisation.
|
||||
const booksDirPath = useMemo(
|
||||
() => `${buildBasePath(settings.rootPath || '/')}/${WEBDAV_BOOKS_DIR}`,
|
||||
() => `${buildBasePath(settings.rootPath || '/')}/${SYNC_BOOKS_DIR}`,
|
||||
[settings.rootPath],
|
||||
);
|
||||
|
||||
@@ -197,7 +202,7 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const t0 = targets[i]!;
|
||||
try {
|
||||
const res = await deleteRemoteBookDir(settings, t0.hash);
|
||||
const res = await deleteRemoteBookDir(provider, t0.hash);
|
||||
if (res.ok) {
|
||||
succeeded++;
|
||||
// Splice on success so the listing itself is the progress
|
||||
@@ -217,7 +222,7 @@ const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({ settings }) => {
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') {
|
||||
if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') {
|
||||
// Every remaining target would fail identically; stop
|
||||
// and surface a single re-auth toast.
|
||||
authFailed = true;
|
||||
|
||||
@@ -7,21 +7,18 @@ import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useWebDAVSyncStore } from '@/store/webdavSyncStore';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { tauriDownload, tauriUpload } from '@/utils/transfer';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import {
|
||||
buildBasicAuthHeader,
|
||||
buildRequestUrl,
|
||||
checkConnection,
|
||||
normalizeRootPath,
|
||||
WebDAVConnectResult,
|
||||
WebDAVRequestError,
|
||||
} from '@/services/webdav/WebDAVClient';
|
||||
} from '@/services/sync/providers/webdav/client';
|
||||
import { type TranslationFunc } from '@/hooks/useTranslation';
|
||||
import { syncLibrary } from '@/services/webdav/WebDAVSync';
|
||||
import { buildWebDAVConnectSettings } from '@/services/webdav/webdavConnectSettings';
|
||||
import { getCoverFilename, getLocalBookFilename } from '@/utils/book';
|
||||
import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider';
|
||||
import { buildWebDAVConnectSettings } from '@/services/sync/providers/webdav/connectSettings';
|
||||
import { FileSyncEngine } from '@/services/sync/file/engine';
|
||||
import { FileSyncError } from '@/services/sync/file/provider';
|
||||
import { createAppLocalStore } from '@/services/sync/file/appLocalStore';
|
||||
import SubPageHeader from '../SubPageHeader';
|
||||
import {
|
||||
BoxedList,
|
||||
@@ -68,7 +65,7 @@ const formatConnectError = (_: TranslationFunc, result: WebDAVConnectResult): st
|
||||
* showing the raw English `e.message` to the user.
|
||||
*/
|
||||
const formatSyncError = (_: TranslationFunc, e: unknown): string => {
|
||||
if (e instanceof WebDAVRequestError) {
|
||||
if (e instanceof FileSyncError) {
|
||||
switch (e.code) {
|
||||
case 'AUTH_FAILED':
|
||||
return _('WebDAV authentication failed. Reconnect in Settings.');
|
||||
@@ -209,21 +206,23 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
// enabled — anyone bothering to set up cloud sync wants those. Only
|
||||
// book files stay opt-in because they're bandwidth/storage heavy.
|
||||
const handleToggleSyncBooks = () => persistWebdav({ syncBooks: !(stored?.syncBooks ?? false) });
|
||||
const handleToggleFullSync = () => persistWebdav({ fullSync: !(stored?.fullSync ?? false) });
|
||||
const handleStrategyChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
await persistWebdav({ strategy: e.target.value as typeof stored.strategy });
|
||||
};
|
||||
|
||||
/**
|
||||
* Manual "Sync now" — push every book in the local library up to the
|
||||
* remote in a single sequential pass. We don't pull here; the per-
|
||||
* book Reader hook handles incoming changes when the user opens a
|
||||
* book.
|
||||
* Manual "Sync now" — reconcile the local library with the remote over a
|
||||
* bounded-concurrency pool. By default this is incremental: only books whose
|
||||
* local copy differs from the shared library.json index are processed
|
||||
* (`book.updatedAt` is the per-book change marker). The "Full Sync" toggle
|
||||
* re-checks every book. The engine pulls peers' changes and pushes ours; the
|
||||
* per-book Reader hook still handles live changes as the user reads.
|
||||
*
|
||||
* Why sequential: shared WebDAV servers (NextCloud, Synology, …) are
|
||||
* not happy with parallel PUTs from one user, and a steady linear
|
||||
* walk gives us a usable progress indicator. The whole thing runs
|
||||
* off-thread relative to the UI by virtue of being async — we just
|
||||
* surface a status string and disable the button.
|
||||
* Concurrency is capped (engine default 4) so shared WebDAV servers
|
||||
* (NextCloud, Synology, …) aren't hammered while still hiding per-request
|
||||
* latency. The run is async relative to the UI — we surface a status string
|
||||
* and disable the button.
|
||||
*/
|
||||
const handleSyncNow = async () => {
|
||||
// Re-entrancy gate must read the live store, not the closure: a
|
||||
@@ -239,6 +238,12 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
let currentLibrary = library ?? [];
|
||||
if (!libraryLoaded && appService) {
|
||||
currentLibrary = await appService.loadLibraryBooks();
|
||||
// Hydrate the store before syncing. The engine's addBookToLibrary /
|
||||
// updateBookMetadata merge against the in-memory library; if it were
|
||||
// still empty here, a downloaded book or a metadata update would persist
|
||||
// as the *entire* library and clobber what's on disk. setLibrary also
|
||||
// flips libraryLoaded so the per-book store calls see a loaded store.
|
||||
useLibraryStore.getState().setLibrary(currentLibrary);
|
||||
}
|
||||
|
||||
const eligibleBooks = currentLibrary.filter((b) => !b.deletedAt);
|
||||
@@ -257,173 +262,18 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
beginSync(_('Syncing {{n}} / {{total}}', { n: 0, total: eligibleBooks.length }));
|
||||
|
||||
try {
|
||||
const result = await syncLibrary(stored, eligibleBooks, {
|
||||
// The provider owns the WebDAV URL + auth + streaming transport; the
|
||||
// shared local-store bridge owns all on-disk book/cover/config I/O
|
||||
// (including the in-place vs hash-copy path resolution and the Tauri
|
||||
// streaming fast path). This form no longer knows any WebDAV specifics.
|
||||
const provider = createWebDAVProvider(stored);
|
||||
const store = createAppLocalStore({ appService, settings, envConfig });
|
||||
const engine = new FileSyncEngine(provider, store);
|
||||
const result = await engine.syncLibrary(eligibleBooks, {
|
||||
strategy: stored.strategy === 'prompt' ? 'silent' : stored.strategy,
|
||||
syncBooks: stored.syncBooks ?? false,
|
||||
fullSync: stored.fullSync ?? false,
|
||||
deviceId: deviceId as string,
|
||||
loadConfig: (book) =>
|
||||
appService ? appService.loadBookConfig(book, settings) : Promise.resolve(null),
|
||||
loadBookFile: async (book) => {
|
||||
if (!appService) return null;
|
||||
// In-place imports live outside Books/<hash>/; resolve to
|
||||
// (book.filePath, 'None') when set. Hash-copy books fall
|
||||
// through to the original Books-relative path. Same fallback
|
||||
// pattern as cloudService.uploadBook so library Sync now
|
||||
// treats in-place books as first-class.
|
||||
const fp = book.filePath ?? getLocalBookFilename(book);
|
||||
const base = book.filePath ? 'None' : 'Books';
|
||||
if (!(await appService.exists(fp, base))) return null;
|
||||
const file = await appService.openFile(fp, base);
|
||||
const bytes = await file.arrayBuffer();
|
||||
return { bytes, size: bytes.byteLength };
|
||||
},
|
||||
// Tauri-only: stream the book file straight from disk to the
|
||||
// WebDAV server via Rust-side `upload_file`, never letting the
|
||||
// bytes land in the JS heap. Without this, syncing a library
|
||||
// with multiple multi-hundred-megabyte PDFs accumulates
|
||||
// ArrayBuffers that V8 can't free fast enough between
|
||||
// sequential `pushBookFile` calls — the renderer eventually
|
||||
// hits its heap ceiling and the WebView crashes mid-sync,
|
||||
// surfacing as a blank white screen on desktop and as a
|
||||
// binder-OOM kill on Android. The metadata-only fast path
|
||||
// (open file just to read `.size`) keeps the HEAD short-
|
||||
// circuit working the same way the buffered path does.
|
||||
loadBookFileStreaming: isTauriAppPlatform()
|
||||
? async (book) => {
|
||||
if (!appService) return null;
|
||||
const fp = book.filePath ?? getLocalBookFilename(book);
|
||||
const base = book.filePath ? 'None' : 'Books';
|
||||
if (!(await appService.exists(fp, base))) return null;
|
||||
const file = await appService.openFile(fp, base);
|
||||
const size = file.size;
|
||||
// openFile returns a File-like handle; close eagerly when
|
||||
// the platform exposes it so the Tauri side can re-open
|
||||
// the path for the streamed PUT without holding two FDs.
|
||||
const closable = file as { close?: () => Promise<void> };
|
||||
if (closable.close) await closable.close();
|
||||
const dst = await appService.resolveFilePath(fp, base);
|
||||
return {
|
||||
size,
|
||||
upload: async (remoteUrl, headers) => {
|
||||
try {
|
||||
// tauriUpload's TS type says Map, but its Tauri
|
||||
// command on the Rust side accepts a JSON object →
|
||||
// HashMap<String, String>. The internal `headers ??
|
||||
// {}` default already proves a plain object works,
|
||||
// so cast and pass the headers object directly
|
||||
// rather than building a Map (which Tauri's IPC
|
||||
// serialiser handles less consistently).
|
||||
await tauriUpload(
|
||||
remoteUrl,
|
||||
dst,
|
||||
'PUT',
|
||||
undefined,
|
||||
headers as unknown as Map<string, string>,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('WD library sync: tauriUpload failed', book.hash, e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
: undefined,
|
||||
loadBookCover: async (book) => {
|
||||
// Covers are best-effort — books without one (TXT/MD without
|
||||
// metadata, custom imports without art) just return null and
|
||||
// syncLibrary skips them silently.
|
||||
if (!appService) return null;
|
||||
const fp = getCoverFilename(book);
|
||||
if (!(await appService.exists(fp, 'Books'))) return null;
|
||||
const file = await appService.openFile(fp, 'Books');
|
||||
const bytes = await file.arrayBuffer();
|
||||
return { bytes, size: bytes.byteLength };
|
||||
},
|
||||
saveBookFile: async (book, bytes) => {
|
||||
if (!appService) return;
|
||||
const fp = getLocalBookFilename(book);
|
||||
await appService.writeFile(fp, 'Books', bytes);
|
||||
},
|
||||
// Tauri-only: stream the book straight to disk via the Rust
|
||||
// side instead of slurping it into a JS ArrayBuffer first. The
|
||||
// WebView<->Tauri IPC bridge cannot handle multi-megabyte
|
||||
// buffers on Android (the renderer is binder-killed mid-write),
|
||||
// so for any non-trivial epub/pdf this is the *only* path that
|
||||
// works reliably on mobile.
|
||||
downloadBookFile: isTauriAppPlatform()
|
||||
? async (book, remotePath) => {
|
||||
if (!appService) return false;
|
||||
const url = buildRequestUrl(stored.serverUrl, remotePath);
|
||||
const headers = {
|
||||
Authorization: buildBasicAuthHeader(stored.username, stored.password),
|
||||
};
|
||||
// The Rust downloader writes the file verbatim and does
|
||||
// NOT create parent dirs — make sure the per-hash folder
|
||||
// under Books exists before kicking off the stream.
|
||||
try {
|
||||
if (!(await appService.exists(book.hash, 'Books'))) {
|
||||
await appService.createDir(book.hash, 'Books', true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('WD library sync: mkdir failed', book.hash, e);
|
||||
}
|
||||
const dst = await appService.resolveFilePath(getLocalBookFilename(book), 'Books');
|
||||
try {
|
||||
await tauriDownload(url, dst, undefined, headers);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('WD library sync: tauriDownload failed', book.hash, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
saveBookCover: async (book, bytes) => {
|
||||
if (!appService) return;
|
||||
const fp = getCoverFilename(book);
|
||||
await appService.writeFile(fp, 'Books', bytes);
|
||||
},
|
||||
saveBookConfig: async (book, config) => {
|
||||
if (!appService) return;
|
||||
await appService.saveBookConfig(book, config, settings);
|
||||
},
|
||||
addBookToLibrary: async (book) => {
|
||||
if (!appService) return;
|
||||
try {
|
||||
book.coverImageUrl = await appService.generateCoverImageUrl(book);
|
||||
} catch (e) {
|
||||
// Missing or broken cover shouldn't block adding the book —
|
||||
// the bookshelf renders a placeholder when coverImageUrl
|
||||
// is empty.
|
||||
console.warn('WD library sync: cover URL generation failed', book.hash, e);
|
||||
book.coverImageUrl = null;
|
||||
}
|
||||
book.syncedAt = Date.now();
|
||||
book.downloadedAt = Date.now();
|
||||
if (!book.metaHash) book.metaHash = book.hash;
|
||||
const { library, setLibrary } = useLibraryStore.getState();
|
||||
// Avoid duplicates if the user runs Sync now twice quickly.
|
||||
if (library.find((b) => b.hash === book.hash)) return;
|
||||
const newLibrary = [...library, book];
|
||||
await appService.saveLibraryBooks(newLibrary);
|
||||
// Update the store last so subscribers re-render against a
|
||||
// library that's already persisted on disk.
|
||||
setLibrary(newLibrary);
|
||||
},
|
||||
updateBookMetadata: async (book) => {
|
||||
if (!appService) return;
|
||||
// The cover bytes were just refreshed via saveBookCover, so
|
||||
// regenerate the device-local blob URL the bookshelf renders.
|
||||
try {
|
||||
book.coverImageUrl = await appService.generateCoverImageUrl(book);
|
||||
} catch (e) {
|
||||
console.warn('WD library sync: cover URL generation failed', book.hash, e);
|
||||
}
|
||||
book.syncedAt = Date.now();
|
||||
// updateBook persists via saveLibraryBooks and refreshes the store,
|
||||
// so the new title / author / cover show up without a reload.
|
||||
await useLibraryStore.getState().updateBook(envConfig, book);
|
||||
},
|
||||
onProgress: ({ book, index, total, action }) => {
|
||||
const actionStr = action === 'downloading' ? _('Downloading') : _('Uploading');
|
||||
updateProgress(
|
||||
@@ -434,58 +284,23 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
});
|
||||
|
||||
await persistWebdav({ lastSyncedAt: Date.now() });
|
||||
// Build a compact, accurate summary. Downloads happen regardless
|
||||
// of the `syncBooks` toggle, so they're always part of the toast;
|
||||
// the upload counters are only included when there was anything
|
||||
// to push (otherwise they'd just be a wall of zeros).
|
||||
const parts: string[] = [];
|
||||
if (result.booksDownloaded > 0) {
|
||||
parts.push(_('downloaded {{n}} book(s)', { n: result.booksDownloaded }));
|
||||
}
|
||||
if (result.configsDownloaded > 0) {
|
||||
parts.push(_('pulled progress for {{n}} book(s)', { n: result.configsDownloaded }));
|
||||
}
|
||||
if (result.metadataUpdated > 0) {
|
||||
parts.push(_('updated metadata for {{n}} book(s)', { n: result.metadataUpdated }));
|
||||
}
|
||||
if (result.configsUploaded > 0) {
|
||||
parts.push(_('pushed {{n}} config(s)', { n: result.configsUploaded }));
|
||||
}
|
||||
if (stored.syncBooks && result.filesUploaded > 0) {
|
||||
parts.push(_('uploaded {{n}} new file(s)', { n: result.filesUploaded }));
|
||||
}
|
||||
// Build the toast in two pieces so we can render the details on
|
||||
// their own lines on mobile. The Toast component truncates
|
||||
// single-line `info` messages (max-width + `truncate`), which
|
||||
// chops the long detail string on small screens. Two ways out:
|
||||
// 1. Use `success` type, which renders multi-line and shows a
|
||||
// dismiss button — picked when there's actionable detail.
|
||||
// 2. Stick with `info` for the short "everything up to date"
|
||||
// string, which always fits in one line anyway.
|
||||
// The detail bullets are joined with `\n` because Toast's
|
||||
// renderer (Toast.tsx) already splits on newlines into <br>s.
|
||||
let toastType: 'info' | 'success' | 'warning' = 'info';
|
||||
let summary: string;
|
||||
// Keep the toast as simple as the native cloud sync: a single-line
|
||||
// "{{count}} book(s) synced" info message. Failures still surface as a
|
||||
// warning so a partial sync isn't reported as a clean success.
|
||||
if (result.failures > 0) {
|
||||
toastType = 'warning';
|
||||
summary = _('Sync finished with {{failed}} failure(s). {{ok}} ok.', {
|
||||
failed: result.failures,
|
||||
ok: Math.max(0, result.totalBooks - result.failures),
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'warning',
|
||||
message: _('Sync finished with {{failed}} failure(s). {{ok}} ok.', {
|
||||
failed: result.failures,
|
||||
ok: Math.max(0, result.totalBooks - result.failures),
|
||||
}),
|
||||
});
|
||||
if (parts.length > 0) {
|
||||
summary += '\n' + parts.map((p) => `• ${p}`).join('\n');
|
||||
}
|
||||
} else if (parts.length > 0) {
|
||||
toastType = 'success';
|
||||
const heading = _('Sync complete');
|
||||
summary = `${heading}\n${parts.map((p) => `• ${p}`).join('\n')}`;
|
||||
} else {
|
||||
summary = _('Everything is already up to date.');
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'info',
|
||||
message: _('{{count}} book(s) synced', { count: result.booksSynced }),
|
||||
});
|
||||
}
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: toastType,
|
||||
message: summary,
|
||||
});
|
||||
} catch (e) {
|
||||
const message = formatSyncError(_, e);
|
||||
eventDispatcher.dispatch('toast', { type: 'error', message });
|
||||
@@ -522,6 +337,12 @@ const WebDAVForm: React.FC<WebDAVFormProps> = ({ onBack }) => {
|
||||
checked={stored.syncBooks ?? false}
|
||||
onChange={handleToggleSyncBooks}
|
||||
/>
|
||||
<SettingsSwitchRow
|
||||
label={_('Full Sync')}
|
||||
description={_('Re-check every book instead of only changed ones.')}
|
||||
checked={stored.fullSync ?? false}
|
||||
onChange={handleToggleFullSync}
|
||||
/>
|
||||
<SettingsRow label={_('Sync Strategy')}>
|
||||
<SettingsSelect
|
||||
value={stored.strategy ?? 'silent'}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { AppService } from '@/types/system';
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { getCoverFilename, getLocalBookFilename } from '@/utils/book';
|
||||
import { LocalStore } from './localStore';
|
||||
|
||||
/**
|
||||
* The app-backed {@link LocalStore} used by every file-sync consumer (the
|
||||
* reader hook and the library "Sync now" form). Consolidating the buffered +
|
||||
* streaming book/cover loaders here is the whole reason the bridge exists:
|
||||
* the logic used to be copy-pasted across both consumers.
|
||||
*
|
||||
* In-place imports keep their bytes outside `Books/<hash>/`, so the book-file
|
||||
* helpers resolve to `(book.filePath, 'None')` when `filePath` is set and fall
|
||||
* through to the hash-copy `Books`-relative path otherwise — mirroring
|
||||
* `cloudService.uploadBook` so sync treats in-place books as first-class.
|
||||
*/
|
||||
export const createAppLocalStore = ({
|
||||
appService,
|
||||
settings,
|
||||
envConfig,
|
||||
}: {
|
||||
appService: AppService;
|
||||
settings: SystemSettings;
|
||||
envConfig: EnvConfigType;
|
||||
}): LocalStore => ({
|
||||
loadConfig: (book) => appService.loadBookConfig(book, settings),
|
||||
saveBookConfig: (book, config) => appService.saveBookConfig(book, config, settings),
|
||||
|
||||
loadBookFile: async (book) => {
|
||||
const fp = book.filePath ?? getLocalBookFilename(book);
|
||||
const base = book.filePath ? 'None' : 'Books';
|
||||
if (!(await appService.exists(fp, base))) return null;
|
||||
const file = await appService.openFile(fp, base);
|
||||
const bytes = await file.arrayBuffer();
|
||||
return { bytes, size: bytes.byteLength };
|
||||
},
|
||||
|
||||
resolveLocalBookPath: async (book) => {
|
||||
const fp = book.filePath ?? getLocalBookFilename(book);
|
||||
const base = book.filePath ? 'None' : 'Books';
|
||||
if (!(await appService.exists(fp, base))) return null;
|
||||
const file = await appService.openFile(fp, base);
|
||||
const size = file.size;
|
||||
// Release the FD before streaming so the Tauri side can re-open the path
|
||||
// for the PUT without contending.
|
||||
const closable = file as { close?: () => Promise<void> };
|
||||
if (closable.close) await closable.close();
|
||||
const path = await appService.resolveFilePath(fp, base);
|
||||
return { path, size };
|
||||
},
|
||||
|
||||
saveBookFile: async (book, bytes) => {
|
||||
await appService.writeFile(getLocalBookFilename(book), 'Books', bytes);
|
||||
},
|
||||
|
||||
prepareLocalBookPath: async (book) => {
|
||||
// The Rust downloader writes the file verbatim and does NOT create parent
|
||||
// dirs — make sure the per-hash folder under Books exists first.
|
||||
try {
|
||||
if (!(await appService.exists(book.hash, 'Books'))) {
|
||||
await appService.createDir(book.hash, 'Books', true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('createAppLocalStore: mkdir failed', book.hash, e);
|
||||
}
|
||||
return appService.resolveFilePath(getLocalBookFilename(book), 'Books');
|
||||
},
|
||||
|
||||
loadBookCover: async (book) => {
|
||||
const fp = getCoverFilename(book);
|
||||
if (!(await appService.exists(fp, 'Books'))) return null;
|
||||
const file = await appService.openFile(fp, 'Books');
|
||||
const bytes = await file.arrayBuffer();
|
||||
return { bytes, size: bytes.byteLength };
|
||||
},
|
||||
|
||||
saveBookCover: async (book, bytes) => {
|
||||
await appService.writeFile(getCoverFilename(book), 'Books', bytes);
|
||||
},
|
||||
|
||||
addBookToLibrary: async (book) => {
|
||||
try {
|
||||
book.coverImageUrl = await appService.generateCoverImageUrl(book);
|
||||
} catch (e) {
|
||||
// Missing/broken cover shouldn't block adding the book — the bookshelf
|
||||
// renders a placeholder when coverImageUrl is empty.
|
||||
console.warn('createAppLocalStore: cover URL generation failed', book.hash, e);
|
||||
book.coverImageUrl = null;
|
||||
}
|
||||
book.syncedAt = Date.now();
|
||||
book.downloadedAt = Date.now();
|
||||
if (!book.metaHash) book.metaHash = book.hash;
|
||||
// Hydrate from disk if the store hasn't loaded yet. Merging against an
|
||||
// empty in-memory array would persist this book as the *entire* library
|
||||
// and clobber whatever is on disk. Mirrors useLibraryStore.updateBooks'
|
||||
// hardening; the Sync-now caller also hydrates up front, so this is
|
||||
// belt-and-suspenders for a data-loss path.
|
||||
let library = useLibraryStore.getState().library;
|
||||
if (!useLibraryStore.getState().libraryLoaded) {
|
||||
library = await appService.loadLibraryBooks();
|
||||
useLibraryStore.getState().setLibrary(library);
|
||||
}
|
||||
// Avoid duplicates if the user runs Sync now twice quickly.
|
||||
if (library.find((b) => b.hash === book.hash)) return;
|
||||
const newLibrary = [...library, book];
|
||||
await appService.saveLibraryBooks(newLibrary);
|
||||
// Update the store last so subscribers re-render against a library that's
|
||||
// already persisted on disk.
|
||||
useLibraryStore.getState().setLibrary(newLibrary);
|
||||
},
|
||||
|
||||
updateBookMetadata: async (book) => {
|
||||
// The cover bytes were just refreshed via saveBookCover, so regenerate the
|
||||
// device-local blob URL the bookshelf renders.
|
||||
try {
|
||||
book.coverImageUrl = await appService.generateCoverImageUrl(book);
|
||||
} catch (e) {
|
||||
console.warn('createAppLocalStore: cover URL generation failed', book.hash, e);
|
||||
}
|
||||
book.syncedAt = Date.now();
|
||||
// Hydrate before updating: updateBook merges against the in-memory library,
|
||||
// so an unloaded store would persist an empty (or single-book) library and
|
||||
// clobber the disk. See the same guard in addBookToLibrary.
|
||||
if (!useLibraryStore.getState().libraryLoaded) {
|
||||
useLibraryStore.getState().setLibrary(await appService.loadLibraryBooks());
|
||||
}
|
||||
// updateBook persists via saveLibraryBooks and refreshes the store, so the
|
||||
// new title / author / cover show up without a reload.
|
||||
await useLibraryStore.getState().updateBook(envConfig, book);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,736 @@
|
||||
import { Book, BookConfig, BookNote } from '@/types/book';
|
||||
import { FileHead, FileSyncError, FileSyncProvider } from './provider';
|
||||
import { LocalStore } from './localStore';
|
||||
import {
|
||||
ancestorsOf,
|
||||
buildBasePath,
|
||||
buildBookConfigPath,
|
||||
buildBookCoverPath,
|
||||
buildBookDirPath,
|
||||
buildBookFilePath,
|
||||
buildLibraryPath,
|
||||
SYNC_BOOKS_DIR,
|
||||
SYNC_BOOK_CONFIG_FILE,
|
||||
SYNC_BOOK_COVER_FILE,
|
||||
} from './layout';
|
||||
import {
|
||||
buildRemotePayload,
|
||||
parseRemotePayload,
|
||||
parseRemoteLibraryIndex,
|
||||
RemoteLibraryIndex,
|
||||
} from './wire';
|
||||
import { isRemoteBookMetadataNewer, mergeBookConfig, mergeBookMetadata } from './merge';
|
||||
|
||||
export type SyncStrategy = 'silent' | 'send' | 'receive';
|
||||
|
||||
export interface PullResult {
|
||||
/** True when the remote had a config and we merged something into local. */
|
||||
applied: boolean;
|
||||
/** The merged config to be written back into the local store. */
|
||||
mergedConfig?: BookConfig;
|
||||
/** When non-empty, these are the notes after merge — use them to update the live view. */
|
||||
mergedNotes?: BookNote[];
|
||||
/** The remote's writerDeviceId, useful for diagnostics. */
|
||||
remoteDeviceId?: string;
|
||||
}
|
||||
|
||||
export interface PushBookFileResult {
|
||||
/** True when bytes were uploaded; false when the upload was skipped. */
|
||||
uploaded: boolean;
|
||||
/** Reason for the skip, when applicable — surfaced for diagnostics. */
|
||||
reason?: 'remote-matches' | 'no-source' | 'disabled';
|
||||
}
|
||||
|
||||
export interface DeleteRemoteBookDirResult {
|
||||
/** True when the server confirmed deletion (or the dir was already gone). */
|
||||
ok: boolean;
|
||||
/** Compact reason string when `ok === false`, for the failure toast. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface SyncFailureEntry {
|
||||
hash: string;
|
||||
title: string;
|
||||
reason: string;
|
||||
/** Which phase of the per-book pipeline failed; helps users self-triage. */
|
||||
phase: 'download' | 'upload-config' | 'upload-file' | 'upload-cover';
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate result of a library-wide sync. Counters are kept granular so the
|
||||
* UI can render an honest "X uploaded, Y already in sync, Z failed" toast.
|
||||
*/
|
||||
export interface SyncLibraryResult {
|
||||
totalBooks: number;
|
||||
configsUploaded: number;
|
||||
configsDownloaded: number;
|
||||
filesUploaded: number;
|
||||
filesAlreadyInSync: number;
|
||||
coversUploaded: number;
|
||||
booksDownloaded: number;
|
||||
/** Already-local books whose metadata was refreshed from a newer index copy (#4756). */
|
||||
metadataUpdated: number;
|
||||
/** Distinct books that had any sync activity (pushed, downloaded, or reconciled). */
|
||||
booksSynced: number;
|
||||
failures: number;
|
||||
/** Per-book failure breakdown for the diagnostic log in the Settings UI. */
|
||||
failedBooks: SyncFailureEntry[];
|
||||
}
|
||||
|
||||
export interface SyncLibraryOptions {
|
||||
syncBooks: boolean;
|
||||
strategy?: SyncStrategy;
|
||||
/** Stable per-device id; written into every config envelope. */
|
||||
deviceId: string;
|
||||
/**
|
||||
* When false (default), only books whose local copy differs from the shared
|
||||
* library.json index are processed — `book.updatedAt` bumps on every
|
||||
* progress / notes / metadata save, so the index is a reliable per-book
|
||||
* change marker. When true, every book is re-checked (the original full
|
||||
* walk), an escape hatch for drift or a first sync to a fresh remote.
|
||||
*/
|
||||
fullSync?: boolean;
|
||||
/**
|
||||
* Max books processed concurrently per phase (download / reconcile / push).
|
||||
* Defaults to 4. A bounded pool keeps shared WebDAV servers happy while
|
||||
* still hiding per-request latency.
|
||||
*/
|
||||
concurrency?: number;
|
||||
/**
|
||||
* Optional progress callback fired before each book is processed,
|
||||
* suitable for driving a UI like "Syncing 3 / 42 — Project Hail Mary".
|
||||
*/
|
||||
onProgress?: (info: { book: Book; index: number; total: number; action?: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an arbitrary error to a short, single-line description for the
|
||||
* per-book failure breakdown in {@link SyncLibraryResult}. Preserves the
|
||||
* semantically useful bits (HTTP status, the `code` enum), strips stack
|
||||
* traces / server XML, and caps at 200 chars.
|
||||
*/
|
||||
const formatFailureReason = (e: unknown): string => {
|
||||
let message: string;
|
||||
if (e instanceof FileSyncError) {
|
||||
const parts: string[] = [];
|
||||
if (e.code) parts.push(e.code);
|
||||
if (typeof e.status === 'number') parts.push(`HTTP ${e.status}`);
|
||||
parts.push(e.message || 'Request failed');
|
||||
message = parts.join(' · ');
|
||||
} else if (e instanceof Error) {
|
||||
message = e.message || e.name || 'Unknown error';
|
||||
} else {
|
||||
message = String(e);
|
||||
}
|
||||
message = message.replace(/\s+/g, ' ').trim();
|
||||
return message.length > 200 ? `${message.slice(0, 197)}...` : message;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete the per-book directory `<rootPath>/Readest/books/<hash>/` — file,
|
||||
* cover and config.json — in one round-trip. Used by the remote-browser
|
||||
* cleanup mode to evict orphans. AUTH failures rethrow (a global condition
|
||||
* the caller surfaces as a single re-auth toast); every other failure is
|
||||
* folded into `{ ok: false, reason }` so a batch loop can aggregate.
|
||||
*
|
||||
* Standalone (not a method) because it needs no {@link LocalStore} — the
|
||||
* WebDAV-specific browse UI builds a provider and calls it directly.
|
||||
*/
|
||||
export const deleteRemoteBookDir = async (
|
||||
provider: FileSyncProvider,
|
||||
bookHash: string,
|
||||
): Promise<DeleteRemoteBookDirResult> => {
|
||||
const path = buildBookDirPath(provider.rootPath, bookHash);
|
||||
try {
|
||||
await provider.deleteDir(path);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
if (e instanceof FileSyncError && e.code === 'AUTH_FAILED') throw e;
|
||||
return { ok: false, reason: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Run `worker` over `items` with at most `limit` in flight at once. A bounded
|
||||
* pool: `limit` runner loops each pull the next index off a shared cursor until
|
||||
* the list drains. JS's single-threaded event loop makes the cursor increment
|
||||
* and the per-book result mutations race-free between await points.
|
||||
*/
|
||||
const runPool = async <T>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
worker: (item: T, index: number) => Promise<void>,
|
||||
): Promise<void> => {
|
||||
if (items.length === 0) return;
|
||||
let cursor = 0;
|
||||
const runners = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
await worker(items[index]!, index);
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider-agnostic file-sync orchestration: progress + booknote merge per
|
||||
* book, library-wide push/pull with last-writer-wins metadata reconciliation,
|
||||
* and HEAD-short-circuited binary upload. All remote I/O goes through a
|
||||
* {@link FileSyncProvider}; all local I/O goes through a {@link LocalStore}.
|
||||
*/
|
||||
export class FileSyncEngine {
|
||||
constructor(
|
||||
private readonly provider: FileSyncProvider,
|
||||
private readonly store: LocalStore,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Pull `<rootPath>/Readest/books/<hash>/config.json`, merge into the
|
||||
* provided local config, and return the merged result. The caller writes
|
||||
* the merged config back (so the engine stays free of store-write side
|
||||
* effects here). `applied: false` when the remote file is absent/malformed.
|
||||
*/
|
||||
async pullBookConfig(book: Book, localConfig: BookConfig): Promise<PullResult> {
|
||||
const path = buildBookConfigPath(this.provider.rootPath, book.hash);
|
||||
const remote = parseRemotePayload(await this.provider.readText(path));
|
||||
if (!remote) return { applied: false };
|
||||
const { config, notes } = mergeBookConfig(localConfig, remote);
|
||||
return {
|
||||
applied: true,
|
||||
mergedConfig: config,
|
||||
mergedNotes: notes,
|
||||
remoteDeviceId: remote.writerDeviceId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the local BookConfig to the remote, creating parent dirs as needed.
|
||||
* A 409 (parent vanished between MKCOL and PUT) triggers one re-ensure +
|
||||
* retry. Deciding *whether* to push is the caller's job; this is the dumb
|
||||
* mechanism.
|
||||
*/
|
||||
async pushBookConfig(book: Book, config: BookConfig, deviceId: string): Promise<void> {
|
||||
const dirPath = buildBookDirPath(this.provider.rootPath, book.hash);
|
||||
const path = buildBookConfigPath(this.provider.rootPath, book.hash);
|
||||
const dirs = [...ancestorsOf(`${dirPath}/.placeholder`), dirPath];
|
||||
await this.provider.ensureDir(dirs);
|
||||
const body = JSON.stringify(buildRemotePayload(book, config, deviceId));
|
||||
try {
|
||||
await this.provider.writeText(path, body);
|
||||
} catch (e) {
|
||||
if (e instanceof FileSyncError && e.status === 409) {
|
||||
await this.provider.ensureDir(dirs);
|
||||
await this.provider.writeText(path, body);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the book binary to `<rootPath>/Readest/books/<hash>/<title>.<ext>`.
|
||||
* HEAD-probe + size compare skips re-uploading an already-mirrored book.
|
||||
* Streaming (provider.uploadStream, Tauri only) is preferred — constant JS
|
||||
* heap regardless of book size; web falls back to buffered writeBinary.
|
||||
*/
|
||||
async pushBookFile(book: Book): Promise<PushBookFileResult> {
|
||||
const dirPath = buildBookDirPath(this.provider.rootPath, book.hash);
|
||||
const path = buildBookFilePath(this.provider.rootPath, book);
|
||||
const dirs = [...ancestorsOf(`${dirPath}/.placeholder`), dirPath];
|
||||
|
||||
let remoteHead: FileHead | null = null;
|
||||
try {
|
||||
remoteHead = await this.provider.head(path);
|
||||
} catch (e) {
|
||||
if (!(e instanceof FileSyncError) || e.code !== 'NETWORK') throw e;
|
||||
}
|
||||
|
||||
// Streaming path: resolve the on-disk path + size only, then stream the
|
||||
// bytes straight from disk. The metadata fetch never reads the body, so
|
||||
// heap stays flat even for gigabyte-scale PDFs.
|
||||
if (this.provider.uploadStream) {
|
||||
const src = await this.store.resolveLocalBookPath(book);
|
||||
if (src) {
|
||||
if (remoteHead && remoteHead.size === src.size) {
|
||||
return { uploaded: false, reason: 'remote-matches' };
|
||||
}
|
||||
await this.provider.ensureDir(dirs);
|
||||
let ok = await this.provider.uploadStream(path, src.path);
|
||||
if (!ok) {
|
||||
// Mirror the buffered path's one-shot retry: a parent may have been
|
||||
// recreated mid-PUT (409). Re-ensure directories and try once more.
|
||||
await this.provider.ensureDir(dirs);
|
||||
ok = await this.provider.uploadStream(path, src.path);
|
||||
if (!ok) throw new FileSyncError('Streaming upload failed', 'NETWORK');
|
||||
}
|
||||
return { uploaded: true };
|
||||
}
|
||||
// src null — book isn't on this device via the streaming resolver; fall
|
||||
// through to the buffered loader as a last resort.
|
||||
}
|
||||
|
||||
const local = await this.store.loadBookFile(book);
|
||||
if (!local) return { uploaded: false, reason: 'no-source' };
|
||||
if (remoteHead && remoteHead.size === local.size) {
|
||||
return { uploaded: false, reason: 'remote-matches' };
|
||||
}
|
||||
await this.provider.ensureDir(dirs);
|
||||
try {
|
||||
await this.provider.writeBinary(path, local.bytes);
|
||||
} catch (e) {
|
||||
if (e instanceof FileSyncError && e.status === 409) {
|
||||
await this.provider.ensureDir(dirs);
|
||||
await this.provider.writeBinary(path, local.bytes);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return { uploaded: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the book's cover image to `<rootPath>/Readest/books/<hash>/cover.png`.
|
||||
* Same HEAD-probe + size-compare idempotency as {@link pushBookFile}. Covers
|
||||
* are best-effort: a book without a local cover resolves to `no-source`.
|
||||
*/
|
||||
async pushBookCover(book: Book): Promise<PushBookFileResult> {
|
||||
const dirPath = buildBookDirPath(this.provider.rootPath, book.hash);
|
||||
const path = buildBookCoverPath(this.provider.rootPath, book.hash);
|
||||
const dirs = [...ancestorsOf(`${dirPath}/.placeholder`), dirPath];
|
||||
|
||||
let remoteHead: FileHead | null = null;
|
||||
try {
|
||||
remoteHead = await this.provider.head(path);
|
||||
} catch (e) {
|
||||
if (!(e instanceof FileSyncError) || e.code !== 'NETWORK') throw e;
|
||||
}
|
||||
|
||||
const local = await this.store.loadBookCover(book);
|
||||
if (!local) return { uploaded: false, reason: 'no-source' };
|
||||
if (remoteHead && remoteHead.size === local.size) {
|
||||
return { uploaded: false, reason: 'remote-matches' };
|
||||
}
|
||||
await this.provider.ensureDir(dirs);
|
||||
try {
|
||||
await this.provider.writeBinary(path, local.bytes, 'image/png');
|
||||
} catch (e) {
|
||||
if (e instanceof FileSyncError && e.status === 409) {
|
||||
await this.provider.ensureDir(dirs);
|
||||
await this.provider.writeBinary(path, local.bytes, 'image/png');
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
return { uploaded: true };
|
||||
}
|
||||
|
||||
/** GET the remote cover.png bytes for a hash, or null when absent. */
|
||||
async pullBookCover(bookHash: string): Promise<ArrayBuffer | null> {
|
||||
return this.provider.readBinary(buildBookCoverPath(this.provider.rootPath, bookHash));
|
||||
}
|
||||
|
||||
/** GET + parse the shared library.json index, or null when absent/malformed. */
|
||||
async pullLibraryIndex(): Promise<RemoteLibraryIndex | null> {
|
||||
const path = buildLibraryPath(this.provider.rootPath);
|
||||
return parseRemoteLibraryIndex(await this.provider.readText(path));
|
||||
}
|
||||
|
||||
/** PUT the shared library.json index, creating its parent dirs. */
|
||||
async pushLibraryIndex(index: RemoteLibraryIndex): Promise<void> {
|
||||
const path = buildLibraryPath(this.provider.rootPath);
|
||||
await this.provider.ensureDir(ancestorsOf(path));
|
||||
await this.provider.writeText(path, JSON.stringify(index));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync every book in `books` against the remote in sequence (predictable
|
||||
* progress bar; no parallel PUTs that upset shared servers). Per book:
|
||||
* pull index → reconcile metadata (LWW) → discover remote-only books and
|
||||
* download them → pull-merge-push each local config + cover + (optionally)
|
||||
* file → re-push the merged index.
|
||||
*
|
||||
* Strategy gating: 'silent' two-way, 'send' push-only (blind, local
|
||||
* authoritative), 'receive' pull-only. Single-book failures are caught and
|
||||
* counted so one bad apple never aborts the rest of the library.
|
||||
*/
|
||||
async syncLibrary(books: Book[], options: SyncLibraryOptions): Promise<SyncLibraryResult> {
|
||||
const result: SyncLibraryResult = {
|
||||
totalBooks: books.length,
|
||||
configsUploaded: 0,
|
||||
configsDownloaded: 0,
|
||||
filesUploaded: 0,
|
||||
filesAlreadyInSync: 0,
|
||||
coversUploaded: 0,
|
||||
booksDownloaded: 0,
|
||||
metadataUpdated: 0,
|
||||
booksSynced: 0,
|
||||
failures: 0,
|
||||
failedBooks: [],
|
||||
};
|
||||
|
||||
// Distinct books touched in any direction — the single "N book(s) synced"
|
||||
// number the UI surfaces. Tracked as a set because the per-action counters
|
||||
// overlap (a Full-Sync re-check both reconciles and re-pushes the same
|
||||
// book, and one book can push a config + cover + file).
|
||||
const syncedHashes = new Set<string>();
|
||||
|
||||
const strategy = options.strategy || 'silent';
|
||||
const canPull = strategy !== 'send';
|
||||
const canPush = strategy !== 'receive';
|
||||
|
||||
let remoteIndex: RemoteLibraryIndex | null = null;
|
||||
if (canPull) {
|
||||
try {
|
||||
remoteIndex = await this.pullLibraryIndex();
|
||||
} catch (e) {
|
||||
console.warn('file sync: failed to pull index', e);
|
||||
}
|
||||
}
|
||||
|
||||
const allBooksMap = new Map<string, Book>();
|
||||
for (const b of books) {
|
||||
allBooksMap.set(b.hash, b);
|
||||
}
|
||||
|
||||
const fullSync = options.fullSync ?? false;
|
||||
const concurrency = Math.max(1, options.concurrency ?? 4);
|
||||
|
||||
// Incremental cursor: a book needs a push only when its local copy is newer
|
||||
// than (or absent from) the shared library.json index. `book.updatedAt`
|
||||
// bumps on every progress / notes / metadata save, so the index is a
|
||||
// reliable per-book change marker. When no index is available (send mode,
|
||||
// or a failed pull) every local book counts as new and is pushed.
|
||||
const remoteByHash = new Map<string, Book>();
|
||||
if (remoteIndex?.books) {
|
||||
for (const rb of remoteIndex.books) {
|
||||
if (!rb.deletedAt) remoteByHash.set(rb.hash, rb);
|
||||
}
|
||||
}
|
||||
const isLocalNewer = (book: Book): boolean => {
|
||||
const remote = remoteByHash.get(book.hash);
|
||||
if (!remote) return true;
|
||||
return (book.updatedAt ?? 0) > (remote.updatedAt ?? 0);
|
||||
};
|
||||
|
||||
const remoteBooksToDownload: Book[] = [];
|
||||
// The remote source of truth for a book's on-disk filename is the per-hash
|
||||
// directory listing — NOT the book's title (which may be stale). We always
|
||||
// resolve the path by listing the hash dir.
|
||||
const explicitRemotePaths = new Map<string, string>();
|
||||
|
||||
// Metadata reconciliation for books present BOTH locally and in the shared
|
||||
// library.json (#4756). Last-writer-wins on `book.updatedAt`: when a peer's
|
||||
// indexed copy is strictly newer, pull its title / author / cover down.
|
||||
// Updating allBooksMap with the merged copy also stops the final index
|
||||
// re-push from clobbering the peer's newer metadata with this device's
|
||||
// stale copy.
|
||||
if (canPull && remoteIndex && remoteIndex.books) {
|
||||
const remoteNewer = remoteIndex.books.filter((rb) => {
|
||||
if (rb.deletedAt) return false;
|
||||
const local = allBooksMap.get(rb.hash);
|
||||
return !!local && !local.deletedAt && isRemoteBookMetadataNewer(local, rb);
|
||||
});
|
||||
await runPool(remoteNewer, concurrency, async (rb) => {
|
||||
const local = allBooksMap.get(rb.hash)!;
|
||||
const merged = mergeBookMetadata(local, rb);
|
||||
// Re-pull the cover so a changed cover travels with the metadata. The
|
||||
// subsequent push-side pushBookCover HEAD/size short-circuit then
|
||||
// matches (local now equals remote), so we never bounce it back up.
|
||||
try {
|
||||
const coverBytes = await this.pullBookCover(rb.hash);
|
||||
if (coverBytes) await this.store.saveBookCover(merged, coverBytes);
|
||||
} catch (e) {
|
||||
console.warn('file sync: metadata cover pull failed', rb.hash, e);
|
||||
}
|
||||
// Incremental only: the per-book push loop below skips remote-newer
|
||||
// books, so pull their config here too — otherwise a peer's progress /
|
||||
// notes wouldn't propagate without re-walking every book. In full-sync
|
||||
// mode the push loop pulls each config, so we skip this to avoid a
|
||||
// duplicate GET.
|
||||
if (!fullSync) {
|
||||
try {
|
||||
const localConfig = (await this.store.loadConfig(merged)) ?? {
|
||||
updatedAt: 0,
|
||||
booknotes: [],
|
||||
};
|
||||
const pull = await this.pullBookConfig(merged, localConfig);
|
||||
if (pull.applied && pull.mergedConfig) {
|
||||
await this.store.saveBookConfig(merged, pull.mergedConfig);
|
||||
result.configsDownloaded += 1;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('file sync: metadata config pull failed', rb.hash, e);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.store.updateBookMetadata(merged);
|
||||
allBooksMap.set(rb.hash, merged);
|
||||
result.metadataUpdated += 1;
|
||||
syncedHashes.add(rb.hash);
|
||||
} catch (e) {
|
||||
console.warn('file sync: metadata update failed', rb.hash, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (canPull) {
|
||||
const candidateHashes = new Set<string>();
|
||||
|
||||
// 1) Seed with hashes from the remote index (when the file exists).
|
||||
if (remoteIndex && remoteIndex.books) {
|
||||
for (const rb of remoteIndex.books) {
|
||||
if (!allBooksMap.has(rb.hash) && !rb.deletedAt) {
|
||||
candidateHashes.add(rb.hash);
|
||||
// Provisionally register the indexed book — fields refreshed below
|
||||
// once we've inspected the actual hash dir.
|
||||
allBooksMap.set(rb.hash, rb);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Also scan the books/ directory so legacy uploads (no library.json
|
||||
// entry) and index/disk drift are still picked up.
|
||||
try {
|
||||
const booksDirPath = `${buildBasePath(this.provider.rootPath)}/${SYNC_BOOKS_DIR}`;
|
||||
const dirEntries = await this.provider.list(booksDirPath);
|
||||
for (const entry of dirEntries) {
|
||||
if (entry.isDirectory && !allBooksMap.has(entry.name)) {
|
||||
candidateHashes.add(entry.name);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 404 is normal if the user has never pushed anything yet.
|
||||
console.warn('file sync: failed to list books directory', e);
|
||||
}
|
||||
|
||||
// 3) For every candidate, look inside its hash directory to find the
|
||||
// actual book file (the only entry that isn't config.json/cover.png).
|
||||
for (const hash of candidateHashes) {
|
||||
try {
|
||||
const hashDirPath = `${buildBasePath(this.provider.rootPath)}/${SYNC_BOOKS_DIR}/${hash}`;
|
||||
const hashDirEntries = await this.provider.list(hashDirPath);
|
||||
const fileEntry = hashDirEntries.find(
|
||||
(e) =>
|
||||
!e.isDirectory && e.name !== SYNC_BOOK_CONFIG_FILE && e.name !== SYNC_BOOK_COVER_FILE,
|
||||
);
|
||||
if (!fileEntry) continue;
|
||||
|
||||
const extMatch = fileEntry.name.match(/\.([^.]+)$/);
|
||||
const ext = extMatch && extMatch[1] ? extMatch[1].toUpperCase() : 'EPUB';
|
||||
const format = ext as Book['format'];
|
||||
const title = fileEntry.name.replace(/\.[^.]+$/, '');
|
||||
|
||||
// If the index already gave us a book object, refresh the fields
|
||||
// that might be wrong/stale from a previous buggy push.
|
||||
const existing = allBooksMap.get(hash);
|
||||
const book: Book = existing
|
||||
? {
|
||||
...existing,
|
||||
format,
|
||||
title:
|
||||
!existing.title || existing.title.toLowerCase().endsWith(`.${ext.toLowerCase()}`)
|
||||
? title
|
||||
: existing.title,
|
||||
sourceTitle: title,
|
||||
updatedAt: existing.updatedAt || Date.now(),
|
||||
createdAt: existing.createdAt || Date.now(),
|
||||
}
|
||||
: {
|
||||
hash,
|
||||
format,
|
||||
title,
|
||||
sourceTitle: title,
|
||||
author: 'Unknown',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
explicitRemotePaths.set(hash, fileEntry.path);
|
||||
remoteBooksToDownload.push(book);
|
||||
allBooksMap.set(hash, book);
|
||||
} catch (e) {
|
||||
console.warn('file sync: failed to inspect hash dir', hash, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery+download is NOT gated on `syncBooks`. The toggle controls
|
||||
// whether the *push* side ships binaries to the remote — pulling books
|
||||
// that exist remotely but not locally is the whole point of a sync.
|
||||
if (canPull) {
|
||||
let downloadStarted = 0;
|
||||
await runPool(remoteBooksToDownload, concurrency, async (rb) => {
|
||||
options.onProgress?.({
|
||||
book: rb,
|
||||
index: downloadStarted,
|
||||
total: remoteBooksToDownload.length,
|
||||
action: 'downloading',
|
||||
});
|
||||
downloadStarted += 1;
|
||||
try {
|
||||
const explicitPath = explicitRemotePaths.get(rb.hash);
|
||||
// Prefer the streaming downloader. On Tauri/Android we MUST take
|
||||
// this path — moving a 30 MB epub through the WebView<->Rust IPC
|
||||
// bridge as a single Uint8Array crashes the renderer.
|
||||
let written = false;
|
||||
if (this.provider.downloadStream && explicitPath) {
|
||||
const dst = await this.store.prepareLocalBookPath(rb);
|
||||
written = await this.provider.downloadStream(explicitPath, dst);
|
||||
} else {
|
||||
const remotePath = explicitPath ?? buildBookFilePath(this.provider.rootPath, rb);
|
||||
const fileBytes = await this.provider.readBinary(remotePath);
|
||||
if (fileBytes) {
|
||||
await this.store.saveBookFile(rb, fileBytes);
|
||||
written = true;
|
||||
}
|
||||
}
|
||||
if (written) {
|
||||
try {
|
||||
const coverBytes = await this.pullBookCover(rb.hash);
|
||||
if (coverBytes) await this.store.saveBookCover(rb, coverBytes);
|
||||
} catch (e) {
|
||||
console.warn('file sync: cover download failed', rb.hash, e);
|
||||
}
|
||||
// Pull the remote config so progress, bookmarks and annotations
|
||||
// travel with the book. Best-effort: a missing config is not a
|
||||
// failure.
|
||||
try {
|
||||
const emptyLocal: BookConfig = { updatedAt: 0, booknotes: [] };
|
||||
const pullResult = await this.pullBookConfig(rb, emptyLocal);
|
||||
if (pullResult.applied && pullResult.mergedConfig) {
|
||||
await this.store.saveBookConfig(rb, pullResult.mergedConfig);
|
||||
result.configsDownloaded += 1;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('file sync: config download failed', rb.hash, e);
|
||||
}
|
||||
await this.store.addBookToLibrary(rb);
|
||||
result.booksDownloaded += 1;
|
||||
syncedHashes.add(rb.hash);
|
||||
} else {
|
||||
// No bytes returned (typically a 404 we couldn't resolve).
|
||||
result.failures += 1;
|
||||
result.failedBooks.push({
|
||||
hash: rb.hash,
|
||||
title: rb.title || rb.hash,
|
||||
phase: 'download',
|
||||
reason: 'No bytes returned (file may have been moved or deleted on the server)',
|
||||
});
|
||||
console.warn('file sync: book download produced no bytes', rb.hash, explicitPath);
|
||||
}
|
||||
} catch (e) {
|
||||
result.failures += 1;
|
||||
result.failedBooks.push({
|
||||
hash: rb.hash,
|
||||
title: rb.title || rb.hash,
|
||||
phase: 'download',
|
||||
reason: formatFailureReason(e),
|
||||
});
|
||||
console.warn('file sync: book download failed', rb.hash, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Books we just downloaded already exist on the remote — don't re-push
|
||||
// them. Only push books already present in the caller-supplied library.
|
||||
const downloadedHashes = new Set(remoteBooksToDownload.map((b) => b.hash));
|
||||
// Incremental (default): push only books that changed locally since the
|
||||
// last index push. Full-sync re-checks everything.
|
||||
const booksToPush = books.filter(
|
||||
(b) => !b.deletedAt && !downloadedHashes.has(b.hash) && (fullSync || isLocalNewer(b)),
|
||||
);
|
||||
result.totalBooks = booksToPush.length;
|
||||
|
||||
if (canPush && booksToPush.length > 0) {
|
||||
let pushStarted = 0;
|
||||
await runPool(booksToPush, concurrency, async (book) => {
|
||||
options.onProgress?.({
|
||||
book,
|
||||
index: pushStarted,
|
||||
total: booksToPush.length,
|
||||
action: 'uploading',
|
||||
});
|
||||
pushStarted += 1;
|
||||
let phase: SyncFailureEntry['phase'] = 'upload-config';
|
||||
try {
|
||||
const config = await this.store.loadConfig(book);
|
||||
if (config) {
|
||||
// Mirror the reader hook's pull-merge-push discipline so a manual
|
||||
// "Sync now" can't blind-overwrite state this device hasn't pulled
|
||||
// yet. Only in two-way ('silent') mode — 'send' keeps the blind
|
||||
// push. A failed pull-merge falls back to the local config.
|
||||
let configToPush = config;
|
||||
if (canPull) {
|
||||
try {
|
||||
const pull = await this.pullBookConfig(book, config);
|
||||
if (pull.applied && pull.mergedConfig) {
|
||||
configToPush = pull.mergedConfig;
|
||||
// Persist the merged superset locally so this device
|
||||
// converges too, not just the remote.
|
||||
await this.store.saveBookConfig(book, pull.mergedConfig);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('file sync: config pull-merge failed', book.hash, e);
|
||||
}
|
||||
}
|
||||
await this.pushBookConfig(book, configToPush, options.deviceId);
|
||||
result.configsUploaded += 1;
|
||||
syncedHashes.add(book.hash);
|
||||
}
|
||||
// Covers ride along with the config-level sync, NOT with syncBooks:
|
||||
// the receiving device can't regenerate them without the book bytes.
|
||||
// Failures here are warnings, not hard failures.
|
||||
try {
|
||||
const coverResult = await this.pushBookCover(book);
|
||||
if (coverResult.uploaded) {
|
||||
result.coversUploaded += 1;
|
||||
syncedHashes.add(book.hash);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('file sync: cover failed', book.hash, e);
|
||||
}
|
||||
if (options.syncBooks) {
|
||||
phase = 'upload-file';
|
||||
const fileResult = await this.pushBookFile(book);
|
||||
if (fileResult.uploaded) {
|
||||
result.filesUploaded += 1;
|
||||
syncedHashes.add(book.hash);
|
||||
} else if (fileResult.reason === 'remote-matches') {
|
||||
result.filesAlreadyInSync += 1;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
result.failures += 1;
|
||||
result.failedBooks.push({
|
||||
hash: book.hash,
|
||||
title: book.title || book.hash,
|
||||
phase,
|
||||
reason: formatFailureReason(e),
|
||||
});
|
||||
console.warn('file sync: book failed', book.hash, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Push the merged index whenever we're allowed to write, even if no
|
||||
// binaries moved this turn (keeps library.json authoritative). Soft-deleted
|
||||
// books' per-hash dirs are intentionally NOT GC'd here — that's the manual
|
||||
// cleanup sweep's job (see deleteRemoteBookDir).
|
||||
if (canPush) {
|
||||
try {
|
||||
const newIndex: RemoteLibraryIndex = {
|
||||
schemaVersion: 1,
|
||||
books: Array.from(allBooksMap.values()),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await this.pushLibraryIndex(newIndex);
|
||||
} catch (e) {
|
||||
console.warn('file sync: failed to push index', e);
|
||||
}
|
||||
}
|
||||
|
||||
result.booksSynced = syncedHashes.size;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Provider-agnostic file-sync core. Consumers import the engine, the provider
|
||||
* interface, and the local-store bridge from here; a concrete backend lives
|
||||
* under `src/services/sync/providers/<name>/`.
|
||||
*/
|
||||
export * from './provider';
|
||||
export * from './localStore';
|
||||
export * from './appLocalStore';
|
||||
export * from './layout';
|
||||
export * from './wire';
|
||||
export * from './merge';
|
||||
export * from './engine';
|
||||
+19
-14
@@ -3,9 +3,10 @@ import { EXTS } from '@/libs/document';
|
||||
import { makeSafeFilename } from '@/utils/misc';
|
||||
|
||||
/**
|
||||
* Layout convention for the WebDAV "Readest" subtree under the user's
|
||||
* configured rootPath. The whole sync feature is scoped to this subtree so
|
||||
* we never touch unrelated files in the user's WebDAV.
|
||||
* Layout convention for the "Readest" subtree under the user's configured
|
||||
* rootPath, shared by every file-based sync provider (WebDAV today; Google
|
||||
* Drive / Dropbox / FTP / SFTP in future). The whole sync feature is scoped
|
||||
* to this subtree so we never touch unrelated files in the user's storage.
|
||||
*
|
||||
* Tree:
|
||||
* <rootPath>/
|
||||
@@ -19,14 +20,18 @@ import { makeSafeFilename } from '@/utils/misc';
|
||||
*
|
||||
* Why hash directories: avoids title collisions and makes title edits a
|
||||
* pure metadata operation (no remote rename). The friendly file name
|
||||
* inside the directory keeps the WebDAV client experience readable.
|
||||
* inside the directory keeps the remote browse experience readable.
|
||||
*
|
||||
* These builders are pure functions of `rootPath` — no transport knowledge.
|
||||
* The directory/file names below are a FROZEN wire layout: changing them
|
||||
* would orphan every existing remote tree, so they must stay byte-stable.
|
||||
*/
|
||||
|
||||
export const WEBDAV_BASE_DIR = 'Readest';
|
||||
export const WEBDAV_BOOKS_DIR = 'books';
|
||||
export const WEBDAV_LIBRARY_FILE = 'library.json';
|
||||
export const WEBDAV_BOOK_CONFIG_FILE = 'config.json';
|
||||
export const WEBDAV_BOOK_COVER_FILE = 'cover.png';
|
||||
export const SYNC_BASE_DIR = 'Readest';
|
||||
export const SYNC_BOOKS_DIR = 'books';
|
||||
export const SYNC_LIBRARY_FILE = 'library.json';
|
||||
export const SYNC_BOOK_CONFIG_FILE = 'config.json';
|
||||
export const SYNC_BOOK_COVER_FILE = 'cover.png';
|
||||
|
||||
/**
|
||||
* Normalise the user-entered rootPath so the rest of the code can rely on
|
||||
@@ -48,19 +53,19 @@ const join = (...parts: string[]): string => {
|
||||
|
||||
/** Absolute path of the Readest base directory (where library.json lives). */
|
||||
export const buildBasePath = (rootPath: string): string =>
|
||||
join(normalizeRoot(rootPath), WEBDAV_BASE_DIR);
|
||||
join(normalizeRoot(rootPath), SYNC_BASE_DIR);
|
||||
|
||||
/** Absolute path of the per-book directory keyed by hash. */
|
||||
export const buildBookDirPath = (rootPath: string, bookHash: string): string =>
|
||||
join(buildBasePath(rootPath), WEBDAV_BOOKS_DIR, bookHash);
|
||||
join(buildBasePath(rootPath), SYNC_BOOKS_DIR, bookHash);
|
||||
|
||||
/** Absolute path of a book's config.json (progress + booknotes). */
|
||||
export const buildBookConfigPath = (rootPath: string, bookHash: string): string =>
|
||||
join(buildBookDirPath(rootPath, bookHash), WEBDAV_BOOK_CONFIG_FILE);
|
||||
join(buildBookDirPath(rootPath, bookHash), SYNC_BOOK_CONFIG_FILE);
|
||||
|
||||
/** Absolute path of the shared library.json index. */
|
||||
export const buildLibraryPath = (rootPath: string): string =>
|
||||
join(buildBasePath(rootPath), WEBDAV_LIBRARY_FILE);
|
||||
join(buildBasePath(rootPath), SYNC_LIBRARY_FILE);
|
||||
|
||||
/**
|
||||
* Friendly book file name "<sanitized title>.<ext>" used inside the
|
||||
@@ -83,7 +88,7 @@ export const buildBookFilePath = (rootPath: string, book: Book): string =>
|
||||
|
||||
/** Absolute path of the book cover image. */
|
||||
export const buildBookCoverPath = (rootPath: string, bookHash: string): string =>
|
||||
join(buildBookDirPath(rootPath, bookHash), WEBDAV_BOOK_COVER_FILE);
|
||||
join(buildBookDirPath(rootPath, bookHash), SYNC_BOOK_COVER_FILE);
|
||||
|
||||
/**
|
||||
* Walk the parents of an absolute path, top-down, so callers can
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Book, BookConfig } from '@/types/book';
|
||||
|
||||
/** A buffered file payload + its byte length (for the HEAD-vs-local probe). */
|
||||
export interface BookBytes {
|
||||
bytes: ArrayBuffer;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* App-side local I/O the {@link FileSyncEngine} needs, abstracted away from
|
||||
* `appService` / Zustand stores so the engine stays testable with a plain
|
||||
* fake. One implementation — {@link createAppLocalStore} — is built once per
|
||||
* consumer (the reader hook and the library "Sync now" form) and shared, so
|
||||
* the buffered + streaming book/cover loaders live in exactly one place
|
||||
* instead of being copy-pasted across consumers.
|
||||
*
|
||||
* Split of responsibilities with {@link FileSyncProvider}:
|
||||
* - provider owns the REMOTE side (and the actual stream transport);
|
||||
* - localStore owns the LOCAL side (reading/writing files on this device,
|
||||
* resolving on-disk paths for streaming, and mutating the local library).
|
||||
*/
|
||||
export interface LocalStore {
|
||||
/** Load a book's local config (progress + booknotes), or null if none. */
|
||||
loadConfig(book: Book): Promise<BookConfig | null>;
|
||||
/** Persist a (merged) config to local disk. */
|
||||
saveBookConfig(book: Book, config: BookConfig): Promise<void>;
|
||||
|
||||
/** Buffered upload source: the book file bytes, or null when not on disk. */
|
||||
loadBookFile(book: Book): Promise<BookBytes | null>;
|
||||
/**
|
||||
* Streaming upload source: the absolute on-disk path + size of the book
|
||||
* file, or null when not on this device. Size lets the engine run the
|
||||
* HEAD-vs-local short-circuit without reading any bytes.
|
||||
*/
|
||||
resolveLocalBookPath(book: Book): Promise<{ path: string; size: number } | null>;
|
||||
/** Buffered download sink: write freshly-pulled book bytes to disk. */
|
||||
saveBookFile(book: Book, bytes: ArrayBuffer): Promise<void>;
|
||||
/**
|
||||
* Streaming download sink: ensure the per-book directory exists and return
|
||||
* the absolute destination path the provider should stream into.
|
||||
*/
|
||||
prepareLocalBookPath(book: Book): Promise<string>;
|
||||
|
||||
/** Buffered cover source, or null when the book has no local cover. */
|
||||
loadBookCover(book: Book): Promise<BookBytes | null>;
|
||||
/** Write a freshly-pulled cover image to disk. */
|
||||
saveBookCover(book: Book, bytes: ArrayBuffer): Promise<void>;
|
||||
|
||||
/** Insert a brand-new book row (no-op on an existing hash). */
|
||||
addBookToLibrary(book: Book): Promise<void>;
|
||||
/** Persist refreshed metadata for a book already in the local library. */
|
||||
updateBookMetadata(book: Book): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Book, BookConfig, BookNote } from '@/types/book';
|
||||
import { RemoteBookConfig } from './wire';
|
||||
|
||||
/**
|
||||
* Declarative merge policies for the file-sync engine. Each function is
|
||||
* pure (no I/O) and independently unit-tested with algebraic laws so the
|
||||
* convergence guarantees are explicit rather than implied by the
|
||||
* orchestration code:
|
||||
* - notes → element-set CRDT (union by id, per-note updatedAt,
|
||||
* deletedAt tombstones).
|
||||
* - config → last-writer-wins on `config.updatedAt` for scalars,
|
||||
* notes merged via the CRDT regardless of scalar winner.
|
||||
* - book meta → last-writer-wins on `book.updatedAt` over a fixed field
|
||||
* subset; device-local / on-disk fields always preserved.
|
||||
*
|
||||
* State-based CRDT semantics make this safe over a lossy single-file
|
||||
* transport: every replica holds full state and re-merges, so a blind PUT
|
||||
* of a merged superset converges even when intermediate writes are lost.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Per-note merge: pick the locally-stored copy or the remote copy of each
|
||||
* note based on `updatedAt` / `deletedAt`. Mirrors `processNewNote` in
|
||||
* `useNotesSync.ts` so users get the same semantics regardless of which
|
||||
* sync backend produced the row.
|
||||
*
|
||||
* A note is keyed by `id`. When the same id exists on both sides we keep
|
||||
* whichever side has the larger updatedAt; ties go to the side whose
|
||||
* `deletedAt` is more recent (which usually means the deletion came after
|
||||
* the creation/edit).
|
||||
*/
|
||||
export const mergeNotes = (local: BookNote[], remote: BookNote[]): BookNote[] => {
|
||||
const byId = new Map<string, BookNote>();
|
||||
for (const n of local) byId.set(n.id, n);
|
||||
for (const r of remote) {
|
||||
const l = byId.get(r.id);
|
||||
if (!l) {
|
||||
byId.set(r.id, r);
|
||||
continue;
|
||||
}
|
||||
const lUpdated = l.updatedAt ?? 0;
|
||||
const rUpdated = r.updatedAt ?? 0;
|
||||
const lDeleted = l.deletedAt ?? 0;
|
||||
const rDeleted = r.deletedAt ?? 0;
|
||||
if (rUpdated > lUpdated || rDeleted > lDeleted) {
|
||||
byId.set(r.id, { ...l, ...r });
|
||||
} else {
|
||||
byId.set(r.id, { ...r, ...l });
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge a remote config envelope into the local BookConfig.
|
||||
*
|
||||
* Scalars use a per-config `updatedAt` LWW (same as the native cloud sync
|
||||
* in `useProgressSync.applyRemoteProgress`); booknotes always merge via the
|
||||
* element-set CRDT regardless of which side won the scalar race. Null /
|
||||
* undefined remote fields are dropped before the spread so a server can
|
||||
* never inject keys the wire envelope isn't supposed to carry (viewSettings,
|
||||
* searchConfig, RSVP) — those never appear in `remote.config` because
|
||||
* `buildRemotePayload` strips them on push.
|
||||
*
|
||||
* Returns both the merged config (with `booknotes` populated) and the merged
|
||||
* notes separately so callers can drive a live view off the note set.
|
||||
*/
|
||||
export const mergeBookConfig = (
|
||||
local: BookConfig,
|
||||
remote: RemoteBookConfig,
|
||||
): { config: BookConfig; notes: BookNote[] } => {
|
||||
const remoteConfigUpdated = remote.config.updatedAt ?? remote.updatedAt;
|
||||
const localConfigUpdated = local.updatedAt ?? 0;
|
||||
const filteredRemote = Object.fromEntries(
|
||||
Object.entries(remote.config).filter(([, v]) => v !== null && v !== undefined),
|
||||
) as Partial<BookConfig>;
|
||||
const merged: BookConfig =
|
||||
remoteConfigUpdated >= localConfigUpdated
|
||||
? ({ ...local, ...filteredRemote } as BookConfig)
|
||||
: ({ ...filteredRemote, ...local } as BookConfig);
|
||||
const notes = mergeNotes(local.booknotes ?? [], remote.booknotes ?? []);
|
||||
merged.booknotes = notes;
|
||||
return { config: merged, notes };
|
||||
};
|
||||
|
||||
/**
|
||||
* Overlay the user-facing metadata of `remote` onto `local`, preserving every
|
||||
* device-local / file-system field: `filePath`, `sourceTitle` (which names the
|
||||
* on-disk file), `coverImageUrl` (a device-local blob URL the caller
|
||||
* regenerates), reading progress, reading status, group membership, `hash`,
|
||||
* `format`, `createdAt`, etc.
|
||||
*
|
||||
* Only the fields a metadata edit actually changes travel — this list mirrors
|
||||
* `getBookWithUpdatedMetadata` in `utils/book.ts`, which is the local side of
|
||||
* the same operation. The cover image is replicated separately as cover.png
|
||||
* bytes (see the reconciliation pass in the engine), so it is intentionally
|
||||
* absent here.
|
||||
*/
|
||||
export const mergeBookMetadata = (local: Book, remote: Book): Book => ({
|
||||
...local,
|
||||
title: remote.title,
|
||||
author: remote.author,
|
||||
metadata: remote.metadata ?? local.metadata,
|
||||
primaryLanguage: remote.primaryLanguage ?? local.primaryLanguage,
|
||||
updatedAt: remote.updatedAt,
|
||||
});
|
||||
|
||||
/**
|
||||
* LWW predicate for the library-index metadata reconciliation: true when the
|
||||
* remote indexed copy is strictly newer than the local one and neither side
|
||||
* is tombstoned. A strict `>` keeps the pass a no-op when timestamps match so
|
||||
* we never re-apply identical metadata or bounce updates between devices.
|
||||
*/
|
||||
export const isRemoteBookMetadataNewer = (local: Book, remote: Book): boolean =>
|
||||
!remote.deletedAt && !local.deletedAt && (remote.updatedAt ?? 0) > (local.updatedAt ?? 0);
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Transport abstraction for a file-based sync backend.
|
||||
*
|
||||
* A `FileSyncProvider` exposes the minimal set of remote file operations the
|
||||
* {@link FileSyncEngine} needs; everything above this line (layout, wire
|
||||
* envelopes, merge policy, orchestration) is provider-agnostic. Implementing
|
||||
* a new backend (Google Drive / Dropbox / FTP / SFTP) means writing one of
|
||||
* these — ideally validated against the shared provider-conformance test
|
||||
* suite — and nothing else.
|
||||
*
|
||||
* Error contract: read/head operations return `null` for a missing path
|
||||
* (HTTP 404 and equivalents); every other failure throws a
|
||||
* {@link FileSyncError} carrying a normalised `code` so the engine can branch
|
||||
* on auth / not-found / network / conflict without knowing the backend.
|
||||
*/
|
||||
|
||||
export type FileSyncErrorCode = 'AUTH_FAILED' | 'NOT_FOUND' | 'NETWORK' | 'CONFLICT' | 'UNKNOWN';
|
||||
|
||||
export class FileSyncError extends Error {
|
||||
code: FileSyncErrorCode;
|
||||
/** HTTP status when the request reached the server, if applicable. */
|
||||
status?: number;
|
||||
|
||||
constructor(message: string, code: FileSyncErrorCode = 'UNKNOWN', status?: number) {
|
||||
super(message);
|
||||
this.name = 'FileSyncError';
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** A single directory entry returned by {@link FileSyncProvider.list}. */
|
||||
export interface FileEntry {
|
||||
/** File or directory name (single decoded path segment). */
|
||||
name: string;
|
||||
/** Absolute path on the backend, leading slash, decoded. */
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
/** Content length in bytes when the backend reports it (files only). */
|
||||
size?: number;
|
||||
/** Backend-provided modification timestamp, if any. */
|
||||
lastModified?: string;
|
||||
}
|
||||
|
||||
/** Metadata returned by a HEAD-style probe. */
|
||||
export interface FileHead {
|
||||
size?: number;
|
||||
etag?: string;
|
||||
}
|
||||
|
||||
export interface FileSyncProvider {
|
||||
/** Normalised root path: leading '/', no trailing slash (root === '/'). */
|
||||
readonly rootPath: string;
|
||||
|
||||
/** GET text. Resolves `null` when the path doesn't exist (404). */
|
||||
readText(path: string): Promise<string | null>;
|
||||
/** GET binary. Resolves `null` when the path doesn't exist (404). */
|
||||
readBinary(path: string): Promise<ArrayBuffer | null>;
|
||||
/** HEAD probe. Resolves `null` when the path doesn't exist (404). */
|
||||
head(path: string): Promise<FileHead | null>;
|
||||
/** List immediate children of a directory. Throws on non-2xx. */
|
||||
list(path: string): Promise<FileEntry[]>;
|
||||
|
||||
/** PUT text. Parent directories must already exist (see {@link ensureDir}). */
|
||||
writeText(path: string, body: string, contentType?: string): Promise<void>;
|
||||
/** PUT binary. Parent directories must already exist. */
|
||||
writeBinary(path: string, body: ArrayBuffer, contentType?: string): Promise<void>;
|
||||
|
||||
/** Create each directory in `paths` (top-down). Idempotent. */
|
||||
ensureDir(paths: string[]): Promise<void>;
|
||||
/** Recursively delete a directory subtree. A missing dir is success. */
|
||||
deleteDir(path: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Optional streaming upload: PUT `localPath`'s bytes to `remotePath`
|
||||
* without materialising them in the JS heap. The provider owns the
|
||||
* remote URL + auth. Resolves `true` on success, `false` on a swallowed
|
||||
* failure. Absent on backends without a streaming primitive (e.g. web);
|
||||
* the engine then falls back to buffered {@link writeBinary}.
|
||||
*/
|
||||
uploadStream?(remotePath: string, localPath: string): Promise<boolean>;
|
||||
/**
|
||||
* Optional streaming download: GET `remotePath` straight to `localPath`.
|
||||
* Same ownership + fallback rules as {@link uploadStream}.
|
||||
*/
|
||||
downloadStream?(remotePath: string, localPath: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Book, BookConfig, BookNote } from '@/types/book';
|
||||
|
||||
/**
|
||||
* Per-book remote payload stored at
|
||||
* <rootPath>/Readest/books/<hash>/config.json
|
||||
*
|
||||
* The wire format is a thin envelope around the existing local
|
||||
* `BookConfig` so the merge logic can stay identical to readest's other
|
||||
* sync providers (per-field updatedAt LWW for top-level keys, per-note
|
||||
* updatedAt + deletedAt for booknotes). The envelope adds the bare
|
||||
* minimum "who/when last wrote this" metadata so clients can detect
|
||||
* cross-device clobbers and surface them in diagnostics.
|
||||
*
|
||||
* FROZEN: `schemaVersion`, `writerVersion`, and the trimmed-config field
|
||||
* set are part of the on-wire contract. A refactored client and an old
|
||||
* client must interoperate byte-for-byte, so do not rename or re-shape
|
||||
* these fields. `writerVersion` keeps its historical `'readest-webdav-1'`
|
||||
* value even though the engine is now provider-agnostic — it is an opaque
|
||||
* tag, never branched on, and changing it buys nothing.
|
||||
*/
|
||||
export interface RemoteBookConfig {
|
||||
schemaVersion: 1;
|
||||
bookHash: string;
|
||||
metaHash?: string;
|
||||
/** Trimmed BookConfig — only the keys we care about syncing. */
|
||||
config: Partial<BookConfig>;
|
||||
/** Booknotes carry their own per-note updatedAt/deletedAt for merging. */
|
||||
booknotes: BookNote[];
|
||||
writerDeviceId: string;
|
||||
writerVersion: 'readest-webdav-1';
|
||||
/** When the writer last touched the row (client wall clock, millis). */
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the live local BookConfig into the wire envelope. We deliberately
|
||||
* drop transient view state (search config, RSVP position, viewSettings,
|
||||
* etc.) — those are device-local UI preferences, not progress.
|
||||
*
|
||||
* Why viewSettings stays local even though it lives in BookConfig:
|
||||
* - Different devices have different screen sizes / DPI / typography
|
||||
* preferences. Pushing a phone's 14pt setting onto a desktop would
|
||||
* surprise users in a bad way.
|
||||
* - readest's own cloud sync similarly carves out viewSettings from
|
||||
* cross-device replication. Duplicating that policy here keeps the
|
||||
* backends behaviourally aligned.
|
||||
* - The trim list below is the SOURCE OF TRUTH for what travels —
|
||||
* when adding a new BookConfig field, decide here whether it's a
|
||||
* "reading state" (include) or a "device preference" (skip).
|
||||
*
|
||||
* Anything not in `trimmed` therefore never reaches the server, and
|
||||
* conversely the config merge (see `mergeBookConfig` in merge.ts) only
|
||||
* ever merges fields the server actually carries — so a malicious or
|
||||
* buggy server can't somehow inject viewSettings into a local config.
|
||||
*/
|
||||
export const buildRemotePayload = (
|
||||
book: Book,
|
||||
config: BookConfig,
|
||||
deviceId: string,
|
||||
): RemoteBookConfig => {
|
||||
const trimmed: Partial<BookConfig> = {
|
||||
progress: config.progress,
|
||||
location: config.location,
|
||||
xpointer: config.xpointer,
|
||||
updatedAt: config.updatedAt,
|
||||
};
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
bookHash: book.hash,
|
||||
metaHash: book.metaHash,
|
||||
config: trimmed,
|
||||
booknotes: config.booknotes ?? [],
|
||||
writerDeviceId: deviceId,
|
||||
writerVersion: 'readest-webdav-1',
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
};
|
||||
|
||||
export const parseRemotePayload = (raw: string | null): RemoteBookConfig | null => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as RemoteBookConfig;
|
||||
if (!parsed || parsed.schemaVersion !== 1) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The shared `<rootPath>/Readest/library.json` index. Membership is a
|
||||
* union-by-hash CRDT (with `deletedAt` tombstones); per-book metadata is
|
||||
* last-writer-wins on `book.updatedAt`. See merge.ts for the policies.
|
||||
*/
|
||||
export interface RemoteLibraryIndex {
|
||||
schemaVersion: 1;
|
||||
books: Book[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const parseRemoteLibraryIndex = (raw: string | null): RemoteLibraryIndex | null => {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as RemoteLibraryIndex;
|
||||
if (parsed && parsed.schemaVersion === 1) return parsed;
|
||||
} catch {
|
||||
// Ignore parse errors — a malformed index is treated as "no index".
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
import { WebDAVSettings } from '@/types/settings';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { tauriDownload, tauriUpload } from '@/utils/transfer';
|
||||
import {
|
||||
FileEntry,
|
||||
FileHead,
|
||||
FileSyncError,
|
||||
FileSyncErrorCode,
|
||||
FileSyncProvider,
|
||||
} from '@/services/sync/file/provider';
|
||||
import {
|
||||
WebDAVConfig,
|
||||
WebDAVRequestError,
|
||||
buildBasicAuthHeader,
|
||||
buildRequestUrl,
|
||||
deleteDirectory,
|
||||
ensureDirectory,
|
||||
getFile,
|
||||
getFileBinary,
|
||||
headFile,
|
||||
listDirectory,
|
||||
normalizeRootPath,
|
||||
putFile,
|
||||
putFileBinary,
|
||||
} from './client';
|
||||
|
||||
/**
|
||||
* WebDAV implementation of {@link FileSyncProvider} — the first concrete
|
||||
* backend for the provider-agnostic file-sync engine.
|
||||
*
|
||||
* Responsibilities unique to this layer:
|
||||
* - own the WebDAV transport config (serverUrl + Basic-auth credentials);
|
||||
* - translate the transport client's {@link WebDAVRequestError} into the
|
||||
* engine's neutral {@link FileSyncError} so nothing above this file
|
||||
* knows the backend is WebDAV;
|
||||
* - own streaming upload/download (URL + auth + the Tauri-side
|
||||
* `tauriUpload`/`tauriDownload`), keeping gigabyte-scale book payloads out
|
||||
* of the JS heap. Streaming is exposed only on Tauri; on web the engine
|
||||
* falls back to buffered {@link FileSyncProvider.writeBinary}/`readBinary`.
|
||||
*/
|
||||
|
||||
const mapError = (e: unknown): FileSyncError => {
|
||||
if (e instanceof FileSyncError) return e;
|
||||
if (e instanceof WebDAVRequestError) {
|
||||
const code: FileSyncErrorCode =
|
||||
e.code === 'AUTH_FAILED'
|
||||
? 'AUTH_FAILED'
|
||||
: e.code === 'NOT_FOUND'
|
||||
? 'NOT_FOUND'
|
||||
: e.code === 'NETWORK'
|
||||
? 'NETWORK'
|
||||
: e.status === 409
|
||||
? 'CONFLICT'
|
||||
: 'UNKNOWN';
|
||||
return new FileSyncError(e.message, code, e.status);
|
||||
}
|
||||
return new FileSyncError(e instanceof Error ? e.message : String(e), 'UNKNOWN');
|
||||
};
|
||||
|
||||
const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
throw mapError(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebDAVProvider = (settings: WebDAVSettings): FileSyncProvider => {
|
||||
const config: WebDAVConfig = {
|
||||
serverUrl: settings.serverUrl,
|
||||
username: settings.username,
|
||||
password: settings.password,
|
||||
};
|
||||
|
||||
const provider: FileSyncProvider = {
|
||||
rootPath: normalizeRootPath(settings.rootPath),
|
||||
readText: (path) => wrap(() => getFile(config, path)),
|
||||
readBinary: (path) => wrap(() => getFileBinary(config, path)),
|
||||
head: (path): Promise<FileHead | null> => wrap(() => headFile(config, path)),
|
||||
list: (path): Promise<FileEntry[]> => wrap(() => listDirectory(config, path)),
|
||||
writeText: (path, body, contentType) => wrap(() => putFile(config, path, body, contentType)),
|
||||
writeBinary: (path, body, contentType) =>
|
||||
wrap(() => putFileBinary(config, path, body, contentType)),
|
||||
ensureDir: (paths) => wrap(() => ensureDirectory(config, paths)),
|
||||
deleteDir: (path) => wrap(() => deleteDirectory(config, path)),
|
||||
};
|
||||
|
||||
if (isTauriAppPlatform()) {
|
||||
const authHeaders = (): Record<string, string> => ({
|
||||
Authorization: buildBasicAuthHeader(settings.username, settings.password),
|
||||
});
|
||||
provider.uploadStream = async (remotePath, localPath) => {
|
||||
const url = buildRequestUrl(settings.serverUrl, remotePath);
|
||||
try {
|
||||
// tauriUpload's TS type says Map, but the Rust command accepts a JSON
|
||||
// object → HashMap<String, String>; pass the headers object directly.
|
||||
await tauriUpload(
|
||||
url,
|
||||
localPath,
|
||||
'PUT',
|
||||
undefined,
|
||||
authHeaders() as unknown as Map<string, string>,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('WebDAVProvider.uploadStream failed', remotePath, e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
provider.downloadStream = async (remotePath, localPath) => {
|
||||
const url = buildRequestUrl(settings.serverUrl, remotePath);
|
||||
try {
|
||||
await tauriDownload(url, localPath, undefined, authHeaders());
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('WebDAVProvider.downloadStream failed', remotePath, e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return provider;
|
||||
};
|
||||
+25
-11
@@ -1,5 +1,5 @@
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
import { isTauriAppPlatform } from '../environment';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
/**
|
||||
* Minimal WebDAV client used by the Integrations panel.
|
||||
@@ -299,17 +299,31 @@ export const listDirectory = async (
|
||||
const root = normalizeRootPath(rootPath);
|
||||
const url = buildUrl(config.serverUrl, root);
|
||||
const fetchFn = getFetch();
|
||||
const response = await fetchFn(url, {
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
Authorization: buildAuthHeader(config.username, config.password),
|
||||
Depth: '1',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
},
|
||||
body: PROPFIND_BODY,
|
||||
});
|
||||
// Throw the same WebDAVRequestError taxonomy as the file-level helpers so the
|
||||
// provider layer can map list() failures to FileSyncError codes (auth /
|
||||
// not-found / network) instead of flattening every failure to UNKNOWN.
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchFn(url, {
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
Authorization: buildAuthHeader(config.username, config.password),
|
||||
Depth: '1',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
},
|
||||
body: PROPFIND_BODY,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new WebDAVRequestError((e as Error).message || 'Network error', undefined, 'NETWORK');
|
||||
}
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new WebDAVRequestError('Authentication failed', response.status, 'AUTH_FAILED');
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new WebDAVRequestError('Directory not found', response.status, 'NOT_FOUND');
|
||||
}
|
||||
if (response.status !== 207 && response.status !== 200) {
|
||||
throw new Error(`PROPFIND failed with status ${response.status}`);
|
||||
throw new WebDAVRequestError(`PROPFIND failed with status ${response.status}`, response.status);
|
||||
}
|
||||
const xml = await response.text();
|
||||
let serverOrigin = '';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,6 +117,10 @@ export interface WebDAVSettings {
|
||||
syncProgress?: boolean;
|
||||
syncNotes?: boolean;
|
||||
syncBooks?: boolean;
|
||||
// When true, "Sync now" re-checks every book instead of only those whose
|
||||
// local copy differs from the shared library.json index (the default
|
||||
// incremental walk). An escape hatch for drift or a first full sync.
|
||||
fullSync?: boolean;
|
||||
// Conflict policy — same vocabulary as KOSync so users only learn one.
|
||||
strategy?: KOSyncStrategy;
|
||||
// Stable per-device id (uuidv4); written into library.json so we can tell
|
||||
|
||||
Reference in New Issue
Block a user