diff --git a/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts b/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts new file mode 100644 index 00000000..4e33388c --- /dev/null +++ b/apps/readest-app/src/__tests__/services/webdav-connect-settings.test.ts @@ -0,0 +1,107 @@ +import { describe, test, expect } from 'vitest'; +import { buildWebDAVConnectSettings } from '@/services/webdav/webdavConnectSettings'; +import type { WebDAVSettings, WebDAVSyncLogEntry } from '@/types/settings'; + +describe('buildWebDAVConnectSettings', () => { + test('applies form fields onto a blank previous state', () => { + const result = buildWebDAVConnectSettings(undefined, { + serverUrl: ' https://dav.example.com ', + username: 'alice', + password: 'hunter2', + rootPath: '/Readest', + }); + expect(result).toEqual({ + enabled: true, + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'hunter2', + rootPath: '/Readest', + }); + }); + + test('preserves prior bookkeeping fields across reconnect', () => { + // Simulates the disconnect → reconnect flow: the user previously + // synced (deviceId minted, syncBooks toggled on, history populated), + // disabled WebDAV, and is now reconnecting with the same credentials. + const log: WebDAVSyncLogEntry[] = [ + { + id: 'log-1', + startedAt: 1_700_000_000_000, + finishedAt: 1_700_000_001_500, + status: 'success', + trigger: 'manual', + totalBooks: 3, + booksDownloaded: 0, + filesUploaded: 1, + filesAlreadyInSync: 2, + configsUploaded: 3, + configsDownloaded: 0, + coversUploaded: 0, + failures: 0, + summary: 'Sync complete', + }, + ]; + const previous: WebDAVSettings = { + enabled: false, + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'hunter2', + rootPath: '/Readest', + syncProgress: true, + syncNotes: true, + syncBooks: true, + strategy: 'send', + deviceId: 'device-uuid-9f3c', + lastSyncedAt: 1_700_000_001_500, + syncLog: log, + }; + + const next = buildWebDAVConnectSettings(previous, { + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'hunter2', + rootPath: '/Readest', + }); + + expect(next.enabled).toBe(true); + // Stable per-device id MUST survive — losing it makes the next sync + // look like a brand-new device and breaks cross-device clobber + // detection in `RemoteBookConfig.writerDeviceId`. + expect(next.deviceId).toBe('device-uuid-9f3c'); + expect(next.syncBooks).toBe(true); + expect(next.strategy).toBe('send'); + expect(next.syncProgress).toBe(true); + expect(next.syncNotes).toBe(true); + expect(next.lastSyncedAt).toBe(1_700_000_001_500); + expect(next.syncLog).toEqual(log); + }); + + test('updates the credentials when the user reconnects to a different account', () => { + const previous: WebDAVSettings = { + enabled: false, + serverUrl: 'https://old.example.com', + username: 'alice', + password: 'old-pw', + rootPath: '/Old', + deviceId: 'device-keep', + syncBooks: false, + }; + const next = buildWebDAVConnectSettings(previous, { + serverUrl: 'https://new.example.com/', + username: 'bob', + password: 'new-pw', + rootPath: '/New', + }); + expect(next.serverUrl).toBe('https://new.example.com/'); + expect(next.username).toBe('bob'); + expect(next.password).toBe('new-pw'); + expect(next.rootPath).toBe('/New'); + // The deviceId is intentionally NOT rotated even when the user + // reconnects to a different server/account: it identifies the + // physical device, not the remote account. A user moving between + // self-hosted instances still wants their device to be recognised + // by whichever server it's currently talking to. + expect(next.deviceId).toBe('device-keep'); + expect(next.syncBooks).toBe(false); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/webdav-delete.test.ts b/apps/readest-app/src/__tests__/services/webdav-delete.test.ts new file mode 100644 index 00000000..a94d4a9e --- /dev/null +++ b/apps/readest-app/src/__tests__/services/webdav-delete.test.ts @@ -0,0 +1,170 @@ +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 type { WebDAVSettings } from '@/types/settings'; + +/** + * Tests for the cleanup-mode delete plumbing — both the low-level + * `deleteDirectory` HTTP wrapper in WebDAVClient and the high-level + * `deleteRemoteBookDir` orchestrator in WebDAVSync. + * + * The two layers are tested side by side because their contracts + * are tightly coupled (one returns void / throws, the other adapts + * that into a discriminated result for batch aggregation), and a + * single fetch mock replays naturally for both. + */ + +const ORIGINAL_FETCH = globalThis.fetch; + +const config: WebDAVConfig = { + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'secret', +}; + +const settings: WebDAVSettings = { + enabled: true, + serverUrl: 'https://dav.example.com', + username: 'alice', + password: 'secret', + // `/` is the canonical root used by every other test; matches what + // `normalizeRoot` would do to an unset rootPath, so the assertions + // below can hard-code "/Readest/books/". + rootPath: '/', +}; + +const buildResponse = (status: number): Response => + // 204 No Content is the typical success for DELETE; we set a body + // anyway so any future caller that tries to read it doesn't blow + // up. JSDOM's Response constructor refuses 204+body, so we tag it + // as 200 internally and override the status reporter — but it's + // simpler to just use `new Response(null, { status })` for the + // bodyless cases and `new Response('', { status })` otherwise. + new Response(status === 204 ? null : '', { status }); + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = ORIGINAL_FETCH; + vi.restoreAllMocks(); +}); + +describe('deleteDirectory', () => { + test('204 No Content resolves without throwing', async () => { + fetchMock.mockResolvedValueOnce(buildResponse(204)); + await expect(deleteDirectory(config, '/Readest/books/abc')).resolves.toBeUndefined(); + }); + + test('200 OK resolves without throwing', async () => { + fetchMock.mockResolvedValueOnce(buildResponse(200)); + await expect(deleteDirectory(config, '/Readest/books/abc')).resolves.toBeUndefined(); + }); + + test('404 Not Found is treated as success (already gone)', async () => { + fetchMock.mockResolvedValueOnce(buildResponse(404)); + await expect(deleteDirectory(config, '/Readest/books/missing')).resolves.toBeUndefined(); + }); + + test('401 Unauthorized throws AUTH_FAILED', async () => { + fetchMock.mockResolvedValueOnce(buildResponse(401)); + await expect(deleteDirectory(config, '/Readest/books/abc')).rejects.toMatchObject({ + code: 'AUTH_FAILED', + status: 401, + }); + }); + + test('403 Forbidden throws AUTH_FAILED', async () => { + fetchMock.mockResolvedValueOnce(buildResponse(403)); + await expect(deleteDirectory(config, '/Readest/books/abc')).rejects.toMatchObject({ + code: 'AUTH_FAILED', + status: 403, + }); + }); + + test('500 Internal Server Error throws WebDAVRequestError with the status', async () => { + fetchMock.mockResolvedValueOnce(buildResponse(500)); + await expect(deleteDirectory(config, '/Readest/books/abc')).rejects.toMatchObject({ + status: 500, + }); + }); + + test('fetch network failure surfaces as a NETWORK error', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')); + await expect(deleteDirectory(config, '/Readest/books/abc')).rejects.toMatchObject({ + code: 'NETWORK', + }); + }); + + test('sends DELETE with explicit Depth: infinity header', async () => { + // Depth: infinity is required by RFC 4918 §9.6.1 for collection + // deletes. Some servers reject the implicit form, so this is a + // load-bearing piece of the request — guard it explicitly so a + // future refactor can't silently drop the header and still pass + // the rest of the suite. + fetchMock.mockResolvedValueOnce(buildResponse(204)); + await deleteDirectory(config, '/Readest/books/abc'); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://dav.example.com/Readest/books/abc'); + expect(init?.method).toBe('DELETE'); + const headers = init?.headers as Record; + expect(headers['Depth']).toBe('infinity'); + // Authorization is added by the same plumbing as every other + // method; verify it's attached so AUTH_FAILED tests above can + // never accidentally test the unauthenticated path. + expect(headers['Authorization']).toMatch(/^Basic /); + }); +}); + +describe('deleteRemoteBookDir', () => { + const HASH = 'abc123'; + + test('successful DELETE returns ok=true', async () => { + fetchMock.mockResolvedValueOnce(buildResponse(204)); + await expect(deleteRemoteBookDir(settings, 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); + 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 + // string so the toast has something to show. + expect(result.reason).toBeTruthy(); + expect(typeof result.reason).toBe('string'); + }); + + test('AUTH_FAILED is rethrown so callers can short-circuit batches', async () => { + fetchMock.mockResolvedValueOnce(buildResponse(401)); + await expect(deleteRemoteBookDir(settings, HASH)).rejects.toBeInstanceOf(WebDAVRequestError); + // 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({ + code: 'AUTH_FAILED', + }); + }); + + test('targets the correct per-hash directory under /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). + fetchMock.mockResolvedValueOnce(buildResponse(204)); + await deleteRemoteBookDir({ ...settings, rootPath: '/MyDav' }, HASH); + const [url] = fetchMock.mock.calls[0]!; + expect(url).toBe('https://dav.example.com/MyDav/Readest/books/abc123'); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/webdav-encode-path.test.ts b/apps/readest-app/src/__tests__/services/webdav-encode-path.test.ts new file mode 100644 index 00000000..e9a90bf5 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/webdav-encode-path.test.ts @@ -0,0 +1,33 @@ +import { describe, test, expect } from 'vitest'; +import { buildRequestUrl } from '@/services/webdav/WebDAVClient'; + +describe('buildRequestUrl (encodePath)', () => { + test('escapes spaces and unicode in each segment', () => { + expect(buildRequestUrl('https://dav.example.com', '/Readest/My Books/小说.epub')).toBe( + 'https://dav.example.com/Readest/My%20Books/%E5%B0%8F%E8%AF%B4.epub', + ); + }); + + test("preserves existing %-escapes — the comment's promise that we don't double-encode", () => { + // Caller pre-encoded the literal space; encodePath used to turn the + // %20 into %2520, silently breaking the request URL for any path + // that came in already escaped. + expect(buildRequestUrl('https://dav.example.com', '/Readest/My%20Books/file.epub')).toBe( + 'https://dav.example.com/Readest/My%20Books/file.epub', + ); + }); + + test('mixes raw and pre-escaped characters in the same segment', () => { + // `a b%20c d` → the `b%20c` triplet survives, the bare spaces around + // it get escaped, and the segment as a whole stays roundtrip-clean. + expect(buildRequestUrl('https://dav.example.com', '/dir/a b%20c d/file')).toBe( + 'https://dav.example.com/dir/a%20b%20c%20d/file', + ); + }); + + test('keeps the path separator unchanged and trims server trailing slash', () => { + expect(buildRequestUrl('https://dav.example.com/', '/a/b/c.txt')).toBe( + 'https://dav.example.com/a/b/c.txt', + ); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 59418d56..a11181b4 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -23,6 +23,7 @@ import { useAutoFocus } from '@/hooks/useAutoFocus'; import { useTranslation } from '@/hooks/useTranslation'; import { useEinkMode } from '@/hooks/useEinkMode'; import { useKOSync } from '../hooks/useKOSync'; +import { useWebDAVSync } from '../hooks/useWebDAVSync'; import { applyFixedlayoutStyles, applyImageStyle, @@ -131,6 +132,7 @@ const FoliateViewer: React.FC<{ useProgressAutoSave(bookKey); useBookCoverAutoSave(bookKey); const { syncState, conflictDetails, resolveWithLocal, resolveWithRemote } = useKOSync(bookKey); + useWebDAVSync(bookKey); useTextTranslation(bookKey, viewRef.current); const progressRelocateHandler = (event: Event) => { diff --git a/apps/readest-app/src/app/reader/hooks/useWebDAVSync.ts b/apps/readest-app/src/app/reader/hooks/useWebDAVSync.ts new file mode 100644 index 00000000..cc46e121 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useWebDAVSync.ts @@ -0,0 +1,545 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { useEnv } from '@/context/EnvContext'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useReaderStore } from '@/store/readerStore'; +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 { removeBookNoteOverlays } from '../utils/annotatorUtil'; +import { useWindowActiveChanged } from './useWindowActiveChanged'; + +/** + * WebDAV per-book sync hook. + * + * Mirrors the architecture of `useKOSync` / `useProgressSync`: a single + * Reader-level hook drives both progress and booknote sync against + * `/Readest/books//config.json`. Pull-once on book open, + * debounced push on progress / booknote changes, manual flush on + * `flush-webdav-sync` event. + * + * Energy budget — these constants are deliberately tuned for mobile: + * - Push debounce: 15 s. Real reading sessions involve continuous + * page-turns, so a longer window collapses many turns into one PUT. + * - Pull cooldown: 60 s. Window focus shouldn't trigger a fresh PROPFIND + * on every alt-tab; once a minute is plenty for cross-device drift. + * - Open-pull skip: 30 s. Quickly closing/reopening a book shouldn't + * re-fetch the same config that's already current in memory. + * + * Gating: + * - `settings.webdav.enabled` must be true (master switch on the WebDAV + * sub-page in Integrations) + * - `settings.webdav.serverUrl` and `settings.webdav.username` must be + * non-empty (the Connect flow guarantees this when enabled is true, + * but defensive check is cheap) + * + * Strategy semantics — same vocabulary as KOSync so users only learn one: + * - 'silent' (default): always push and always pull, latest writer wins + * - 'send': push only, never pull (this device feeds others) + * - 'receive': pull only, never push (this device follows others) + * - 'prompt': not implemented in v1 — falls back to 'silent' + */ + +/** Debounce window for auto-push triggered by progress / booknote churn. */ +const PUSH_DEBOUNCE_MS = 15_000; +/** Minimum gap between automatic pulls (e.g. window-focus, open-book). */ +const PULL_COOLDOWN_MS = 60_000; +/** + * If this hook ran a successful pull less than this long ago for the + * current book, skip the open-book pull entirely. + * + * Note: `lastPulledAtRef` is component-instance state, so closing the + * reader unmounts the hook and resets the ref. A real "close-then- + * reopen" therefore *doesn't* trigger this guard — the new instance + * starts at 0 and proceeds to pull. The skip only fires when the + * open-book effect re-runs within a single hook lifetime (e.g. + * navigating between two books in the same reader window, or + * progress arriving in two ticks before `hasPulledOnce` is set), + * which is the common case we actually want to deduplicate. + */ +const OPEN_PULL_SKIP_MS = 30_000; + +export const useWebDAVSync = (bookKey: string) => { + const _ = useTranslation(); + const { envConfig, appService } = useEnv(); + const { settings, setSettings, saveSettings } = useSettingsStore(); + const { getProgress, getViewsById, getView } = useReaderStore(); + const { getConfig, setConfig, getBookData, saveConfig } = useBookDataStore(); + const progress = getProgress(bookKey); + + /** + * `dirtyRef` flips to true on the first locally-driven change after a + * successful push, and back to false right before each push fires. We + * use it to skip no-op flushes (e.g., user just opens then closes a + * book without reading) so mobile doesn't burn a PUT for no reason. + */ + const dirtyRef = useRef(false); + /** Last successful pull timestamp; gates window-focus and open-book pulls. */ + const lastPulledAtRef = useRef(0); + const hasPulledOnce = useRef(false); + /** + * Per-instance lock for the book-file uploader. Once we've HEAD-probed + * (and possibly uploaded) the binary for this book in this hook + * lifetime, we never re-do it — the file's content is hash-keyed so + * the only thing that could change is the friendly filename, which is + * a metadata-only operation handled elsewhere. + */ + const fileSyncedRef = useRef(false); + + // The deviceId is generated lazily on first push so users who never + // enable WebDAV don't carry it around. + // + // Read latest settings from the store rather than the closure for the + // same reason `updateLastSyncedAt` does: `pullNow → pushNow` can fire + // back-to-back when a book opens, and the closure's `settings` may + // not reflect a sibling write that just landed (e.g. the settings + // panel flipping `syncBooks`). A closure-based merge here would + // rebuild the webdav block from a stale snapshot and silently + // clobber that write. + const ensureDeviceId = useCallback((): string => { + const latest = useSettingsStore.getState().settings; + let id = latest.webdav?.deviceId; + if (!id) { + id = uuidv4(); + const next = { ...latest, webdav: { ...latest.webdav, deviceId: id } }; + setSettings(next); + saveSettings(envConfig, next); + } + return id; + }, [envConfig, setSettings, saveSettings]); + + const updateLastSyncedAt = useCallback( + async (ts: number) => { + // Read the latest settings from the store rather than the + // closure: pullNow → pushNow → pushBookFileNow can fire + // back-to-back when a book opens, and the closure's `settings` + // doesn't reflect interim writes by the prior call. Using the + // closure here would let a second `updateLastSyncedAt` rebuild + // the webdav object from a stale snapshot, clobbering whatever + // the first call (or a sibling write like `syncLog` from the + // settings panel) just committed. + const latest = useSettingsStore.getState().settings; + const next = { ...latest, webdav: { ...latest.webdav, lastSyncedAt: ts } }; + setSettings(next); + await saveSettings(envConfig, next); + }, + [envConfig, setSettings, saveSettings], + ); + + const isReady = useMemo(() => { + const w = settings.webdav; + return !!(w?.enabled && w?.serverUrl && w?.username); + }, [settings.webdav]); + + const strategy = settings.webdav?.strategy ?? 'silent'; + const allowPush = isReady && strategy !== 'receive'; + const allowPull = isReady && strategy !== 'send'; + + /** + * Push the latest config (progress + booknotes) to the remote. + * Skips while the user is previewing a deep-link target — the in-memory + * position there reflects the annotation, not actual reading. + */ + const pushNow = useCallback(async () => { + if (!allowPush) return; + if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return; + // Default-on semantics for older settings.json files that predate + // these keys (undefined in storage → opt in, not opt out). + const wantProgress = settings.webdav?.syncProgress ?? true; + const wantNotes = settings.webdav?.syncNotes ?? true; + if (!wantProgress && !wantNotes) return; + + const config = getConfig(bookKey); + const book = getBookData(bookKey)?.book; + if (!config || !book) 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); + dirtyRef.current = false; + await updateLastSyncedAt(Date.now()); + } catch (e) { + if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') { + eventDispatcher.dispatch('toast', { + type: 'error', + message: _('WebDAV authentication failed. Reconnect in Settings.'), + }); + } else { + console.warn('WD push failed', e); + } + } + }, [ + allowPush, + bookKey, + getConfig, + getBookData, + ensureDeviceId, + settings.webdav, + updateLastSyncedAt, + _, + ]); + + /** + * Upload the book binary if syncBooks is on and the remote doesn't + * already have a same-sized copy. Designed to be cheap on the steady + * state — the underlying `pushBookFile` does a single HEAD before any + * PUT, so for already-mirrored books we burn just one round-trip per + * book per session. Re-runs of this callback within the same hook + * instance no-op via `fileSyncedRef`. + */ + const pushBookFileNow = useCallback(async () => { + if (!allowPush) return; + if (!(settings.webdav?.syncBooks ?? false)) return; + if (fileSyncedRef.current) return; + fileSyncedRef.current = true; + + const book = getBookData(bookKey)?.book; + if (!book || !appService) 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. + const fp = getLocalBookFilename(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 }; + }, + // 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 = getLocalBookFilename(book); + if (!(await appService.exists(fp, 'Books'))) return null; + const file = await appService.openFile(fp, 'Books'); + 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 }; + if (closable.close) await closable.close(); + const dst = await appService.resolveFilePath(fp, 'Books'); + return { + size, + upload: async (remoteUrl, headers) => { + try { + await tauriUpload( + remoteUrl, + dst, + 'PUT', + undefined, + headers as unknown as Map, + ); + return true; + } catch (e) { + console.warn('WD per-book push: tauriUpload failed', book.hash, e); + return false; + } + }, + }; + } + : undefined, + ); + if (result.uploaded) { + await updateLastSyncedAt(Date.now()); + } + // Cover ride-along — best-effort, failures don't unwind the + // syncedRef lock or surface a toast. Same HEAD short-circuit as + // the book file, so steady-state cost is one HEAD per session. + 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 }; + }); + } catch (e) { + console.warn('WD book cover push failed', e); + } + } catch (e) { + // Reset the lock on failure so a manual Sync now or a subsequent + // 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') { + eventDispatcher.dispatch('toast', { + type: 'error', + message: _('WebDAV authentication failed. Reconnect in Settings.'), + }); + } else { + console.warn('WD book file push failed', e); + } + } + }, [allowPush, settings.webdav, getBookData, bookKey, appService, updateLastSyncedAt, _]); + + /** + * Pull, merge, and persist. Uses the same per-config / per-note merge + * semantics as the native cloud sync so a user running both feels the + * same behaviour from each. + * + * Returns `true` when remote already had a payload (merge happened), + * `false` when remote was empty. Callers use that to decide whether to + * follow up with an initial push (which is how the per-book directory + * actually gets created on first use). + */ + const pullNow = useCallback(async (): Promise => { + if (!allowPull) return false; + const wantProgress = settings.webdav?.syncProgress ?? true; + const wantNotes = settings.webdav?.syncNotes ?? true; + if (!wantProgress && !wantNotes) return false; + + const config = getConfig(bookKey); + const book = getBookData(bookKey)?.book; + if (!config || !book) return false; + + try { + const result = await pullBookConfig(settings.webdav!, book, config); + lastPulledAtRef.current = Date.now(); + if (!result.applied || !result.mergedConfig) return false; + + // Surface merged notes through the live view so highlights re-appear / + // disappear without waiting for the next render pass. + if (wantNotes && result.mergedNotes) { + const view = getView(bookKey); + const previousById = new Map((config.booknotes ?? []).map((n) => [n.id, n])); + for (const note of result.mergedNotes) { + const prev = previousById.get(note.id); + if (note.deletedAt && (!prev || !prev.deletedAt)) { + // Newly soft-deleted on the remote — strip overlays locally. + getViewsById(bookKey.split('-')[0]!).forEach((v) => removeBookNoteOverlays(v, note)); + } else if (!note.deletedAt && note.cfi && view) { + // Newly added or re-surrected; only render in the live spine. + try { + view.addAnnotation(note); + } catch { + // The annotation may not belong to the current spine index; + // it'll get rendered when that section is loaded. + } + } + } + } + + // Honour sub-toggles: drop the parts the user opted out of before + // writing back to the local config store. + const toApply = { ...result.mergedConfig }; + if (!wantProgress) { + toApply.progress = config.progress; + toApply.location = config.location; + toApply.xpointer = config.xpointer; + } + if (!wantNotes) { + toApply.booknotes = config.booknotes; + } + + setConfig(bookKey, toApply); + // Persist locally so a later session sees the merged state even if + // the user closes the book without further interaction. + const latest = getConfig(bookKey); + if (latest) await saveConfig(envConfig, bookKey, latest, settings); + await updateLastSyncedAt(Date.now()); + return true; + } catch (e) { + if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') { + eventDispatcher.dispatch('toast', { + type: 'error', + message: _('WebDAV authentication failed. Reconnect in Settings.'), + }); + } else { + console.warn('WD pull failed', e); + } + return false; + } + }, [ + allowPull, + bookKey, + getConfig, + getBookData, + getView, + getViewsById, + setConfig, + saveConfig, + envConfig, + settings, + updateLastSyncedAt, + _, + ]); + + // Stash the latest pull/push callbacks in a ref so the event-bridge + // useEffect below doesn't have to re-bind on every render. Pattern + // taken from useKOSync. + const syncRefs = useRef({ pushNow, pullNow, pushBookFileNow }); + useEffect(() => { + syncRefs.current = { pushNow, pullNow, pushBookFileNow }; + }, [pushNow, pullNow, pushBookFileNow]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const debouncedPush = useCallback( + debounce(() => { + // Skip the network round-trip when nothing has changed since the + // last successful push (the dirtyRef.current = false in pushNow's + // success path). + if (!dirtyRef.current) return; + syncRefs.current.pushNow(); + }, PUSH_DEBOUNCE_MS), + [], + ); + + /** + * Mark dirty + schedule a debounced push. Centralises the pattern so + * progress / booknote effects don't both have to remember to flip the + * dirty bit. + */ + const markDirtyAndSchedule = useCallback(() => { + dirtyRef.current = true; + debouncedPush(); + }, [debouncedPush]); + + // Pull once on book open, then push if remote was empty so the per-book + // directory gets created on first use rather than waiting for the user + // to scroll several pages. We hold off until progress.location is known + // so the merge has a real local side to compare against. The book file + // upload is gated by syncBooks and rides along on the same trigger. + useEffect(() => { + if (!isReady) return; + if (!progress?.location) return; + if (hasPulledOnce.current) return; + hasPulledOnce.current = true; + // Same-instance dedupe — if we already pulled for this book within + // the cooldown window, skip the second pull (and its bootstrap push) + // since the remote almost certainly hasn't moved. See the comment + // on `OPEN_PULL_SKIP_MS`: this guard only fires on re-runs of this + // effect within one hook lifetime, not on close-then-reopen. + if (Date.now() - lastPulledAtRef.current < OPEN_PULL_SKIP_MS) return; + (async () => { + const merged = await syncRefs.current.pullNow(); + if (!merged) { + // Remote had nothing for this book yet — bootstrap the directory + // structure (Readest/books//) by uploading the local config. + // Force-push (mark dirty first) so the bootstrap actually fires. + dirtyRef.current = true; + await syncRefs.current.pushNow(); + } + // Run the file-binary upload last so the lighter config sync is + // already mirrored before we start moving megabytes. The HEAD probe + // inside makes this near-free for already-synced books. + await syncRefs.current.pushBookFileNow(); + })(); + }, [isReady, progress?.location]); + + // Auto-push on progress changes. The debounce holds the network call + // off until the user has stopped turning pages for PUSH_DEBOUNCE_MS, + // and the dirty check inside debouncedPush short-circuits no-op flushes. + useEffect(() => { + if (!isReady) return; + if (!progress?.location) return; + markDirtyAndSchedule(); + }, [isReady, progress?.location, markDirtyAndSchedule]); + + // Booknote mutations: hash on length + max(updatedAt, deletedAt) so a + // pure re-render that produces a fresh `booknotes` array reference + // without any real change doesn't fire a push. The hash is cheap + // enough to recompute every render and keeps the effect dependency + // primitive. + const config = getConfig(bookKey); + const booknoteFingerprint = useMemo(() => { + const notes = config?.booknotes ?? []; + let max = 0; + for (const n of notes) { + const t = Math.max(n.updatedAt ?? 0, n.deletedAt ?? 0); + if (t > max) max = t; + } + return `${notes.length}:${max}`; + }, [config?.booknotes]); + useEffect(() => { + if (!isReady) return; + // The very first render after a pull populates the booknotes; we + // shouldn't treat that as a local edit. lastPulledAtRef being recent + // is a sufficient proxy. + if (Date.now() - lastPulledAtRef.current < 1_000) return; + markDirtyAndSchedule(); + }, [isReady, booknoteFingerprint, markDirtyAndSchedule]); + + // Manual triggers: settings UI dispatches these via "Sync now" buttons, + // and the reader emits flush-webdav-sync on close so we don't lose the + // last few seconds of reading progress. + useEffect(() => { + const handlePush = (event: CustomEvent) => { + if (event.detail?.bookKey && event.detail.bookKey !== bookKey) return; + // User-triggered push is unconditional — flip dirty so the flush + // actually does something, and re-run the book-file upload check + // so a freshly-toggled "Sync Book Files" picks up the binary. + dirtyRef.current = true; + fileSyncedRef.current = false; + debouncedPush.flush(); + syncRefs.current.pushBookFileNow(); + }; + const handlePull = (event: CustomEvent) => { + if (event.detail?.bookKey && event.detail.bookKey !== bookKey) return; + lastPulledAtRef.current = 0; // bypass cooldown for explicit pulls + hasPulledOnce.current = false; + syncRefs.current.pullNow(); + }; + eventDispatcher.on('push-webdav-sync', handlePush); + eventDispatcher.on('pull-webdav-sync', handlePull); + eventDispatcher.on('flush-webdav-sync', handlePush); + return () => { + eventDispatcher.off('push-webdav-sync', handlePush); + eventDispatcher.off('pull-webdav-sync', handlePull); + eventDispatcher.off('flush-webdav-sync', handlePush); + }; + }, [bookKey, debouncedPush]); + + // Window blur ⇒ push pending changes if any. Window focus ⇒ pull, but + // only if we haven't pulled within PULL_COOLDOWN_MS — important on + // mobile where alt-tab equivalents (notifications, app switch) can + // fire many times per minute. + useWindowActiveChanged((isActive) => { + if (!isReady) return; + if (isActive) { + if (Date.now() - lastPulledAtRef.current < PULL_COOLDOWN_MS) return; + syncRefs.current.pullNow(); + } else if (dirtyRef.current) { + debouncedPush.flush(); + } + }); + + // Flush any pending debounced push when the hook unmounts (book closed, + // user navigated away). Without this, a quick read-then-close session + // can lose its tail-end progress because the debounce timer never + // fires. The dirty check inside debouncedPush keeps no-op flushes out + // of the wire. + useEffect(() => { + return () => { + debouncedPush.flush(); + }; + }, [debouncedPush]); + + return { pushNow, pullNow }; +}; + +export default useWebDAVSync; diff --git a/apps/readest-app/src/components/settings/IntegrationsPanel.tsx b/apps/readest-app/src/components/settings/IntegrationsPanel.tsx index 1c64e883..a7859f5c 100644 --- a/apps/readest-app/src/components/settings/IntegrationsPanel.tsx +++ b/apps/readest-app/src/components/settings/IntegrationsPanel.tsx @@ -9,12 +9,14 @@ import { RiBook3Line, RiDiscordLine, RiSendPlaneLine, + RiCloudLine, } from 'react-icons/ri'; import { useEnv } from '@/context/EnvContext'; import { useAuth } from '@/context/AuthContext'; import { useTranslation } from '@/hooks/useTranslation'; import { useSettingsStore } from '@/store/settingsStore'; import { useCustomOPDSStore } from '@/store/customOPDSStore'; +import { useWebDAVSyncStore } from '@/store/webdavSyncStore'; import { CatalogManager } from '@/app/opds/components/CatalogManager'; import { saveSysSettings } from '@/helpers/settings'; import { navigateToLogin } from '@/utils/nav'; @@ -22,10 +24,11 @@ import KOSyncForm from './integrations/KOSyncForm'; import ReadwiseForm from './integrations/ReadwiseForm'; import HardcoverForm from './integrations/HardcoverForm'; import SendToReadestForm from './integrations/SendToReadestForm'; +import WebDAVForm from './integrations/WebDAVForm'; import SubPageHeader from './SubPageHeader'; import { SectionTitle, SettingLabel } from './primitives'; -type SubPage = 'kosync' | 'readwise' | 'hardcover' | 'opds' | 'send' | null; +type SubPage = 'kosync' | 'webdav' | 'readwise' | 'hardcover' | 'opds' | 'send' | null; /** * Integrations panel — single point of discovery for external service config: @@ -47,6 +50,10 @@ const IntegrationsPanel: React.FC = () => { const { settings, requestedSubPage, setRequestedSubPage } = useSettingsStore(); const opdsCatalogs = useCustomOPDSStore((s) => s.catalogs); const opdsCount = opdsCatalogs.filter((c) => !c.deletedAt).length; + // Surface a library-wide WebDAV sync that's mid-flight in the row's + // status line. Keeps the user from feeling like the run was lost + // when they back out of the WebDAV sub-page or close the dialog. + const isWebDAVSyncing = useWebDAVSyncStore((s) => s.isSyncing); const [subPage, setSubPage] = useState(null); @@ -66,6 +73,7 @@ const IntegrationsPanel: React.FC = () => { if (!requestedSubPage) return; if ( requestedSubPage === 'kosync' || + requestedSubPage === 'webdav' || requestedSubPage === 'readwise' || requestedSubPage === 'hardcover' || requestedSubPage === 'opds' || @@ -86,6 +94,12 @@ const IntegrationsPanel: React.FC = () => { setSubPage(null)} /> ); + if (subPage === 'webdav') + return ( +
+ setSubPage(null)} /> +
+ ); if (subPage === 'readwise') return (
@@ -125,6 +139,13 @@ const IntegrationsPanel: React.FC = () => { const readwiseStatus = settings.readwise?.enabled ? _('Connected') : _('Not connected'); const hardcoverStatus = settings.hardcover?.enabled ? _('Connected') : _('Not connected'); + const webdavStatus = isWebDAVSyncing + ? _('Syncing…') + : settings.webdav?.enabled + ? settings.webdav.username + ? _('Connected as {{user}}', { user: settings.webdav.username }) + : _('Connected') + : _('Not connected'); const opdsStatus = opdsCount > 0 ? _('{{count}} catalog', { count: opdsCount }) : _('No catalogs'); @@ -147,6 +168,12 @@ const IntegrationsPanel: React.FC = () => { status={koSyncStatus} onClick={() => setSubPage('kosync')} /> + setSubPage('webdav')} + /> void | Promise; + t: (key: string, params?: Record) => string; +} + +const SyncHistoryPanel: React.FC = ({ entries, onClear, t }) => { + // Only one entry expanded at a time keeps the panel scannable on + // mobile — multiple open rows can quickly push the disconnect button + // off-screen. Set to null when no row is expanded. + const [expandedId, setExpandedId] = useState(null); + + const hasEntries = entries.length > 0; + + return ( + + + {hasEntries ? ( + + ) : ( + {t('No manual syncs yet')} + )} + + {hasEntries && ( +
    + {entries.map((entry) => { + const isExpanded = expandedId === entry.id; + return ( +
  • + + {isExpanded && } +
  • + ); + })} +
+ )} +
+ ); +}; + +/** + * Coloured pill summarising an entry's status. We pick semantic + * Tailwind utilities (success / warning / error) so the badge respects + * the user's theme (eink, dark, light) without per-mode overrides. + */ +const SyncStatusBadge: React.FC<{ status: WebDAVSyncLogStatus; t: SyncHistoryPanelProps['t'] }> = ({ + status, + t, +}) => { + const map: Record = { + success: { label: t('OK'), className: 'bg-success/15 text-success' }, + partial: { label: t('Partial'), className: 'bg-warning/15 text-warning' }, + failure: { label: t('Failed'), className: 'bg-error/15 text-error' }, + }; + const { label, className } = map[status]; + return ( + + {label} + + ); +}; + +/** + * Secondary badge that flags non-sync runs (currently only batch + * cleanups from the WebDAV browser). Sync entries don't get a kind + * badge — the absence is the signal — so the row stays visually + * unchanged for the common case. The cleanup variant uses a neutral + * info colour rather than red/orange because the run already carries + * its own status badge (success / partial / failed) right next to + * it; piling more colour on would just shout. + */ +const SyncKindBadge: React.FC<{ t: SyncHistoryPanelProps['t'] }> = ({ t }) => { + return ( + + {t('Cleanup')} + + ); +}; + +/** + * Build the one-line summary shown next to each history row's status + * badge. We re-derive it from the structured counters (rather than + * reusing the toast's `entry.summary`) so the text in the log stays + * compact even when the original toast was multi-line. + */ +const formatSyncSummaryLine = ( + entry: WebDAVSyncLogEntry, + t: SyncHistoryPanelProps['t'], +): string => { + if (entry.status === 'failure') { + return ( + entry.errorMessage || (entry.kind === 'cleanup' ? t('Cleanup failed') : t('Sync failed')) + ); + } + if (entry.kind === 'cleanup') { + // Cleanup runs only have two interesting numbers: how many + // server-side dirs got deleted and how many failed. None of the + // sync counters apply, so build a dedicated summary rather than + // running the cleanup entry through the upload/download formatter + // and watching every clause come up zero. + const parts: string[] = []; + if ((entry.booksDeleted ?? 0) > 0) { + parts.push(t('{{n}} deleted', { n: entry.booksDeleted ?? 0 })); + } + if (entry.failures > 0) { + parts.push(t('{{n}} failed', { n: entry.failures })); + } + return parts.length > 0 ? parts.join(' · ') : t('Nothing deleted'); + } + const parts: string[] = []; + if (entry.booksDownloaded > 0) { + parts.push(t('{{n}} downloaded', { n: entry.booksDownloaded })); + } + if (entry.filesUploaded > 0) { + parts.push(t('{{n}} uploaded', { n: entry.filesUploaded })); + } + if (entry.configsUploaded > 0 || entry.configsDownloaded > 0) { + parts.push(t('{{n}} progress', { n: entry.configsUploaded + entry.configsDownloaded })); + } + if (entry.failures > 0) { + parts.push(t('{{n}} failed', { n: entry.failures })); + } + return parts.length > 0 ? parts.join(' · ') : t('Up to date'); +}; + +/** + * "Mar 18, 14:32 · 4.2 s" — short locale-aware timestamp plus a + * duration so users can spot abnormally slow runs at a glance. + */ +const formatSyncTimestamp = ( + startedAt: number, + finishedAt: number, + t: SyncHistoryPanelProps['t'], +): string => { + const when = new Date(startedAt).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + const durMs = Math.max(0, finishedAt - startedAt); + const dur = durMs >= 1000 ? `${(durMs / 1000).toFixed(1)} s` : `${durMs} ms`; + return t('{{when}} · {{dur}}', { when, dur }); +}; + +/** + * Expanded body of one history entry: the full counter grid plus the + * per-book failure list when present. Counters that are zero are + * suppressed so the grid only shows what actually happened — a + * partial-failure row with three items is much easier to read than + * the same row with seven zeroes interleaved. + */ +const SyncHistoryDetails: React.FC<{ + entry: WebDAVSyncLogEntry; + t: SyncHistoryPanelProps['t']; +}> = ({ entry, t }) => { + // Counters are grouped semantically so the user can scan them at a + // glance instead of treating eight numbers as a flat blob: + // - "activity": work performed during this run + // - "skipped": work that was deduped / no-op'd + // - "outcome": totals + failure count for at-a-glance triage + // Each group renders independently and is separated by a divider so + // it's visually obvious that "Configs uploaded" and "Total books" + // are different things — they previously sat side-by-side in a + // single grid which read as one block. + const groups: { label: string; value: number }[][] = [ + [ + { label: t('Books downloaded'), value: entry.booksDownloaded }, + { label: t('Files uploaded'), value: entry.filesUploaded }, + { label: t('Configs uploaded'), value: entry.configsUploaded }, + { label: t('Configs downloaded'), value: entry.configsDownloaded }, + { label: t('Covers uploaded'), value: entry.coversUploaded }, + // Cleanup-specific counter. Suppressed by the zero-filter on + // sync entries (which always set this to zero/undefined), so + // it only shows up on cleanup runs without polluting the + // common sync detail view. + { label: t('Books deleted'), value: entry.booksDeleted ?? 0 }, + ], + [{ label: t('Files in sync'), value: entry.filesAlreadyInSync }], + [ + { label: t('Failures'), value: entry.failures }, + { label: t('Total books'), value: entry.totalBooks }, + ], + ] + // Suppress zero-only groups entirely so we don't render an empty + // section + divider for a group whose every counter happens to be + // zero this run (common: 'skipped' and 'outcome' rows on a quiet + // sync). The within-group filter keeps individual zero entries out + // of mixed groups. + .map((group) => group.filter((c) => c.value > 0)) + .filter((group) => group.length > 0); + + return ( +
+ {groups.length > 0 && ( + // Six-column grid: each of the three semantic groups occupies + // a (label-column, value-column) pair. Label columns flex with + // available space and wrap naturally for long strings like + // "Configs uploaded"; value columns are sized to content so + // the numbers stay tightly packed against their labels. Border + // dividers between every other column visually separate the + // three groups; we draw them with `border-l` on columns 3 and + // 5 rather than CSS `divide-x` because divide-x can't honour + // the "skip every two columns" pattern. +
+ {(() => { + // All three columns share row count to keep the grid rows + // aligned. Compute it once outside the per-group map so + // each column sees the same value. + const maxRows = Math.max(...groups.map((g) => g.length), 0); + return [0, 1, 2].map((groupIdx) => { + const group = groups[groupIdx] ?? []; + const cells: React.ReactNode[] = []; + for (let row = 0; row < maxRows; row++) { + const c = group[row]; + cells.push( +
0 && 'border-base-200 -ml-3 border-l pl-3', + )} + > + {c?.label ?? ''} +
, + ); + cells.push( +
+ {c?.value ?? ''} +
, + ); + } + return cells; + }); + })()} +
+ )} + {entry.errorMessage && ( +
+ {t('Error:')} + {entry.errorMessage} +
+ )} + {entry.failedBooks && entry.failedBooks.length > 0 && ( +
+ {t('Failed books')} +
    + {entry.failedBooks.map((f) => ( +
  • +
    {f.title}
    +
    {f.reason}
    +
  • + ))} +
+
+ )} +
+ ); +}; + +export default SyncHistoryPanel; diff --git a/apps/readest-app/src/components/settings/integrations/WebDAVBrowsePane.tsx b/apps/readest-app/src/components/settings/integrations/WebDAVBrowsePane.tsx new file mode 100644 index 00000000..1192acbf --- /dev/null +++ b/apps/readest-app/src/components/settings/integrations/WebDAVBrowsePane.tsx @@ -0,0 +1,795 @@ +import clsx from 'clsx'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + MdFolder, + MdFolderOff, + MdRefresh, + MdArrowBack, + MdDownload, + MdCheck, + MdDeleteSweep, + MdClose, +} from 'react-icons/md'; +import { useEnv } from '@/context/EnvContext'; +import { useAuth } from '@/context/AuthContext'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useLibraryStore } from '@/store/libraryStore'; +import { isTauriAppPlatform } from '@/services/environment'; +import { tauriDownload } from '@/utils/transfer'; +import { eventDispatcher } from '@/utils/event'; +import { ingestFile } from '@/services/ingestService'; +import { + buildBasicAuthHeader, + buildRequestUrl, + listDirectory, + normalizeRootPath, + WebDAVEntry, + WebDAVRequestError, +} from '@/services/webdav/WebDAVClient'; +import { buildBasePath, WEBDAV_BOOKS_DIR } from '@/services/webdav/WebDAVPaths'; +import { deleteRemoteBookDir } from '@/services/webdav/WebDAVSync'; +import { v4 as uuidv4 } from 'uuid'; +import { WebDAVSettings, WebDAVSyncLogEntry, WebDAVSyncLogFailure } from '@/types/settings'; +import { Book } from '@/types/book'; +import { SettingLabel } from '../primitives'; +import { + formatLastModified, + formatShortHash, + formatSize, + getEntryIcon, + isSupportedBookExt, +} from './webdavBrowseUtils'; + +/** + * Live browser for the WebDAV root the user connected to. + * + * Owns its own current path, listing and per-entry download status; + * the parent supplies `settings` and `t`. Doubles as the GC surface + * for remote orphans via cleanup mode. + */ +export interface WebDAVBrowsePaneProps { + settings: WebDAVSettings; + t: (key: string, params?: Record) => string; + /** Persist a cleanup run into the parent's sync log when supplied. */ + onAppendSyncLogEntry?: (entry: WebDAVSyncLogEntry) => Promise | void; +} + +const WebDAVBrowsePane: React.FC = ({ + settings, + t, + onAppendSyncLogEntry, +}) => { + const { envConfig } = useEnv(); + const { user } = useAuth(); + const { settings: globalSettings } = useSettingsStore(); + + // The saved root is the authoritative "you can't navigate above me" + // limit. Memoise so we don't recompute on every keystroke. + const savedRoot = useMemo(() => normalizeRootPath(settings.rootPath || '/'), [settings.rootPath]); + + // 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 + // local library (see `bookByHash` below). Computed once per + // rootPath; `buildBasePath` already drops trailing slashes so this + // can be string-compared against `currentPath` without further + // normalisation. + const booksDirPath = useMemo( + () => `${buildBasePath(settings.rootPath || '/')}/${WEBDAV_BOOKS_DIR}`, + [settings.rootPath], + ); + + // `currentPath` may differ from `savedRoot` once the user drills + // into sub-folders. Seeded from saved root so the first render + // already has a directory to load. + const [currentPath, setCurrentPath] = useState(savedRoot); + const [entries, setEntries] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + /** Increments on Refresh; an effect dependency that forces a reload. */ + const [reloadTick, setReloadTick] = useState(0); + /** + * Per-entry download status keyed by remote path. Resets when the + * user navigates or refreshes; "done" stops a redundant re-tap. + */ + const [downloadStatus, setDownloadStatus] = useState< + Record + >({}); + + // —— Cleanup mode —— + // GC surface for remote orphans (per-hash dirs whose local Book + // has `deletedAt` set). When on, the listing is pinned to + // `Readest/books/`, filtered down to those orphan rows, and the + // footer carries a batch Delete from server action. + const [cleanupMode, setCleanupMode] = useState(false); + /** Selected rows, keyed by `entry.path` (already the React list key). */ + const [selected, setSelected] = useState>(new Set()); + /** True during an in-flight batch delete; gates concurrent triggers. */ + const [isDeleting, setIsDeleting] = useState(false); + + const handleEnterCleanup = () => { + // Snap to the books dir even if the user clicks Cleanup while + // browsing some unrelated subfolder. + if (currentPath !== booksDirPath) setCurrentPath(booksDirPath); + setSelected(new Set()); + setCleanupMode(true); + }; + const handleExitCleanup = () => { + setCleanupMode(false); + setSelected(new Set()); + }; + const toggleRowSelected = (path: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + // Hash → Book index built from the live library. Includes + // soft-deleted books on purpose: a remote dir whose local + // counterpart is `deletedAt` is still that book — showing its + // title lets the user identify what's still on the server. + // Subscribing (rather than reading via getState) means imports + // and downloads done elsewhere reflect here without a Refresh. + const library = useLibraryStore((s) => s.library); + const bookByHash = useMemo(() => { + const map = new Map(); + for (const b of library ?? []) { + if (b?.hash) map.set(b.hash, b); + } + return map; + }, [library]); + + /** Per-hash directory of a book the user has soft-deleted locally. */ + const isEntryLocallyDeleted = (entry: WebDAVEntry): boolean => { + if (!entry.isDirectory) return false; + if (currentPath !== booksDirPath) return false; + return !!bookByHash.get(entry.name)?.deletedAt; + }; + + // Cleanup mode shows only the orphan rows; the toolbar uses the + // count too, so materialise the filter once here. + const displayedEntries = useMemo( + () => (cleanupMode ? entries.filter(isEntryLocallyDeleted) : entries), + // eslint-disable-next-line react-hooks/exhaustive-deps + [entries, cleanupMode, bookByHash, currentPath, booksDirPath], + ); + + /** + * Sequentially DELETE the selected per-hash directories from the + * server. The local library is intentionally not touched — + * `Book.deletedAt` is the tombstone that propagates the deletion + * across sync clients, so clearing it would resurrect the book. + * Errors are aggregated; AUTH_FAILED short-circuits the loop. + */ + const handleDelete = async () => { + if (selected.size === 0 || isDeleting) return; + + // Resolve selections before any async work so a re-render of + // `displayedEntries` can't shift indices mid-loop. + const targets: Array<{ path: string; hash: string; title: string }> = []; + for (const e of displayedEntries) { + if (!selected.has(e.path)) continue; + const matched = bookByHash.get(e.name); + targets.push({ + path: e.path, + hash: e.name, + title: matched?.title || e.name, + }); + } + if (targets.length === 0) return; + + // appService.ask, not window.confirm — the latter is a no-op / + // async on Tauri's WebView and would let DELETE start before the + // user could see the dialog. + const appService = await envConfig.getAppService(); + if (!appService) return; + const confirmed = await appService.ask( + t( + 'Delete {{n}} book(s) from the WebDAV server?\n\nThis only removes the remote files; your local library is unaffected. The deletion cannot be undone — the bytes on the server will be permanently gone.', + { n: targets.length }, + ), + ); + if (!confirmed) return; + + const startedAt = Date.now(); + let succeeded = 0; + const failed: Array<{ hash: string; title: string; reason: string }> = []; + let authFailed = false; + + setIsDeleting(true); + try { + for (let i = 0; i < targets.length; i++) { + const t0 = targets[i]!; + try { + const res = await deleteRemoteBookDir(settings, t0.hash); + if (res.ok) { + succeeded++; + // Splice on success so the listing itself is the progress + // indicator: rows visibly disappear one by one. + setEntries((prev) => prev.filter((e) => e.path !== t0.path)); + setSelected((prev) => { + if (!prev.has(t0.path)) return prev; + const next = new Set(prev); + next.delete(t0.path); + return next; + }); + } else { + failed.push({ + hash: t0.hash, + title: t0.title, + reason: res.reason ?? 'Unknown error', + }); + } + } catch (e) { + if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') { + // Every remaining target would fail identically; stop + // and surface a single re-auth toast. + authFailed = true; + break; + } + failed.push({ + hash: t0.hash, + title: t0.title, + reason: e instanceof Error ? e.message : String(e), + }); + } + } + } finally { + setIsDeleting(false); + } + + // No post-batch PROPFIND: the per-item splice already keeps the + // listing in lock-step with the server, and a redundant reload + // would briefly swap in the loading spinner — visible jump. + + // One source of truth for the toast and the log entry. + let toastType: 'info' | 'warning' | 'error'; + let message: string; + let status: 'success' | 'partial' | 'failure'; + let errorMessage: string | undefined; + if (authFailed) { + toastType = 'error'; + status = 'failure'; + message = t('WebDAV authentication failed. Reconnect in Settings.'); + errorMessage = message; + } else if (failed.length === 0) { + toastType = 'info'; + status = 'success'; + message = t('Deleted {{n}} book(s) from server.', { n: succeeded }); + } else if (succeeded > 0) { + toastType = 'warning'; + status = 'partial'; + message = t('Deleted {{ok}} of {{total}}; {{n}} failed (e.g. "{{first}}").', { + ok: succeeded, + total: targets.length, + n: failed.length, + first: failed[0]!.title, + }); + } else { + toastType = 'warning'; + status = 'failure'; + message = t('Failed to delete {{n}} book(s) (e.g. "{{first}}").', { + n: failed.length, + first: failed[0]!.title, + }); + } + + if (failed.length > 0) { + console.warn('[webdav cleanup] delete failures', failed); + } + eventDispatcher.dispatch('toast', { type: toastType, message }); + + // Persist into the shared sync log so cleanup runs are auditable + // alongside Sync now. The 'cleanup' kind tag drives the panel's + // badge and summary line; sync-only counters stay zero and the + // panel's zero-suppress filter hides them. + if (onAppendSyncLogEntry) { + const failedBooks: WebDAVSyncLogFailure[] | undefined = + failed.length > 0 + ? failed.map((f) => ({ hash: f.hash, title: f.title, reason: f.reason })) + : undefined; + const entry: WebDAVSyncLogEntry = { + id: uuidv4(), + startedAt, + finishedAt: Date.now(), + kind: 'cleanup', + status, + trigger: 'manual', + totalBooks: targets.length, + booksDownloaded: 0, + filesUploaded: 0, + filesAlreadyInSync: 0, + configsUploaded: 0, + configsDownloaded: 0, + coversUploaded: 0, + booksDeleted: succeeded, + failures: failed.length, + summary: message, + errorMessage, + failedBooks, + }; + // Fire-and-forget: a log-write failure shouldn't surface as a + // second toast right after the cleanup result, the user has + // already seen the outcome they care about. + void Promise.resolve(onAppendSyncLogEntry(entry)).catch((e) => + console.warn('WD cleanup: failed to append sync log entry', e), + ); + } + }; + + // Drop stale paths from the selection whenever displayedEntries + // shrinks (Refresh, post-Delete splice etc.) so the action button + // can't act on phantom rows. + useEffect(() => { + if (!cleanupMode) return; + setSelected((prev) => { + const live = new Set(displayedEntries.map((e) => e.path)); + let changed = false; + const next = new Set(); + for (const p of prev) { + if (live.has(p)) next.add(p); + else changed = true; + } + return changed ? next : prev; + }); + }, [cleanupMode, displayedEntries]); + + // Reload the listing whenever path / credentials / tick change. + // The `cancelled` flag prevents a stale PROPFIND response from + // overwriting the active folder (the user can navigate faster + // than the round-trip). + useEffect(() => { + if (!currentPath) return; + let cancelled = false; + // Reset per-entry download status on every (re)load so stale + // "done" badges don't carry across folders. + setDownloadStatus({}); + const load = async () => { + setIsLoading(true); + setLoadError(null); + try { + const list = await listDirectory( + { + serverUrl: settings.serverUrl, + username: settings.username, + password: settings.password, + }, + currentPath, + ); + if (!cancelled) setEntries(list); + } catch (e) { + if (!cancelled) { + setEntries([]); + setLoadError((e as Error).message || t('Failed to load directory')); + } + } finally { + if (!cancelled) setIsLoading(false); + } + }; + load(); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentPath, reloadTick, settings.serverUrl, settings.username, settings.password]); + + const handleEntryClick = (entry: WebDAVEntry) => { + if (entry.isDirectory) setCurrentPath(entry.path); + }; + + const handleNavigateUp = () => { + if (currentPath === savedRoot) return; + const trimmed = currentPath.replace(/\/+$/, ''); + const idx = trimmed.lastIndexOf('/'); + const parent = idx <= 0 ? '/' : trimmed.slice(0, idx); + // Don't escape above the saved root — the integration is scoped to it. + if (!parent.startsWith(savedRoot)) { + setCurrentPath(savedRoot); + } else { + setCurrentPath(parent); + } + }; + + const handleRefresh = () => { + setReloadTick((n) => n + 1); + }; + + /** + * Download a single remote file and ingest it into the library. + * Streams via tauriDownload to avoid the WebView IPC limit on + * Android, then delegates to ingestFile for hash dedupe + Book + * record creation. Web/desktop builds without tauriDownload + * surface a toast (Settings is gated to Tauri platforms). + */ + const handleDownloadEntry = async (entry: WebDAVEntry) => { + if (entry.isDirectory) return; + if (!isSupportedBookExt(entry.name)) return; + if (downloadStatus[entry.path] === 'downloading' || downloadStatus[entry.path] === 'done') { + return; + } + if (!isTauriAppPlatform()) { + eventDispatcher.dispatch('toast', { + type: 'error', + message: t('File download is only supported on the desktop and mobile apps.'), + }); + return; + } + const appService = await envConfig.getAppService(); + if (!appService) return; + + setDownloadStatus((prev) => ({ ...prev, [entry.path]: 'downloading' })); + try { + // Cache filename is timestamped so concurrent downloads / re-taps + // can't clobber each other's in-flight bytes. + const safeName = entry.name.replaceAll(/[/\\:*?"<>|]/g, '_').slice(0, 200) || 'download'; + const cacheName = `webdav-${Date.now()}-${safeName}`; + const dst = await appService.resolveFilePath(cacheName, 'Cache'); + const url = buildRequestUrl(settings.serverUrl, entry.path); + const headers = { + Authorization: buildBasicAuthHeader(settings.username, settings.password), + }; + await tauriDownload(url, dst, undefined, headers); + + // Fresh library snapshot — ingestFile mutates `library` in + // place via importBook, so we persist and push back to the + // store afterwards. + const { library: storeLibrary, libraryLoaded, setLibrary } = useLibraryStore.getState(); + const library = libraryLoaded ? [...storeLibrary] : await appService.loadLibraryBooks(); + const imported = await ingestFile( + { file: dst, books: library }, + { appService, settings: globalSettings, isLoggedIn: !!user }, + ); + // ingestFile copies the bytes into Books//, the cache + // copy is now redundant. Best-effort delete; OS GC catches it + // if this fails. + try { + await appService.deleteFile(dst, 'None'); + } catch { + // Cache deletion is non-critical. + } + if (!imported) { + throw new Error('Import returned null'); + } + await appService.saveLibraryBooks(library); + setLibrary(library); + + setDownloadStatus((prev) => ({ ...prev, [entry.path]: 'done' })); + eventDispatcher.dispatch('toast', { + type: 'info', + message: t('Downloaded "{{title}}" to your library.', { + title: imported.title || entry.name, + }), + }); + } catch (e) { + console.warn('WebDAV download failed', entry.path, e); + setDownloadStatus((prev) => ({ ...prev, [entry.path]: 'error' })); + eventDispatcher.dispatch('toast', { + type: 'error', + message: t('Failed to download "{{name}}": {{error}}', { + name: entry.name, + error: (e as Error).message ?? String(e), + }), + }); + } + }; + + return ( + <> +
+
+ + + {cleanupMode + ? t('Cleanup · {{count}} book(s)', { count: displayedEntries.length }) + : currentPath} + +
+
+ {cleanupMode ? ( + + ) : ( + + )} + +
+
+ +
+ {isLoading ? ( +
+ +
+ ) : loadError ? ( +
{loadError}
+ ) : displayedEntries.length === 0 ? ( +
+ {cleanupMode ? t('All clear · no books') : t('Empty directory')} +
+ ) : ( +
    + {/* Per-hash subdirectories under Readest/books resolve + to the local library's title; rows whose hash isn't + in the library fall back to the raw hash + mtime. */} + {displayedEntries.map((entry) => { + const canDownload = !entry.isDirectory && isSupportedBookExt(entry.name); + const dlState = downloadStatus[entry.path]; + // Title decoration only kicks in inside Readest/books + // so unrelated directories elsewhere can't be mistaken + // for content hashes. + const matchedBook = + entry.isDirectory && currentPath === booksDirPath + ? bookByHash.get(entry.name) + : undefined; + // A matched book with `deletedAt` is a remote orphan: + // present on server, marked deleted locally. Surface it + // with MdFolderOff so the cleanup pass can spot it. + const isLocallyDeleted = !!matchedBook?.deletedAt; + const FolderGlyph = isLocallyDeleted ? MdFolderOff : MdFolder; + const FileIcon = entry.isDirectory ? FolderGlyph : getEntryIcon(entry.name); + // Files are inert (only the trailing download button + // is interactive); directories accept enter/click to + // navigate (or toggle in cleanup mode). + const rowClickable = entry.isDirectory; + const rowTitle = isLocallyDeleted + ? t('Deleted locally · still on server') + : undefined; + return ( +
  • +
    toggleRowSelected(entry.path) + : rowClickable + ? () => handleEntryClick(entry) + : undefined + } + onKeyDown={ + rowClickable + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (cleanupMode) toggleRowSelected(entry.path); + else handleEntryClick(entry); + } + } + : undefined + } + className={clsx( + 'group flex w-full items-center gap-3 px-4 py-3 text-left', + 'transition-colors duration-150', + rowClickable ? 'hover:bg-base-200/60 cursor-pointer' : 'cursor-default', + // Selected-row tint, kept subtle so the action + // buttons in the footer carry the semantic colour. + cleanupMode && selected.has(entry.path) && 'bg-base-200/80', + )} + > + {cleanupMode ? ( + // Checkbox replaces the icon: every visible + // row in cleanup is by definition deleted, so + // showing MdFolderOff for all of them is noise. + + e.stopPropagation()} + onChange={() => toggleRowSelected(entry.path)} + aria-label={t('Select')} + /> + + ) : ( + + + + )} +
    + {/* Primary line: matched books show their + title, everything else shows the raw name. + line-clamp-none + break-all let unicode-only + names wrap. */} + + {matchedBook ? matchedBook.title || entry.name : entry.name} + + {/* Metadata line: short hash (when matched) + + file size + last-modified. Whole line is + gated on at least one field being present + so directories don't render an empty span. */} + {(matchedBook || + (!entry.isDirectory && typeof entry.size === 'number') || + entry.lastModified) && ( + + {matchedBook && ( + + {formatShortHash(entry.name)} + + )} + {!entry.isDirectory && typeof entry.size === 'number' && ( + {formatSize(entry.size)} + )} + {entry.lastModified && ( + + {formatLastModified(entry.lastModified)} + + )} + + )} +
    + {canDownload && ( + + )} +
    +
  • + ); + })} +
+ )} + {/* Cleanup-mode batch footer. Lives inside the card so it + belongs visually to the listing it operates on, outside + the loading/error branches so the user can always cancel. + Buttons are dimmed-but-present when selection is empty, + avoiding layout shifts as checkboxes toggle. */} + {cleanupMode && ( +
+ {/* Top row: scope. */} +
+ + + {t('{{n}} selected', { n: selected.size })} + +
+ {/* Bottom row: action. The per-entry download button + provides recovery (ingestFile clears deletedAt on + re-import), so cleanup needs no Restore counterpart. */} +
+ +
+
+ )} +
+ + ); +}; + +export default WebDAVBrowsePane; diff --git a/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx b/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx new file mode 100644 index 00000000..4dae5b76 --- /dev/null +++ b/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx @@ -0,0 +1,740 @@ +import clsx from 'clsx'; +import React, { useState } from 'react'; +import { MdVisibility, MdVisibilityOff, MdCloudSync } from 'react-icons/md'; +import { v4 as uuidv4 } from 'uuid'; +import { useEnv } from '@/context/EnvContext'; +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, + WebDAVRequestError, +} from '@/services/webdav/WebDAVClient'; +import { syncLibrary } from '@/services/webdav/WebDAVSync'; +import { buildWebDAVConnectSettings } from '@/services/webdav/webdavConnectSettings'; +import { getCoverFilename, getLocalBookFilename } from '@/utils/book'; +import { + WEBDAV_SYNC_LOG_LIMIT, + WebDAVSyncLogEntry, + WebDAVSyncLogFailure, + WebDAVSyncLogStatus, +} from '@/types/settings'; +import SubPageHeader from '../SubPageHeader'; +import { + BoxedList, + SectionTitle, + SettingsRow, + SettingsSwitchRow, + SettingsSelect, +} from '../primitives'; +import SyncHistoryPanel from './SyncHistoryPanel'; +import WebDAVBrowsePane from './WebDAVBrowsePane'; + +interface WebDAVFormProps { + onBack: () => void; +} + +/** + * WebDAV integration form. Two modes share the same panel: + * + * - Configuration: editable URL/username/password/root + Connect button. + * Lives in local state until Connect succeeds — only then do we + * persist the credentials via `saveSettings`. Failures surface via + * toast. + * + * - Connected: renders the per-page sync controls (sub-toggles, Sync + * now, sync-history) plus the {@link WebDAVBrowsePane} for the + * stored root, and a Disconnect button. The browse pane is its own + * component to keep this file legible — see its docstring. + */ +const WebDAVForm: React.FC = ({ onBack }) => { + const _ = useTranslation(); + const { settings, setSettings, saveSettings } = useSettingsStore(); + const { envConfig } = useEnv(); + + const stored = settings.webdav; + // Show the browse view only when an active connection is configured. + // We rely on `enabled` (set by Connect, cleared by Disconnect) rather + // than looking at serverUrl/username so Disconnect always returns the + // user to the configuration form even if we keep their previous URL + // pre-filled. + const isConfigured = !!stored?.enabled && !!stored?.serverUrl; + + // Editable form state — initialised from saved settings so re-entering + // the sub-page after a previous configure preserves what the user + // typed. + const [url, setUrl] = useState(stored?.serverUrl || ''); + const [username, setUsername] = useState(stored?.username || ''); + const [password, setPassword] = useState(stored?.password || ''); + const [rootPath, setRootPath] = useState(stored?.rootPath || '/'); + const [isConnecting, setIsConnecting] = useState(false); + const [showPassword, setShowPassword] = useState(false); + // Library-wide Sync now state — stored in a process-local zustand + // store rather than component state so the run survives navigation + // events that would otherwise unmount us (drilling back to the + // Integrations list, closing the SettingsDialog and reopening it). + // Without this hoist, the user would see the button re-enable, no + // progress affordance, and could trigger a second concurrent + // syncLibrary while the first was still in flight against the + // server. See `webdavSyncStore.ts` for the design rationale. + const isSyncing = useWebDAVSyncStore((s) => s.isSyncing); + const syncProgressLabel = useWebDAVSyncStore((s) => s.progressLabel); + const beginSync = useWebDAVSyncStore((s) => s.beginSync); + const updateProgress = useWebDAVSyncStore((s) => s.updateProgress); + const endSync = useWebDAVSyncStore((s) => s.endSync); + + const handleConnect = async () => { + if (!url || !username) return; + setIsConnecting(true); + const normalizedRoot = normalizeRootPath(rootPath); + const result = await checkConnection({ serverUrl: url, username, password }, normalizedRoot); + if (!result.success) { + eventDispatcher.dispatch('toast', { + type: 'error', + message: `${_('Failed to connect')}: ${_(result.message || 'Connection error')}`, + }); + setIsConnecting(false); + return; + } + // Spread previous webdav state so a reconnect preserves bookkeeping + // fields earned by prior use — deviceId, syncBooks, strategy, + // syncProgress, syncNotes, lastSyncedAt, syncLog. Rotating deviceId + // on reconnect would make this device look new to the cross-device + // clobber check in `RemoteBookConfig.writerDeviceId`. + const newSettings = { + ...settings, + webdav: buildWebDAVConnectSettings(settings.webdav, { + serverUrl: url, + username, + password, + rootPath: normalizedRoot, + }), + }; + setSettings(newSettings); + await saveSettings(envConfig, newSettings); + setIsConnecting(false); + eventDispatcher.dispatch('toast', { type: 'info', message: _('Connected') }); + }; + + const handleDisconnect = async () => { + const newSettings = { + ...settings, + webdav: { + ...settings.webdav, + enabled: false, + }, + }; + setSettings(newSettings); + await saveSettings(envConfig, newSettings); + // Keep the password pre-filled (masked) so the user can reconnect + // with a single click — they can still toggle visibility via the + // eye icon. + setShowPassword(false); + eventDispatcher.dispatch('toast', { type: 'info', message: _('Disconnected') }); + }; + + // —— Sync sub-toggles & manual triggers —— + // The toggles persist via saveSettings synchronously (debouncing + // isn't worth the extra state — users tap each toggle at most once + // per session). + // + // IMPORTANT: read latest settings from the store (NOT the closure + // variable) when computing `next`. `handleSyncNow` issues two + // back-to-back persistWebdav calls — first `lastSyncedAt`, then + // `syncLog`. The closure's `settings` was captured before either + // write landed, so a closure-based merge would clobber the freshly- + // written `lastSyncedAt` when the second call rebuilds the webdav + // object from the stale snapshot. Symptom: the "Last synced" label + // stays pinned to the previous value while the Sync History row + // shows the up-to-date timestamp. Use `useSettingsStore.getState()` + // so each call merges into whatever's currently committed. + const persistWebdav = async (patch: Partial) => { + const latest = useSettingsStore.getState().settings; + const next = { ...latest, webdav: { ...latest.webdav, ...patch } }; + setSettings(next); + await saveSettings(envConfig, next); + }; + + // Reading progress and annotations are always synced when WebDAV is + // 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 handleStrategyChange = async (e: React.ChangeEvent) => { + await persistWebdav({ strategy: e.target.value as typeof stored.strategy }); + }; + + /** + * Append a diagnostic entry to the bounded sync history. + * + * We deliberately re-read settings from the store at write time + * (rather than closing over `stored`) so concurrent updates — e.g. + * the user flips the syncBooks toggle while a Sync now is in flight + * — don't clobber each other. The 10-entry cap matches + * `WEBDAV_SYNC_LOG_LIMIT` and trims oldest-first; we keep the + * persisted JSON small so settings.json round-trips on every app + * start stay cheap. + */ + const appendSyncLogEntry = async (entry: WebDAVSyncLogEntry) => { + const current = useSettingsStore.getState().settings.webdav?.syncLog ?? []; + // Newest first — UI renders in array order, so unshift keeps the + // freshest run at the top without reversing on every render. + const next = [entry, ...current].slice(0, WEBDAV_SYNC_LOG_LIMIT); + await persistWebdav({ syncLog: next }); + }; + + const handleClearSyncLog = async () => { + await persistWebdav({ syncLog: [] }); + }; + + /** + * 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. + * + * 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. + */ + const handleSyncNow = async () => { + // Re-entrancy gate must read the live store, not the closure: a + // second click after we re-mount could otherwise see the captured + // `isSyncing` from this render rather than the up-to-date one. + if (useWebDAVSyncStore.getState().isSyncing) return; + if (!stored?.enabled || !stored.serverUrl) return; + + // Load library from disk if not loaded yet + const { libraryLoaded, library } = useLibraryStore.getState(); + const appService = await envConfig.getAppService(); + + let currentLibrary = library ?? []; + if (!libraryLoaded && appService) { + currentLibrary = await appService.loadLibraryBooks(); + } + + const eligibleBooks = currentLibrary.filter((b) => !b.deletedAt); + + // Lazily ensure a deviceId so the first cross-device sync + // attributes its rows correctly. The same field is also touched by + // the Reader hook on first push; doing it here too keeps the Sync + // now path self-sufficient when the user has never opened a book + // yet. + let deviceId = stored.deviceId; + if (!deviceId) { + deviceId = uuidv4(); + await persistWebdav({ deviceId }); + } + + beginSync(_('Syncing 0 / {{total}}', { total: eligibleBooks.length })); + + // Captured before the run begins so we can attribute startedAt + // accurately even when the run fails in the catch block (the + // pre-flight library load can take a moment on slow disks). + const startedAt = Date.now(); + + try { + const result = await syncLibrary(stored, eligibleBooks, { + strategy: stored.strategy === 'prompt' ? 'silent' : stored.strategy, + syncBooks: stored.syncBooks ?? false, + deviceId: deviceId as string, + loadConfig: (book) => + appService ? appService.loadBookConfig(book, settings) : Promise.resolve(null), + loadBookFile: async (book) => { + if (!appService) return null; + const fp = getLocalBookFilename(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 }; + }, + // 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 = getLocalBookFilename(book); + if (!(await appService.exists(fp, 'Books'))) return null; + const file = await appService.openFile(fp, 'Books'); + 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 }; + if (closable.close) await closable.close(); + const dst = await appService.resolveFilePath(fp, 'Books'); + 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. 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, + ); + 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); + }, + onProgress: ({ book, index, total, action }) => { + const actionStr = action === 'downloading' ? _('Downloading') : _('Uploading'); + updateProgress( + _('{{action}} {{n}} / {{total}} — {{title}}', { + action: actionStr, + n: index + 1, + total, + title: book.title || book.hash.slice(0, 8), + }), + ); + }, + }); + + 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 {{n}} progress entr(ies)', { n: result.configsDownloaded })); + } + 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
s. + let toastType: 'info' | 'success' | 'warning' = 'info'; + let summary: string; + 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), + }); + 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: toastType, + message: summary, + }); + // Append a diagnostic entry to the persistent sync log. Status + // mirrors the toast classification: warning toast → 'partial' + // (some failures, some ok); info/success toast → 'success'. We + // also collapse the per-book failure phase + reason into the + // shape the UI expects (no internal type leakage into settings). + const status: WebDAVSyncLogStatus = result.failures > 0 ? 'partial' : 'success'; + const failedBooks: WebDAVSyncLogFailure[] | undefined = + result.failedBooks.length > 0 + ? result.failedBooks.map((f) => ({ + hash: f.hash, + title: f.title, + reason: `[${f.phase}] ${f.reason}`, + })) + : undefined; + const entry: WebDAVSyncLogEntry = { + id: uuidv4(), + startedAt, + finishedAt: Date.now(), + status, + trigger: 'manual', + totalBooks: result.totalBooks, + booksDownloaded: result.booksDownloaded, + filesUploaded: result.filesUploaded, + filesAlreadyInSync: result.filesAlreadyInSync, + configsUploaded: result.configsUploaded, + configsDownloaded: result.configsDownloaded, + coversUploaded: result.coversUploaded, + failures: result.failures, + summary, + failedBooks, + }; + await appendSyncLogEntry(entry); + } catch (e) { + const message = + e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED' + ? _('WebDAV authentication failed. Reconnect in Settings.') + : _('Sync failed: {{error}}', { error: (e as Error).message ?? String(e) }); + eventDispatcher.dispatch('toast', { type: 'error', message }); + // Persist a "failure" entry so the user can show what went wrong + // without rummaging through the dev console. We don't have a + // SyncLibraryResult to draw counters from (the run aborted + // before returning), so all the count fields stay zero except + // totalBooks for context. + const entry: WebDAVSyncLogEntry = { + id: uuidv4(), + startedAt, + finishedAt: Date.now(), + status: 'failure', + trigger: 'manual', + totalBooks: eligibleBooks.length, + booksDownloaded: 0, + filesUploaded: 0, + filesAlreadyInSync: 0, + configsUploaded: 0, + configsDownloaded: 0, + coversUploaded: 0, + failures: 0, + summary: message, + errorMessage: e instanceof Error ? e.message : String(e), + }; + await appendSyncLogEntry(entry); + } finally { + endSync(); + } + }; + + const description: string = isConfigured + ? _('Browsing {{path}} on {{server}}', { + path: normalizeRootPath(stored.rootPath || '/'), + server: stored.serverUrl, + }) + : _('Connect to a WebDAV server to browse your remote files.'); + + return ( +
+ + + {isConfigured ? ( +
+ {/* Sync controls — sub-category toggles, conflict strategy, + and a manual "Sync now" button. Mirrors the layout used + by KOSyncForm so users get a consistent surface. */} + + + + + + + + + + + {/* Sync history panel — diagnostic surface for users to + screenshot when reporting issues. Collapsed by default to + keep the page compact; opens to show the most-recent ten + runs with full counters and per-book failures. We render + even when the log is empty so users can find where it + lives before their first run. */} + + + + +
+ +
+
+ ) : ( +
+
{ + e.preventDefault(); + handleConnect(); + }} + > +
+ + {_('Server URL')} + + setUrl(e.target.value)} + /> +
+ +
+ + {_('Username')} + + setUsername(e.target.value)} + autoComplete='username' + /> +
+ +
+ + {_('Password')} + +
+ setPassword(e.target.value)} + autoComplete='current-password' + /> + +
+
+ +
+ + {_('Root Directory')} + + setRootPath(e.target.value)} + /> +
+ +
+ +
+
+
+ )} +
+ ); +}; + +export default WebDAVForm; diff --git a/apps/readest-app/src/components/settings/integrations/webdavBrowseUtils.ts b/apps/readest-app/src/components/settings/integrations/webdavBrowseUtils.ts new file mode 100644 index 00000000..9b69eb19 --- /dev/null +++ b/apps/readest-app/src/components/settings/integrations/webdavBrowseUtils.ts @@ -0,0 +1,139 @@ +import { + BsBook, + BsFiletypeJpg, + BsFiletypeJson, + BsFiletypeMd, + BsFiletypeOtf, + BsFiletypePdf, + BsFiletypePng, + BsFiletypeTtf, + BsFiletypeTxt, + BsFiletypeWoff, + BsFiletypeXml, +} from 'react-icons/bs'; +import { LuBookImage } from 'react-icons/lu'; +import { MdInsertDriveFile } from 'react-icons/md'; +import React from 'react'; +import { SUPPORTED_BOOK_EXTS } from '@/services/constants'; + +/** + * Pure presentational helpers for WebDAVBrowsePane. Lives apart from the + * component itself so the pane file stays focused on React state and + * JSX, and these utilities can be unit-tested in isolation if needed. + */ + +const BOOK_EXT_SET = new Set(SUPPORTED_BOOK_EXTS.map((e) => e.toLowerCase())); + +/** + * True when the filename's extension matches a reader-supported book + * format. We deliberately reuse `services/constants.SUPPORTED_BOOK_EXTS` + * — the same list that gates drag-drop and folder-import — so the + * download button in the browser only lights up for files the rest + * of readest can actually open after they land on disk. Keeping the + * three entry paths (drag-drop, folder import, WebDAV download) on + * one source of truth avoids the situation where a user can pull a + * file from the server but then can't ingest it. + */ +export const isSupportedBookExt = (filename: string): boolean => { + const m = filename.match(/\.([^.]+)$/); + const ext = m && m[1] ? m[1].toLowerCase() : ''; + return !!ext && BOOK_EXT_SET.has(ext); +}; + +/** Format a byte count as "{value} {unit}" (B / KB / MB / GB / TB). */ +export const formatSize = (bytes: number): string => { + if (!Number.isFinite(bytes) || bytes < 0) return ''; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + const formatted = + unit === 0 ? value.toFixed(0) : value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2); + return `${formatted} ${units[unit]}`; +}; + +/** + * Render the WebDAV-supplied last-modified timestamp in a compact, + * locale-aware form. Servers usually emit RFC 1123 via + * `getlastmodified`; ISO-8601 also parses. Unknown values render as + * empty so the row simply omits the field rather than showing + * "Invalid Date". + */ +export const formatLastModified = (raw: string): string => { + const ts = Date.parse(raw); + if (!Number.isFinite(ts)) return ''; + try { + return new Date(ts).toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + // Some embedded WebViews (older Android) reject options bags + // that combine date and time fields — fall back to the default + // formatter rather than showing nothing. + return new Date(ts).toLocaleString(); + } +}; + +/** + * Compact representation of a content hash for the metadata line. + * Keeps the leading 10 chars (entropy is uniform, so collisions + * require ~2^20 books) and ellipsizes the rest; the row's title + * attribute exposes the full hash on hover. + */ +export const formatShortHash = (hash: string): string => { + if (hash.length <= 12) return hash; + return `${hash.slice(0, 10)}…`; +}; + +/** + * Pick a per-file icon based on the entry's extension. Reader- + * recognised formats get a specific icon; everything else stays on + * the neutral document glyph. Aligned with `EXTS` in `libs/document.ts`. + */ +export const getEntryIcon = (filename: string): React.ComponentType<{ className?: string }> => { + const m = filename.match(/\.([^.]+)$/); + const ext = m && m[1] ? m[1].toLowerCase() : ''; + switch (ext) { + case 'pdf': + return BsFiletypePdf; + case 'txt': + return BsFiletypeTxt; + case 'md': + return BsFiletypeMd; + case 'fb2': + case 'fbz': + return BsFiletypeXml; + case 'cbz': + return LuBookImage; + case 'epub': + case 'mobi': + case 'azw': + case 'azw3': + return BsBook; + case 'png': + return BsFiletypePng; + case 'jpg': + case 'jpeg': + return BsFiletypeJpg; + case 'json': + return BsFiletypeJson; + case 'xml': + return BsFiletypeXml; + case 'otf': + return BsFiletypeOtf; + case 'ttf': + return BsFiletypeTtf; + case 'woff': + case 'woff2': + return BsFiletypeWoff; + default: + return MdInsertDriveFile; + } +}; diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index 2953ce2d..2746a5cd 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -24,6 +24,7 @@ import { ReadSettings, ReadwiseSettings, SystemSettings, + WebDAVSettings, } from '@/types/settings'; import { UserStorageQuota, UserDailyTranslationQuota } from '@/types/quota'; import { getDefaultMaxBlockSize, getDefaultMaxInlineSize } from '@/utils/config'; @@ -83,6 +84,20 @@ export const DEFAULT_HARDCOVER_SETTINGS = { lastSyncedAt: 0, } as HardcoverSettings; +export const DEFAULT_WEBDAV_SETTINGS = { + enabled: false, + serverUrl: '', + username: '', + password: '', + rootPath: '/', + syncProgress: true, + syncNotes: true, + syncBooks: false, + strategy: 'silent', + deviceId: '', + lastSyncedAt: 0, +} as WebDAVSettings; + export const DEFAULT_SYSTEM_SETTINGS: Partial = { keepLogin: false, autoUpload: true, @@ -129,6 +144,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial = { kosync: DEFAULT_KOSYNC_SETTINGS, readwise: DEFAULT_READWISE_SETTINGS, hardcover: DEFAULT_HARDCOVER_SETTINGS, + webdav: DEFAULT_WEBDAV_SETTINGS, aiSettings: DEFAULT_AI_SETTINGS, lastSyncedAtBooks: 0, diff --git a/apps/readest-app/src/services/webdav/WebDAVClient.ts b/apps/readest-app/src/services/webdav/WebDAVClient.ts new file mode 100644 index 00000000..bf6251dc --- /dev/null +++ b/apps/readest-app/src/services/webdav/WebDAVClient.ts @@ -0,0 +1,586 @@ +import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; +import { isTauriAppPlatform } from '../environment'; + +/** + * Minimal WebDAV client used by the Integrations panel. + * + * Goals (intentionally narrow): authenticate against the server, prove the + * configured root directory is reachable, and list its immediate children. + * Anything more elaborate (downloading book binaries, uploading sync state, + * etc.) belongs in higher-level sync code that builds on top of this client. + * + * Notes on transport: + * - In Tauri we use `@tauri-apps/plugin-http` to bypass the renderer's CORS + * sandbox; in the browser/web build we fall back to `window.fetch` and + * rely on the server (or the user's reverse proxy) to send the right + * `Access-Control-Allow-*` headers. + * - All requests use HTTP Basic Auth; users supply username/password in + * plaintext and we compose the header here. We never persist the + * raw password in this layer — that is the caller's responsibility. + */ + +export interface WebDAVEntry { + /** File or directory name (single path segment, decoded). */ + name: string; + /** Absolute path on the server, including leading slash, decoded. */ + path: string; + /** True when the entry is a collection (directory). */ + isDirectory: boolean; + /** Content length in bytes when reported by the server (files only). */ + size?: number; + /** Server-provided modification timestamp, if any. */ + lastModified?: string; +} + +export interface WebDAVConfig { + /** Server root URL, e.g. `https://dav.example.com`. Trailing slash optional. */ + serverUrl: string; + username: string; + password: string; +} + +export interface WebDAVConnectResult { + success: boolean; + /** Translation-friendly short message describing the failure, when any. */ + message?: string; + /** HTTP status surfaced from the server, if the request reached it. */ + status?: number; +} + +const PROPFIND_BODY = ` + + + + + + + +`; + +/** + * Strip the trailing slash off a base URL so we can re-add slashes + * deterministically when joining paths. + */ +const trimTrailingSlash = (s: string) => s.replace(/\/+$/, ''); + +/** + * Normalise an arbitrary user-entered path into a server-absolute one + * with a leading slash and no trailing slash (except for the root). + * + * Examples: + * '' -> '/' + * '/' -> '/' + * 'books' -> '/books' + * '/books/' -> '/books' + * 'a/b' -> '/a/b' + */ +export const normalizeRootPath = (path: string): string => { + if (!path) return '/'; + let p = path.trim(); + if (!p.startsWith('/')) p = `/${p}`; + if (p.length > 1) p = p.replace(/\/+$/, ''); + return p; +}; + +/** + * Encode every path segment for use in a URL while preserving '/' separators. + * Spaces and unicode characters get %-escaped; existing %-escapes are + * deliberately left alone so we don't double-encode caller input. + * + * The naive `encodeURIComponent(segment)` would re-escape the `%` in an + * already-escaped triplet (e.g. `%20` becomes `%2520`), which silently + * corrupts any path the caller chose to pre-encode. Tokenise the segment + * into "already-escaped %XX" runs and "everything else" first, then + * encode only the latter. + */ +// Two regexes so the `g`-flag splitter and the non-`g` classifier don't +// share lastIndex state — `RegExp.test` on a `g` regex is stateful and +// would skip every other token here. +const PERCENT_ESCAPE_SPLIT_RE = /(%[0-9A-Fa-f]{2})/g; +const PERCENT_ESCAPE_RE = /^%[0-9A-Fa-f]{2}$/; +const encodeSegment = (segment: string): string => { + if (!segment) return ''; + // `split` with a capturing group keeps the matched delimiters in the + // resulting array, so we end up with alternating + // "raw text"/"%XX"/"raw text"/"%XX" tokens. + return segment + .split(PERCENT_ESCAPE_SPLIT_RE) + .map((token) => (PERCENT_ESCAPE_RE.test(token) ? token : encodeURIComponent(token))) + .join(''); +}; +const encodePath = (path: string): string => path.split('/').map(encodeSegment).join('/'); + +const buildUrl = (serverUrl: string, path: string): string => { + const base = trimTrailingSlash(serverUrl); + const normalized = normalizeRootPath(path); + return `${base}${encodePath(normalized)}`; +}; + +/** + * Public form of {@link buildUrl} for callers (e.g. the native streaming + * downloader) that need to issue raw HTTP requests without going through + * `requestWithMethod`. + */ +export const buildRequestUrl = buildUrl; + +const buildAuthHeader = (username: string, password: string): string => { + // btoa handles ASCII; for unicode credentials we round-trip through + // TextEncoder so non-Latin1 characters don't throw. + const raw = `${username}:${password}`; + if (typeof btoa === 'function') { + try { + return `Basic ${btoa(raw)}`; + } catch { + // Fall through to the manual encoder below. + } + } + const bytes = new TextEncoder().encode(raw); + let binary = ''; + bytes.forEach((b) => (binary += String.fromCharCode(b))); + return `Basic ${btoa(binary)}`; +}; + +/** Public alias for callers that need to build the same Basic header. */ +export const buildBasicAuthHeader = buildAuthHeader; + +const getFetch = () => (isTauriAppPlatform() ? tauriFetch : window.fetch.bind(window)); + +/** + * Pull the inner text of the first matching tag, regardless of namespace + * prefix. WebDAV servers are inconsistent about prefixes (`d:`, `D:`, none) + * so a tolerant local-name match keeps the parser robust without reaching + * for a full XML library. + */ +const extractTagText = (xml: string, localName: string): string | undefined => { + const re = new RegExp( + `<(?:[a-zA-Z0-9]+:)?${localName}[^>]*>([\\s\\S]*?)<\\/(?:[a-zA-Z0-9]+:)?${localName}>`, + 'i', + ); + const match = re.exec(xml); + return match ? match[1]!.trim() : undefined; +}; + +/** + * `` contains zero or one `` child to flag + * directories. The element may be self-closing or contain whitespace, hence + * the relaxed regex. + */ +const isCollection = (resourceTypeXml: string | undefined): boolean => { + if (!resourceTypeXml) return false; + return /<(?:[a-zA-Z0-9]+:)?collection\b/i.test(resourceTypeXml); +}; + +const splitResponses = (xml: string): string[] => { + const responses: string[] = []; + const re = /<(?:[a-zA-Z0-9]+:)?response\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?response>/gi; + let match: RegExpExecArray | null; + while ((match = re.exec(xml)) !== null) { + responses.push(match[1]!); + } + return responses; +}; + +/** + * Decode the path returned in ``. WebDAV servers may return a fully + * qualified URL or a server-absolute path; both branches resolve to a path + * with leading slash and percent-decoded segments. + */ +const decodeHref = (href: string, serverOrigin: string): string => { + let raw = href.trim(); + if (/^https?:\/\//i.test(raw)) { + try { + raw = new URL(raw).pathname; + } catch { + // Leave as-is; downstream still gets a sensible string. + } + } else if (raw && !raw.startsWith('/') && serverOrigin) { + // Some servers return relative paths — make them absolute against the + // origin so the consumer doesn't have to special-case them. + try { + raw = new URL(raw, serverOrigin).pathname; + } catch { + // Fall through. + } + } + try { + return decodeURIComponent(raw); + } catch { + return raw; + } +}; + +/** + * Strip the server's base path (the path component of the configured + * serverUrl, e.g. `/dav`) off a server-absolute href so the result is + * expressed in the same coordinate system as the user-facing rootPath. + * + * Without this, configuring `serverUrl = https://host/dav` with + * `rootPath = /apps/books` causes hrefs like `/dav/apps/books/Foo` to be + * stored verbatim and re-prefixed with `/dav` on the next PROPFIND, yielding + * `/dav/dav/apps/books/Foo` — a 404 trap. + */ +const stripServerBasePath = (absolutePath: string, serverUrl: string): string => { + let basePath = ''; + try { + basePath = new URL(serverUrl).pathname.replace(/\/+$/, ''); + } catch { + return absolutePath; + } + if (!basePath || basePath === '/') return absolutePath; + if (absolutePath === basePath) return '/'; + if (absolutePath.startsWith(`${basePath}/`)) { + return absolutePath.slice(basePath.length) || '/'; + } + return absolutePath; +}; + +/** + * Simplest possible reachability probe: PROPFIND with Depth: 0 against the + * configured root. Used by the Connect button to fail fast on bad + * URL/credentials/root combinations before we ever try to list children. + */ +export const checkConnection = async ( + config: WebDAVConfig, + rootPath: string, +): Promise => { + if (!config.serverUrl) { + return { success: false, message: 'Server URL is required' }; + } + const url = buildUrl(config.serverUrl, rootPath); + const fetchFn = getFetch(); + try { + const response = await fetchFn(url, { + method: 'PROPFIND', + headers: { + Authorization: buildAuthHeader(config.username, config.password), + Depth: '0', + 'Content-Type': 'application/xml; charset=utf-8', + }, + body: PROPFIND_BODY, + }); + if (response.status === 207 || response.status === 200) { + return { success: true, status: response.status }; + } + if (response.status === 401 || response.status === 403) { + return { success: false, status: response.status, message: 'Authentication failed' }; + } + if (response.status === 404) { + return { success: false, status: response.status, message: 'Root directory not found' }; + } + return { + success: false, + status: response.status, + message: `Unexpected server response (${response.status})`, + }; + } catch (e) { + return { success: false, message: (e as Error).message || 'Network error' }; + } +}; + +/** + * List the immediate children of the given path on the server. + * + * The PROPFIND with `Depth: 1` returns the directory itself plus its + * children; we drop the self-entry by comparing decoded hrefs against the + * normalised root, which is more reliable than trusting the server-set + * `` (some servers omit it for the self-entry). + */ +export const listDirectory = async ( + config: WebDAVConfig, + rootPath: string, +): Promise => { + 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, + }); + if (response.status !== 207 && response.status !== 200) { + throw new Error(`PROPFIND failed with status ${response.status}`); + } + const xml = await response.text(); + let serverOrigin = ''; + try { + serverOrigin = new URL(config.serverUrl).origin; + } catch { + serverOrigin = ''; + } + + const entries: WebDAVEntry[] = []; + for (const block of splitResponses(xml)) { + const hrefRaw = extractTagText(block, 'href'); + if (!hrefRaw) continue; + const decodedAbsolute = decodeHref(hrefRaw, serverOrigin); + // Re-express the server-absolute href in the same coordinate system as + // the user's rootPath by stripping the serverUrl's base path. The + // resulting `path` is what gets passed back into `buildUrl` on the next + // PROPFIND — keeping the two in sync prevents accidental re-prefixing. + const appPath = stripServerBasePath(decodedAbsolute, config.serverUrl); + const trimmedPath = appPath.replace(/\/+$/, '') || '/'; + // Skip the self entry — it points at the directory we asked about. + if (trimmedPath === root || trimmedPath === `${root}/`.replace(/\/+$/, '')) continue; + + const resourceType = extractTagText(block, 'resourcetype'); + const isDir = isCollection(resourceType); + // Prefer the path's last segment over : it always reflects + // what the server stores, which is what users will recognise. + const segments = trimmedPath.split('/').filter(Boolean); + const name = segments[segments.length - 1] ?? trimmedPath; + const sizeStr = extractTagText(block, 'getcontentlength'); + const lastModified = extractTagText(block, 'getlastmodified'); + entries.push({ + name, + path: trimmedPath, + isDirectory: isDir, + size: sizeStr && !isDir ? Number(sizeStr) : undefined, + lastModified, + }); + } + + // Show directories first, then files; alphabetic within each bucket. + entries.sort((a, b) => { + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); + }); + return entries; +}; + +/** + * Error thrown by file-level WebDAV operations. `status` carries the HTTP + * status when the request reached the server; `code === 'NOT_FOUND'` + * uniquely identifies a 404 so the higher-level sync layer can branch on + * "remote has no such file yet" without parsing messages. + */ +export class WebDAVRequestError extends Error { + status?: number; + code?: 'NOT_FOUND' | 'AUTH_FAILED' | 'NETWORK'; + + constructor(message: string, status?: number, code?: WebDAVRequestError['code']) { + super(message); + this.name = 'WebDAVRequestError'; + this.status = status; + this.code = code; + } +} + +const requestWithMethod = async ( + config: WebDAVConfig, + path: string, + method: string, + init: { headers?: Record; body?: BodyInit | null } = {}, +): Promise => { + const url = buildUrl(config.serverUrl, path); + const fetchFn = getFetch(); + const headers: Record = { + Authorization: buildAuthHeader(config.username, config.password), + ...(init.headers || {}), + }; + try { + return await fetchFn(url, { method, headers, body: init.body ?? null }); + } catch (e) { + throw new WebDAVRequestError((e as Error).message || 'Network error', undefined, 'NETWORK'); + } +}; + +/** + * GET a file's content. Returns `null` when the server replies 404, so the + * caller can treat "first push" as a non-error code path. Any other + * non-2xx surfaces as a `WebDAVRequestError` with the status attached. + */ +export const getFile = async (config: WebDAVConfig, path: string): Promise => { + const response = await requestWithMethod(config, path, 'GET'); + if (response.status === 404) return null; + if (response.status === 401 || response.status === 403) { + throw new WebDAVRequestError('Authentication failed', response.status, 'AUTH_FAILED'); + } + if (response.status < 200 || response.status >= 300) { + throw new WebDAVRequestError(`GET failed with status ${response.status}`, response.status); + } + return response.text(); +}; + +/** + * GET a file's binary content. Returns `null` when the server replies 404. + */ +export const getFileBinary = async ( + config: WebDAVConfig, + path: string, +): Promise => { + const response = await requestWithMethod(config, path, 'GET'); + if (response.status === 404) return null; + if (response.status === 401 || response.status === 403) { + throw new WebDAVRequestError('Authentication failed', response.status, 'AUTH_FAILED'); + } + if (response.status < 200 || response.status >= 300) { + throw new WebDAVRequestError(`GET failed with status ${response.status}`, response.status); + } + return response.arrayBuffer(); +}; + +/** + * PUT a file. Parent directories must exist first — see `ensureDirectory` + * which calls `mkdir` for every ancestor. The body is `string` for our + * JSON metadata files; book binaries take a more specialised path that's + * not covered by this helper. + */ +export const putFile = async ( + config: WebDAVConfig, + path: string, + body: string, + contentType: string = 'application/json; charset=utf-8', +): Promise => { + const response = await requestWithMethod(config, path, 'PUT', { + headers: { 'Content-Type': contentType }, + body, + }); + if (response.status === 401 || response.status === 403) { + throw new WebDAVRequestError('Authentication failed', response.status, 'AUTH_FAILED'); + } + // 200 (existing file replaced), 201 (created), 204 (no content). Some + // servers also return 207 multistatus for PUT — accept that too rather + // than fail loudly on a corner-case server quirk. + if (![200, 201, 204, 207].includes(response.status)) { + throw new WebDAVRequestError(`PUT failed with status ${response.status}`, response.status); + } +}; + +/** + * Binary equivalent of `putFile`. Used for book files (epub / pdf / ...) — + * the body is an `ArrayBuffer` and the content type defaults to a + * generic octet-stream so the server doesn't try to interpret it. + * + * Same status-code handling as `putFile`. Parents must already exist. + */ +export const putFileBinary = async ( + config: WebDAVConfig, + path: string, + body: ArrayBuffer, + contentType: string = 'application/octet-stream', +): Promise => { + const response = await requestWithMethod(config, path, 'PUT', { + headers: { 'Content-Type': contentType }, + body, + }); + if (response.status === 401 || response.status === 403) { + throw new WebDAVRequestError('Authentication failed', response.status, 'AUTH_FAILED'); + } + if (![200, 201, 204, 207].includes(response.status)) { + throw new WebDAVRequestError(`PUT failed with status ${response.status}`, response.status); + } +}; + +/** + * HEAD request, returning the remote file's metadata when present. + * Returns `null` on 404 so callers can branch on "remote has no copy + * yet" without try/catch ceremony. Other failures throw. + * + * `size` is parsed from `Content-Length`; some WebDAV servers omit it on + * HEAD (notably for chunked-upload landing files), in which case we + * return `undefined` for the field and the caller should treat the + * remote as "exists, size unknown" — usually that's enough to skip a + * needless re-upload. + */ +export const headFile = async ( + config: WebDAVConfig, + path: string, +): Promise<{ size?: number; etag?: string } | null> => { + const response = await requestWithMethod(config, path, 'HEAD'); + if (response.status === 404) return null; + if (response.status === 401 || response.status === 403) { + throw new WebDAVRequestError('Authentication failed', response.status, 'AUTH_FAILED'); + } + if (response.status < 200 || response.status >= 300) { + throw new WebDAVRequestError(`HEAD failed with status ${response.status}`, response.status); + } + const sizeHeader = response.headers.get('content-length'); + const etag = response.headers.get('etag') ?? undefined; + const size = sizeHeader ? Number(sizeHeader) : undefined; + return { size: Number.isFinite(size) ? (size as number) : undefined, etag }; +}; + +/** + * MKCOL on a single path. 405 is the standard "directory already exists" + * response on most WebDAV servers — we fold that into success so callers + * can call this idempotently without first probing existence. + */ +export const mkdir = async (config: WebDAVConfig, path: string): Promise => { + const response = await requestWithMethod(config, path, 'MKCOL'); + if (response.status === 201 || response.status === 405) return; + if (response.status === 401 || response.status === 403) { + throw new WebDAVRequestError('Authentication failed', response.status, 'AUTH_FAILED'); + } + if (response.status === 409) { + // Conflict — usually means a parent directory is missing. Surface as + // not-found-style so the caller can re-run ensureDirectory. + throw new WebDAVRequestError('Parent directory missing', 409); + } + throw new WebDAVRequestError(`MKCOL failed with status ${response.status}`, response.status); +}; + +/** + * Walk every ancestor of `path` and MKCOL it. Idempotent thanks to the + * 405-as-ok behaviour in `mkdir`. Use this before any PUT to a deep path. + * + * Imported lazily from WebDAVPaths via the caller — keeping this client + * file free of higher-level layout knowledge. + */ +export const ensureDirectory = async (config: WebDAVConfig, ancestors: string[]): Promise => { + for (const dir of ancestors) { + await mkdir(config, dir); + } +}; + +/** Existence probe via HEAD; returns false on 404, true on 2xx, throws otherwise. */ +export const exists = async (config: WebDAVConfig, path: string): Promise => { + const response = await requestWithMethod(config, path, 'HEAD'); + if (response.status === 404) return false; + if (response.status >= 200 && response.status < 300) return true; + if (response.status === 401 || response.status === 403) { + throw new WebDAVRequestError('Authentication failed', response.status, 'AUTH_FAILED'); + } + throw new WebDAVRequestError(`HEAD failed with status ${response.status}`, response.status); +}; + +/** Best-effort delete; 404 is treated as success (already gone). */ +export const deleteFile = async (config: WebDAVConfig, path: string): Promise => { + const response = await requestWithMethod(config, path, 'DELETE'); + if (response.status === 404) return; + if (response.status >= 200 && response.status < 300) return; + if (response.status === 401 || response.status === 403) { + throw new WebDAVRequestError('Authentication failed', response.status, 'AUTH_FAILED'); + } + throw new WebDAVRequestError(`DELETE failed with status ${response.status}`, response.status); +}; + +/** + * Recursively DELETE a collection (directory) and everything below it. + * + * Per RFC 4918 §9.6.1, `DELETE` on a collection is required to delete + * the entire subtree, and `Depth: infinity` is the only legal value + * (servers MUST treat a missing Depth on a collection-DELETE as + * `infinity`). Some implementations (older Apache mod_dav, a handful + * of community servers) reject the request with 400/412 if the header + * is absent — sending it explicitly removes that ambiguity at zero + * cost. NextCloud, sabre/dav, Synology and Microsoft IIS all accept + * the explicit form. + * + * 404 is treated as success: a directory that already isn't there is + * the desired post-condition. + */ +export const deleteDirectory = async (config: WebDAVConfig, path: string): Promise => { + const response = await requestWithMethod(config, path, 'DELETE', { + headers: { Depth: 'infinity' }, + }); + if (response.status === 404) return; + if (response.status >= 200 && response.status < 300) return; + if (response.status === 401 || response.status === 403) { + throw new WebDAVRequestError('Authentication failed', response.status, 'AUTH_FAILED'); + } + throw new WebDAVRequestError(`DELETE failed with status ${response.status}`, response.status); +}; diff --git a/apps/readest-app/src/services/webdav/WebDAVPaths.ts b/apps/readest-app/src/services/webdav/WebDAVPaths.ts new file mode 100644 index 00000000..ae66a497 --- /dev/null +++ b/apps/readest-app/src/services/webdav/WebDAVPaths.ts @@ -0,0 +1,105 @@ +import { Book } from '@/types/book'; +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. + * + * Tree: + * / + * Readest/ + * library.json ← shared index + * books/ + * / + * . ← the book file + * cover.png ← optional + * config.json ← progress + booknotes + * + * 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. + */ + +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'; + +/** + * Normalise the user-entered rootPath so the rest of the code can rely on + * a leading slash and no trailing slash (root = "/"). + */ +export const normalizeRoot = (rootPath: string | undefined): string => { + if (!rootPath) return '/'; + let p = rootPath.trim(); + if (!p.startsWith('/')) p = `/${p}`; + if (p.length > 1) p = p.replace(/\/+$/, ''); + return p; +}; + +/** Join normalised path segments with single slashes, leading-slash kept. */ +const join = (...parts: string[]): string => { + const cleaned = parts.map((p) => p.replace(/^\/+|\/+$/g, '')).filter((p) => p.length > 0); + return `/${cleaned.join('/')}`; +}; + +/** Absolute path of the Readest base directory (where library.json lives). */ +export const buildBasePath = (rootPath: string): string => + join(normalizeRoot(rootPath), WEBDAV_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); + +/** 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); + +/** Absolute path of the shared library.json index. */ +export const buildLibraryPath = (rootPath: string): string => + join(buildBasePath(rootPath), WEBDAV_LIBRARY_FILE); + +/** + * Friendly book file name "." used inside the + * per-hash directory. Collisions across books are impossible because + * each book lives in its own hash dir; collisions inside a single + * hash dir are also impossible because there's only ever one book file. + * + * Re-uses readest's existing `makeSafeFilename` so naming rules are + * consistent with the local on-disk layout (which is `/.<ext>`). + */ +export const buildBookFileName = (book: Book): string => { + const ext = EXTS[book.format] || 'bin'; + const baseName = book.sourceTitle || book.title || book.hash; + return `${makeSafeFilename(baseName)}.${ext}`; +}; + +/** Absolute path of the book file, including the friendly file name. */ +export const buildBookFilePath = (rootPath: string, book: Book): string => + join(buildBookDirPath(rootPath, book.hash), buildBookFileName(book)); + +/** Absolute path of the book cover image. */ +export const buildBookCoverPath = (rootPath: string, bookHash: string): string => + join(buildBookDirPath(rootPath, bookHash), WEBDAV_BOOK_COVER_FILE); + +/** + * Walk the parents of an absolute path, top-down, so callers can + * MKCOL each segment idempotently before writing a file. Excludes the + * leaf itself. + * + * Example: ancestorsOf('/a/b/c/file.json') -> ['/a', '/a/b', '/a/b/c'] + */ +export const ancestorsOf = (absolutePath: string): string[] => { + const segments = absolutePath.split('/').filter(Boolean); + if (segments.length <= 1) return []; + const out: string[] = []; + let acc = ''; + for (let i = 0; i < segments.length - 1; i += 1) { + acc += `/${segments[i]}`; + out.push(acc); + } + return out; +}; diff --git a/apps/readest-app/src/services/webdav/WebDAVSync.ts b/apps/readest-app/src/services/webdav/WebDAVSync.ts new file mode 100644 index 00000000..8ab7f2d5 --- /dev/null +++ b/apps/readest-app/src/services/webdav/WebDAVSync.ts @@ -0,0 +1,1003 @@ +import { Book, BookConfig, BookNote } from '@/types/book'; +import { WebDAVSettings } from '@/types/settings'; +import { + WebDAVConfig, + buildBasicAuthHeader, + buildRequestUrl, + deleteDirectory, + ensureDirectory, + getFile, + getFileBinary, + headFile, + listDirectory, + putFile, + putFileBinary, + WebDAVRequestError, +} from './WebDAVClient'; +import { + ancestorsOf, + buildBasePath, + buildBookConfigPath, + buildBookCoverPath, + buildBookDirPath, + buildBookFilePath, + buildLibraryPath, + WEBDAV_BOOKS_DIR, +} from './WebDAVPaths'; + +/** + * 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. + */ +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 + * two 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 `pullBookConfig` only ever merges fields the server + * actually carries (see filteredRemote spread there) — so a malicious + * or buggy server can't somehow inject viewSettings into a local config. + */ +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(), + }; +}; + +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; + } +}; + +const toClientConfig = (settings: WebDAVSettings): WebDAVConfig => ({ + serverUrl: settings.serverUrl, + username: settings.username, + password: settings.password, +}); + +/** + * 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. + * + * Implementation detail: 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). + */ +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()); +}; + +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; +} + +/** + * Pull `<rootPath>/Readest/books/<hash>/config.json`, merge into the + * provided local config, and return the merged result. The caller is + * responsible for writing the merged config back via the bookData store + * (so this module stays free of React/store dependencies). + * + * Returns `applied: false` when the remote file doesn't exist yet or its + * envelope is malformed. Auth failures and other server errors propagate + * as `WebDAVRequestError` so the caller can show a toast. + */ +export const pullBookConfig = async ( + settings: WebDAVSettings, + book: Book, + localConfig: BookConfig, +): Promise<PullResult> => { + const path = buildBookConfigPath(settings.rootPath, book.hash); + const raw = await getFile(toClientConfig(settings), path); + const remote = parseRemotePayload(raw); + if (!remote) { + return { applied: false }; + } + // Top-level field merge: same per-config updatedAt LWW that the native + // cloud sync uses in useProgressSync.applyRemoteProgress. + const remoteConfigUpdated = remote.config.updatedAt ?? remote.updatedAt; + const localConfigUpdated = localConfig.updatedAt ?? 0; + // Drop null/undefined fields the server might have left in (e.g. an + // older client that didn't write `xpointer`). Crucially, this also + // means the spread below can NEVER introduce server-driven values for + // keys the server isn't supposed to care about (viewSettings, + // searchConfig, RSVP) — those keys never appear in `remote.config` to + // begin with because `buildRemotePayload` strips them on push. The + // invariant "wire envelope only carries reading state" therefore + // protects pull as well as push. + const filteredRemote = Object.fromEntries( + Object.entries(remote.config).filter(([, v]) => v !== null && v !== undefined), + ) as Partial<BookConfig>; + const mergedConfig: BookConfig = + remoteConfigUpdated >= localConfigUpdated + ? ({ ...localConfig, ...filteredRemote } as BookConfig) + : ({ ...filteredRemote, ...localConfig } as BookConfig); + const mergedNotes = mergeNotes(localConfig.booknotes ?? [], remote.booknotes ?? []); + mergedConfig.booknotes = mergedNotes; + return { + applied: true, + mergedConfig, + mergedNotes, + remoteDeviceId: remote.writerDeviceId, + }; +}; + +/** + * Push the local BookConfig to the remote. Creates parent directories as + * needed (idempotent — MKCOL 405 is treated as success). The caller + * provides a stable deviceId so we can record which device last wrote + * the file. + * + * If the remote already has a strictly newer payload, we still overwrite — + * deciding whether to push is the caller's responsibility (it has the + * strategy / preview-mode context). This function is the dumb mechanism. + */ +export const pushBookConfig = async ( + settings: WebDAVSettings, + book: Book, + config: BookConfig, + deviceId: string, +): Promise<void> => { + const client = toClientConfig(settings); + const dirPath = buildBookDirPath(settings.rootPath, book.hash); + const path = buildBookConfigPath(settings.rootPath, book.hash); + // Ensure every ancestor up to and including the per-book directory. + const dirs = [...ancestorsOf(`${dirPath}/.placeholder`), dirPath]; + await ensureDirectory(client, dirs); + const payload = buildRemotePayload(book, config, deviceId); + try { + await putFile(client, path, JSON.stringify(payload)); + } catch (e) { + if (e instanceof WebDAVRequestError && e.status === 409) { + // 409 from PUT means a parent disappeared between our MKCOL and the + // PUT. Re-create the chain and retry once. + await ensureDirectory(client, dirs); + await putFile(client, path, JSON.stringify(payload)); + return; + } + throw e; + } +}; + +export interface BookFileSource { + /** Bytes to upload. The caller owns reading them off disk. */ + bytes: ArrayBuffer; + /** Total byte length, used for the HEAD-vs-local size short-circuit. */ + size: number; +} + +export type BookFileLoader = () => Promise<BookFileSource | null>; + +/** + * Streaming alternative to {@link BookFileLoader}: hands the syncer a + * cheap-to-resolve metadata bundle (just `size` is needed for the + * HEAD-vs-local short-circuit) plus an `upload` callback that streams + * the bytes directly from disk to the WebDAV server, never letting the + * full file land in the JS heap. + * + * Why this exists separately from BookFileLoader: + * - `loader` materialises the entire file as an ArrayBuffer up-front, + * which is fine for small books (a few MB) but a hard kill switch + * for a library of multi-hundred-megabyte PDFs: each book opens + * its own ArrayBuffer in the renderer's V8 heap, and even with + * sequential `pushBookFile` calls the GC can't reliably free them + * before the next `arrayBuffer()` allocates. After 2–3 large + * books the renderer hits the heap ceiling and the WebView + * crashes — symptom: the entire UI goes blank mid-sync. + * - The streaming loader hands the file path to a Tauri command + * (`upload_file` via `tauriUpload`) which reads + uploads off the + * Rust side in chunks. JS heap stays flat regardless of book size. + * + * Callers should provide `streamingLoader` when running on Tauri (where + * `tauriUpload` is available) and fall back to `loader` on web targets + * that don't have a streaming HTTP primitive. + */ +export interface BookFileStreamingSource { + /** File size in bytes, for the HEAD-vs-local short-circuit. */ + size: number; + /** + * Stream the bytes to `remoteUrl` via PUT. Authentication headers + * are pre-baked by the caller (typically via {@link buildBasicAuthHeader}) + * because the streaming primitive can't see the WebDAV settings. + * Returns `true` on success, `false` when the upload was skipped or + * failed in a way the caller wants to swallow. + */ + upload: (remoteUrl: string, headers: Record<string, string>) => Promise<boolean>; +} + +export type BookFileStreamingLoader = () => Promise<BookFileStreamingSource | null>; + +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'; +} + +/** + * Upload the book file binary to + * <rootPath>/Readest/books/<hash>/<safe-title>.<ext> + * + * Idempotency story: the path lives under the per-hash directory, so the + * remote location is uniquely determined by `book.hash` (the local hash + * of the file's content). A HEAD probe + size compare lets us skip a + * re-upload whenever the remote already has a copy of the matching size, + * which in practice means we only PUT bytes the very first time a book + * is seen on a device. Renaming a book locally never re-uploads — we + * MOVE the friendly file name in a future patch (Step 3). + * + * Two upload modes are supported and mutually exclusive: + * - {@link BookFileStreamingLoader} (preferred when available): hands + * the file path off to a Tauri-side streamer. Constant JS heap + * regardless of book size — required for libraries with multi- + * hundred-megabyte PDFs, where buffering was crashing the renderer. + * - {@link BookFileLoader} (fallback): materialises the file as an + * ArrayBuffer in JS, then PUTs it via `putFileBinary`. Fine for + * small files; OOMs the WebView for large libraries. + * + * Pass `streamingLoader` when running on Tauri; pass `loader` otherwise. + * Passing both makes streaming win — the HEAD short-circuit is shared + * either way so steady-state syncs cost a single round-trip per book. + */ +export const pushBookFile = async ( + settings: WebDAVSettings, + book: Book, + loader: BookFileLoader, + streamingLoader?: BookFileStreamingLoader, +): Promise<PushBookFileResult> => { + const client = toClientConfig(settings); + const dirPath = buildBookDirPath(settings.rootPath, book.hash); + const path = buildBookFilePath(settings.rootPath, book); + + // Cheap probe first — if the remote already has a same-sized blob we + // know it's the same content (hash-keyed directory, single file inside). + let remoteHead: { size?: number } | null = null; + try { + remoteHead = await headFile(client, path); + } catch (e) { + // HEAD failures other than 404 propagate; the caller decides whether + // to surface them as a toast. + if (!(e instanceof WebDAVRequestError) || e.code !== 'NETWORK') throw e; + } + + // Streaming path: resolve metadata only, then stream bytes off disk. + // The metadata fetch (file.size) doesn't read the body, so heap + // stays flat even for gigabyte-scale PDFs. + if (streamingLoader) { + const meta = await streamingLoader(); + if (!meta) { + // Loader returned null — most often "file isn't on this device"; + // check the buffered loader as a last resort so callers that + // wired both keep working when one path is unavailable. + if (!loader) return { uploaded: false, reason: 'no-source' }; + } else { + if (remoteHead && remoteHead.size === meta.size) { + return { uploaded: false, reason: 'remote-matches' }; + } + const dirs = [...ancestorsOf(`${dirPath}/.placeholder`), dirPath]; + await ensureDirectory(client, dirs); + const remoteUrl = buildRequestUrl(settings.serverUrl, path); + const headers: Record<string, string> = { + Authorization: buildBasicAuthHeader(settings.username, settings.password), + }; + const ok = await meta.upload(remoteUrl, headers); + if (!ok) { + // Some upstream servers return 409 if a parent is recreated + // mid-PUT. Mirror the buffered path's one-shot retry: re- + // ensure directories and try again. The caller's `upload` + // implementation owns the actual error mapping; we just give + // it one more chance. + await ensureDirectory(client, dirs); + const retried = await meta.upload(remoteUrl, headers); + if (!retried) { + throw new WebDAVRequestError('Streaming upload failed', undefined, 'NETWORK'); + } + } + return { uploaded: true }; + } + } + + const local = await loader(); + if (!local) { + return { uploaded: false, reason: 'no-source' }; + } + + if (remoteHead && remoteHead.size === local.size) { + return { uploaded: false, reason: 'remote-matches' }; + } + + const dirs = [...ancestorsOf(`${dirPath}/.placeholder`), dirPath]; + await ensureDirectory(client, dirs); + try { + await putFileBinary(client, path, local.bytes); + } catch (e) { + if (e instanceof WebDAVRequestError && e.status === 409) { + await ensureDirectory(client, dirs); + await putFileBinary(client, path, local.bytes); + } else { + throw e; + } + } + return { uploaded: true }; +}; + +/** + * Upload the book's cover image to + * <rootPath>/Readest/books/<hash>/cover.png + * + * Same idempotency model as `pushBookFile`: HEAD-probe + size compare, + * then PUT only when missing or sized differently. Covers are tiny + * (typically 50–200 KB), but multiplied across a 100-book library they + * still add up — the HEAD short-circuit keeps the steady state cheap. + * + * Why a separate function (rather than rolling into `pushBookFile`): + * - Most readers don't enable `syncBooks` in v1; covers travel with + * books rather than configs, but should be a stand-alone primitive + * so a future "syncCovers" toggle can reuse it. + * - readest fetches custom covers via metadata services that produce + * better art than what's embedded in the EPUB. Those custom covers + * can't be regenerated on the receiving device, so syncing them is + * the only way to preserve user choice across devices. + */ +export const pushBookCover = async ( + settings: WebDAVSettings, + bookHash: string, + loader: BookFileLoader, +): Promise<PushBookFileResult> => { + const client = toClientConfig(settings); + const dirPath = buildBookDirPath(settings.rootPath, bookHash); + const path = buildBookCoverPath(settings.rootPath, bookHash); + + let remoteHead: { size?: number } | null = null; + try { + remoteHead = await headFile(client, path); + } catch (e) { + if (!(e instanceof WebDAVRequestError) || e.code !== 'NETWORK') throw e; + } + + const local = await loader(); + // Covers are best-effort — books without a local cover are a normal + // state (TXT/MD without metadata), so a missing source is not a + // failure and the caller shouldn't toast about it. + if (!local) return { uploaded: false, reason: 'no-source' }; + + if (remoteHead && remoteHead.size === local.size) { + return { uploaded: false, reason: 'remote-matches' }; + } + + const dirs = [...ancestorsOf(`${dirPath}/.placeholder`), dirPath]; + await ensureDirectory(client, dirs); + try { + await putFileBinary(client, path, local.bytes, 'image/png'); + } catch (e) { + if (e instanceof WebDAVRequestError && e.status === 409) { + await ensureDirectory(client, dirs); + await putFileBinary(client, path, local.bytes, 'image/png'); + } else { + throw e; + } + } + return { uploaded: true }; +}; + +export const pullBookFile = async ( + settings: WebDAVSettings, + book: Book, + explicitPath?: string, +): Promise<ArrayBuffer | null> => { + const client = toClientConfig(settings); + const path = explicitPath ?? buildBookFilePath(settings.rootPath, book); + return getFileBinary(client, path); +}; + +export const pullBookCover = async ( + settings: WebDAVSettings, + bookHash: string, +): Promise<ArrayBuffer | null> => { + const client = toClientConfig(settings); + const path = buildBookCoverPath(settings.rootPath, bookHash); + return getFileBinary(client, path); +}; + +/** + * Delete the per-book directory `<rootPath>/Readest/books/<hash>/` + * — the file, the cover and the config.json — from the WebDAV + * server in one round-trip. + * + * Used by the WebDAV browser's cleanup mode to evict orphans (remote + * dirs whose local Book carries `deletedAt`). We deliberately do + * *not* touch the local library here: the local row's `deletedAt` + * tombstone is the signal that propagates the deletion to other + * sync clients, and clearing it would resurrect the book on next + * push from a sibling device. See the cleanup-pane comment in + * `WebDAVBrowsePane.tsx` for the broader rationale. + * + * The result shape mirrors {@link PushBookFileResult} so callers + * batching a list of hashes can aggregate without exception + * boilerplate. AUTH failures still throw — they're a global + * condition, not a per-book problem, and the caller surfaces a + * single re-auth toast. + */ +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 const deleteRemoteBookDir = async ( + settings: WebDAVSettings, + bookHash: string, +): Promise<DeleteRemoteBookDirResult> => { + const client = toClientConfig(settings); + const path = buildBookDirPath(settings.rootPath, bookHash); + try { + await deleteDirectory(client, path); + return { ok: true }; + } catch (e) { + // Auth failures aren't a "this hash failed" condition — every + // subsequent hash would fail the same way. Re-throw so the + // batch loop can short-circuit and the caller can surface a + // single re-auth prompt. + if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') throw e; + return { + ok: false, + reason: e instanceof Error ? e.message : String(e), + }; + } +}; + +export interface RemoteLibraryIndex { + schemaVersion: 1; + books: Book[]; + updatedAt: number; +} + +export const pullLibraryIndex = async ( + settings: WebDAVSettings, +): Promise<RemoteLibraryIndex | null> => { + const client = toClientConfig(settings); + const path = buildLibraryPath(settings.rootPath); + const raw = await getFile(client, path); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as RemoteLibraryIndex; + if (parsed && parsed.schemaVersion === 1) return parsed; + } catch { + // Ignore parse errors + } + return null; +}; + +export const pushLibraryIndex = async ( + settings: WebDAVSettings, + index: RemoteLibraryIndex, +): Promise<void> => { + const client = toClientConfig(settings); + const path = buildLibraryPath(settings.rootPath); + const dirs = ancestorsOf(path); + await ensureDirectory(client, dirs); + await putFile(client, path, JSON.stringify(index)); +}; + +/** + * Aggregate result of a library-wide push. Counters are kept granular + * so the UI can render an honest "X uploaded, Y already in sync, Z + * failed" toast at the end. + */ +export interface SyncLibraryResult { + totalBooks: number; + configsUploaded: number; + configsDownloaded: number; + filesUploaded: number; + filesAlreadyInSync: number; + coversUploaded: number; + booksDownloaded: number; + failures: number; + /** + * Per-book failure breakdown for the diagnostic log surfaced in the + * Settings UI. Populated alongside `failures` so the user-facing log + * can show which books failed and why without needing to re-run with + * verbose console output. `reason` is a short single-line string — + * the caller is responsible for truncating server XML / stacks + * before persisting. + */ + failedBooks: SyncFailureEntry[]; +} + +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'; +} + +export interface SyncLibraryOptions { + syncBooks: boolean; + strategy?: 'silent' | 'send' | 'receive'; + loadConfig: (book: Book) => Promise<BookConfig | null>; + /** + * Provider that returns the bytes of a book's local file. Resolve to + * `null` when the book hasn't been downloaded locally — those books + * are silently skipped (they'll be picked up on the device that + * actually has the binary). + */ + loadBookFile: (book: Book) => Promise<BookFileSource | null>; + /** + * Streaming alternative to {@link SyncLibraryOptions.loadBookFile}. + * When supplied, the per-book upload path uses this in preference to + * `loadBookFile`, handing the file off to a transport that streams + * the bytes off disk directly to the WebDAV server. Required for + * libraries with multi-hundred-megabyte books — see + * {@link BookFileStreamingSource} for the heap-pressure rationale. + * + * Tauri callers should provide this; web callers (where streaming + * PUTs aren't available) leave it undefined and fall back to the + * buffered `loadBookFile` path. + */ + loadBookFileStreaming?: (book: Book) => Promise<BookFileStreamingSource | null>; + /** + * Provider that returns the bytes of a book's local cover image. + * Books without a cover (e.g. plaintext imports) resolve to `null` + * and are silently skipped — covers are best-effort, not load-bearing. + */ + loadBookCover?: (book: Book) => Promise<BookFileSource | null>; + saveBookFile?: (book: Book, bytes: ArrayBuffer) => Promise<void>; + /** + * Streaming alternative to (pullBookFile + saveBookFile) — when + * provided, the syncer hands the caller the *remote WebDAV path* and + * expects the caller to download + persist the bytes itself. This is + * how the Tauri app keeps gigabyte-scale book payloads out of the + * WebView heap (which crashes Android with a Binder OOM otherwise). + * Return `true` when the book was successfully written; `false` when + * the server returned 404 / nothing to download. + */ + downloadBookFile?: (book: Book, remotePath: string) => Promise<boolean>; + saveBookCover?: (book: Book, bytes: ArrayBuffer) => Promise<void>; + /** + * Persist a freshly-downloaded BookConfig to local disk so progress, + * bookmarks, and annotations are available the next time the user + * opens the book. Called once per book whose remote config existed. + */ + saveBookConfig?: (book: Book, config: BookConfig) => Promise<void>; + addBookToLibrary?: (book: Book) => Promise<void>; + /** Stable per-device id; written into every config envelope. */ + deviceId: string; + /** + * 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; +} + +/** + * Push every book in `books` to the WebDAV remote in sequence. Designed + * for the user-facing "Sync now" flow, where we trade parallelism for + * a predictable progress bar and for not hammering shared servers. + * + * Per-book steps: + * 1. Load the local config from disk (skip the book if it has none — + * brand-new entries the user has never opened don't need to sync + * anything yet). + * 2. `pushBookConfig` — creates `Readest/books/<hash>/config.json`. + * 3. `pushBookFile` (only when `syncBooks` is on) — HEAD-probes the + * friendly file name path, uploads if missing or size-mismatched. + * 4. `pushBookCover` (only when `syncBooks` is on AND a cover loader + * was provided) — same HEAD-then-PUT pattern. Cover failures are + * treated as warnings, not failures, since they don't break the + * reading experience on the receiving device. + * + * Failures on a single book are caught and counted; we keep going so a + * single bad apple doesn't abort the rest of the library. The aggregate + * counters returned to the caller drive the final toast. + */ +/** + * Reduce an arbitrary error to a short, single-line description suitable + * for surfacing in the user-visible sync log. + * + * Goals: + * - Strip stack traces and any embedded server XML so the persisted + * `syncLog` in settings.json doesn't bloat (settings is read on every + * app start, so size matters). + * - Preserve the semantically useful bits — HTTP status, our own + * `code` enum (`AUTH_FAILED`, `NOT_FOUND`, `NETWORK`) — because that + * is what tells a user whether they should re-tap or fix their + * credentials. + * - Cap at 200 chars so a runaway server response doesn't make a + * single failure entry dominate the log file. + */ +const formatFailureReason = (e: unknown): string => { + let message: string; + if (e instanceof WebDAVRequestError) { + 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); + } + // Collapse whitespace (newlines, tabs from server XML) to single spaces + // so the entry stays a true one-liner in the UI. + message = message.replace(/\s+/g, ' ').trim(); + return message.length > 200 ? `${message.slice(0, 197)}...` : message; +}; + +export const syncLibrary = async ( + settings: WebDAVSettings, + books: Book[], + options: SyncLibraryOptions, +): Promise<SyncLibraryResult> => { + const result: SyncLibraryResult = { + totalBooks: books.length, + configsUploaded: 0, + configsDownloaded: 0, + filesUploaded: 0, + filesAlreadyInSync: 0, + coversUploaded: 0, + booksDownloaded: 0, + failures: 0, + failedBooks: [], + }; + + const strategy = options.strategy || 'silent'; + const canPull = strategy !== 'send'; + const canPush = strategy !== 'receive'; + + let remoteIndex: RemoteLibraryIndex | null = null; + if (canPull) { + try { + remoteIndex = await pullLibraryIndex(settings); + } catch (e) { + console.warn('WD library sync: failed to pull index', e); + } + } + + const allBooksMap = new Map<string, Book>(); + for (const b of books) { + allBooksMap.set(b.hash, b); + } + + const remoteBooksToDownload: Book[] = []; + // The remote source of truth for "what filename does this book actually + // have on disk" is the per-hash directory listing — NOT the book's title + // (which may have been written into library.json before makeSafeFilename + // existed, or by an older buggy build). We always resolve the path by + // listing the hash dir. + const explicitRemotePaths = new Map<string, string>(); + + if (canPull) { + const client = toClientConfig(settings); + 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 will be + // 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 any drift between index and disk are still picked up. + try { + const booksDirPath = `${buildBasePath(settings.rootPath)}/${WEBDAV_BOOKS_DIR}`; + const dirEntries = await listDirectory(client, 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('WD library 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). + // We use that file's real path for the GET and derive title/format + // from its real name — independent of whatever is in library.json. + for (const hash of candidateHashes) { + try { + const hashDirPath = `${buildBasePath(settings.rootPath)}/${WEBDAV_BOOKS_DIR}/${hash}`; + const hashDirEntries = await listDirectory(client, hashDirPath); + const fileEntry = hashDirEntries.find( + (e) => !e.isDirectory && e.name !== 'config.json' && e.name !== 'cover.png', + ); + 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, + // Only override title/sourceTitle when the existing values + // look broken (no value, or contain the file extension). + 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('WD library 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, and + // the user already paid the storage cost on the remote side. Without + // this, a fresh device with `syncBooks: false` (the default) would + // never see any of the books in the WebDAV root, which is the exact + // "I only see 1 / 5 books" footgun users have hit. + if (canPull && (options.saveBookFile || options.downloadBookFile) && options.addBookToLibrary) { + for (let i = 0; i < remoteBooksToDownload.length; i++) { + const rb = remoteBooksToDownload[i]!; + options.onProgress?.({ + book: rb, + index: i, + total: remoteBooksToDownload.length, + action: 'downloading', + }); + try { + const explicitPath = explicitRemotePaths.get(rb.hash); + // Prefer the streaming downloader when the caller provides one. + // 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 (options.downloadBookFile && explicitPath) { + written = await options.downloadBookFile(rb, explicitPath); + } else if (options.saveBookFile) { + const fileBytes = await pullBookFile(settings, rb, explicitPath); + if (fileBytes) { + await options.saveBookFile(rb, fileBytes); + written = true; + } + } + if (written) { + if (options.saveBookCover) { + try { + const coverBytes = await pullBookCover(settings, rb.hash); + if (coverBytes) await options.saveBookCover(rb, coverBytes); + } catch (e) { + console.warn('WD library sync: cover download failed', rb.hash, e); + } + } + // Pull the remote config so progress, bookmarks and annotations + // travel with the book. This is best-effort: a missing config + // simply means "no remote progress yet" and is not a failure. + if (options.saveBookConfig) { + try { + const emptyLocal: BookConfig = { updatedAt: 0, booknotes: [] }; + const pullResult = await pullBookConfig(settings, rb, emptyLocal); + if (pullResult.applied && pullResult.mergedConfig) { + await options.saveBookConfig(rb, pullResult.mergedConfig); + result.configsDownloaded += 1; + } + } catch (e) { + console.warn('WD library sync: config download failed', rb.hash, e); + } + } + await options.addBookToLibrary(rb); + result.booksDownloaded += 1; + } else { + // No bytes returned (typically a 404 we couldn't resolve) — + // count as a failure so the user sees something happened. + 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('WD library 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('WD library sync: book download failed', rb.hash, e); + } + } + } + + // Books we just downloaded already exist on the remote — don't waste + // bandwidth/time HEAD-probing and re-pushing them. Only push books that + // were already present in the caller-supplied local library. + const downloadedHashes = new Set(remoteBooksToDownload.map((b) => b.hash)); + const booksToPush = books.filter((b) => !b.deletedAt && !downloadedHashes.has(b.hash)); + result.totalBooks = booksToPush.length; + + if (canPush && booksToPush.length > 0) { + for (let i = 0; i < booksToPush.length; i += 1) { + const book = booksToPush[i]!; + options.onProgress?.({ book, index: i, total: booksToPush.length, action: 'uploading' }); + // Track which step we were in when an exception escapes the inner + // try, so the user-facing log can pinpoint whether config / file / + // cover upload tripped the wire. Cover failures are caught locally + // (covers are best-effort) and don't update this. + let phase: SyncFailureEntry['phase'] = 'upload-config'; + try { + const config = await options.loadConfig(book); + if (config) { + await pushBookConfig(settings, book, config, options.deviceId); + result.configsUploaded += 1; + } + if (options.syncBooks) { + phase = 'upload-file'; + const fileResult = await pushBookFile( + settings, + book, + () => options.loadBookFile(book), + options.loadBookFileStreaming ? () => options.loadBookFileStreaming!(book) : undefined, + ); + if (fileResult.uploaded) { + result.filesUploaded += 1; + } else if (fileResult.reason === 'remote-matches') { + result.filesAlreadyInSync += 1; + } + if (options.loadBookCover) { + try { + const coverResult = await pushBookCover(settings, book.hash, () => + options.loadBookCover!(book), + ); + if (coverResult.uploaded) result.coversUploaded += 1; + } catch (e) { + console.warn('WD library sync: cover failed', book.hash, e); + } + } + } + } catch (e) { + result.failures += 1; + result.failedBooks.push({ + hash: book.hash, + title: book.title || book.hash, + phase, + reason: formatFailureReason(e), + }); + console.warn('WD library sync: book failed', book.hash, e); + } + } + } + + // Push the merged index whenever we're allowed to write to the remote, + // even if we didn't upload any binaries this turn (e.g. all books were + // freshly pulled from the remote). Keeps library.json authoritative. + // + // Per-hash directories of soft-deleted books are intentionally NOT + // GC'd from this sync path: a peer that hasn't pulled this push yet + // would see the `deletedAt` tombstone arrive together with the bytes + // already gone, surfacing as a phantom-deleted shelf row. The orphan + // sweep is instead exposed manually via WebDAVBrowsePane's cleanup + // mode, where it's the user (per device) who decides when the + // deletion has settled. See `deleteRemoteBookDir` above. + if (canPush) { + try { + const newIndex: RemoteLibraryIndex = { + schemaVersion: 1, + books: Array.from(allBooksMap.values()), + updatedAt: Date.now(), + }; + await pushLibraryIndex(settings, newIndex); + } catch (e) { + console.warn('WD library sync: failed to push index', e); + } + } + + return result; +}; diff --git a/apps/readest-app/src/services/webdav/webdavConnectSettings.ts b/apps/readest-app/src/services/webdav/webdavConnectSettings.ts new file mode 100644 index 00000000..17b0a934 --- /dev/null +++ b/apps/readest-app/src/services/webdav/webdavConnectSettings.ts @@ -0,0 +1,41 @@ +import { WebDAVSettings } from '@/types/settings'; + +export interface WebDAVConnectFormValues { + serverUrl: string; + username: string; + password: string; + /** Already passed through `normalizeRootPath` by the caller. */ + rootPath: string; +} + +/** + * Build the updated `webdav` block for a successful Connect submit. + * + * The form's Connect handler only owns the four credential/path fields the + * user just typed. Everything else — `deviceId`, `syncBooks`, `strategy`, + * `syncProgress`, `syncNotes`, `lastSyncedAt`, `syncLog` — was earned by + * prior use and MUST be preserved across a disconnect/reconnect cycle. + * + * Spreading `previous` first lets the form fields shadow the captured + * credentials while every bookkeeping field rides through untouched. The + * `enabled: true` flag is set last so a previously-disabled connection + * comes back online without otherwise mutating user preferences. + * + * Pulled out as a pure helper specifically to unit-test the "reconnect + * preserves prior state" invariant: the inline version in WebDAVForm + * regressed in PR #4204 by replacing the whole webdav block, which + * silently rotated the deviceId and dropped the diagnostic syncLog. + */ +export const buildWebDAVConnectSettings = ( + previous: Partial<WebDAVSettings> | undefined, + form: WebDAVConnectFormValues, +): WebDAVSettings => { + return { + ...(previous ?? {}), + enabled: true, + serverUrl: form.serverUrl.trim(), + username: form.username, + password: form.password, + rootPath: form.rootPath, + } as WebDAVSettings; +}; diff --git a/apps/readest-app/src/store/webdavSyncStore.ts b/apps/readest-app/src/store/webdavSyncStore.ts new file mode 100644 index 00000000..92c9e3ae --- /dev/null +++ b/apps/readest-app/src/store/webdavSyncStore.ts @@ -0,0 +1,60 @@ +import { create } from 'zustand'; + +/** + * Shared in-flight state for the library-wide WebDAV "Sync now" run. + * + * Lives outside React component state so the sync survives navigation + * inside the Settings dialog (drilling out to the Integrations list, or + * closing the dialog entirely and reopening it later) — `WebDAVForm`'s + * old `useState` was destroyed on unmount, leaving the user with a + * re-enabled "Sync now" button while the original `syncLibrary` + * promise was still running off-thread, with no progress affordance + * and the door open to spawning a second concurrent run. + * + * Scope is deliberately narrow: + * - Only the manual library Sync now path uses this. The per-book + * reader hook (`useWebDAVSync`) tracks its own state via refs and + * doesn't surface a button. + * - Not persisted to settings.json — process-local only. If the app + * is killed mid-sync, the in-memory promise dies with the renderer + * and this store starts fresh on next launch (which is the + * correct semantic: an aborted run should not look like it's + * still going). + * - We don't track structured progress (counters, per-book status + * etc.) — `syncLibrary.onProgress` already builds a localised + * label and the UI only ever displays it as a string. Keeping the + * store flat avoids re-implementing the formatting in two places. + * + * Re-entrancy: callers MUST gate on `isSyncing` *before* flipping it. + * The `beginSync` action does not itself enforce mutual exclusion — + * we keep the store dumb and let the handler decide because the + * handler also has to do auth/library pre-flight checks that should + * run after the gate. + */ +interface WebDAVSyncState { + /** True while a library-wide Sync now is currently running. */ + isSyncing: boolean; + /** + * Localised progress string. Set by `syncLibrary.onProgress` via + * `updateProgress`; rendered verbatim by the form. Null when no run + * is active. + */ + progressLabel: string | null; + /** Wall-clock millis when the current run kicked off, or null. */ + startedAt: number | null; + + beginSync: (initialLabel: string) => void; + updateProgress: (label: string) => void; + endSync: () => void; +} + +export const useWebDAVSyncStore = create<WebDAVSyncState>((set) => ({ + isSyncing: false, + progressLabel: null, + startedAt: null, + + beginSync: (initialLabel) => + set({ isSyncing: true, progressLabel: initialLabel, startedAt: Date.now() }), + updateProgress: (label) => set({ progressLabel: label }), + endSync: () => set({ isSyncing: false, progressLabel: null, startedAt: null }), +})); diff --git a/apps/readest-app/src/types/settings.ts b/apps/readest-app/src/types/settings.ts index 3b74a01a..7ab540f7 100644 --- a/apps/readest-app/src/types/settings.ts +++ b/apps/readest-app/src/types/settings.ts @@ -85,6 +85,121 @@ export interface HardcoverSettings { lastSyncedAt: number; } +export interface WebDAVSettings { + enabled: boolean; + serverUrl: string; + username: string; + password: string; + rootPath: string; + // Sync sub-toggles. WebDAV sync runs as a parallel channel alongside the + // native cloud sync, KOSync, Readwise, and Hardcover; each sub-toggle + // gates a category independently so a user can e.g. mirror progress to + // their own server without uploading book binaries. + syncProgress?: boolean; + syncNotes?: boolean; + syncBooks?: 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 + // which device last touched a given book. + deviceId?: string; + // Wall-clock millisecond timestamp of the last successful end-to-end + // sync, surfaced in the WebDAV settings sub-page. + lastSyncedAt?: number; + // Diagnostic ring buffer: most recent ten "Sync now" runs, oldest first + // dropped when full. Persisted alongside the rest of settings so users + // can screenshot a failure breakdown when reporting issues. We keep the + // cap small both for storage hygiene and because debugging beyond ten + // back is rarely useful — by then the live state has long moved on. + syncLog?: WebDAVSyncLogEntry[]; +} + +/** + * Outcome category for one entry in {@link WebDAVSettings.syncLog}. We + * keep this coarse on purpose — it drives the colour of the status pill + * in the history panel and nothing else. Per-step counters travel in the + * same entry for users who want detail. + * + * - `success`: ran to completion with `failures === 0` and at least one + * meaningful action (download/upload). "Up to date" runs (no work) also + * land here. + * - `partial`: ran to completion but `failures > 0`. At least one book + * may need a re-sync to fully converge. + * - `failure`: did not finish. Either a top-level error (auth failed, + * network down before any work) or every book failed. + */ +export type WebDAVSyncLogStatus = 'success' | 'partial' | 'failure'; + +export interface WebDAVSyncLogFailure { + /** Stable identifier for the book — used as React key, never displayed. */ + hash: string; + /** Human-readable book title at the time of the failed attempt. */ + title: string; + /** + * Short, single-line failure description. We deliberately strip stacks + * and long server XML; users want "auth failed" / "404", not a wall of + * text. Truncate to ~200 chars at write time so the persisted log + * doesn't bloat settings.json. + */ + reason: string; +} + +export interface WebDAVSyncLogEntry { + /** UUIDv4. Used as React list key and for "expand details" toggling. */ + id: string; + /** Wall-clock ms when handleSyncNow began. */ + startedAt: number; + /** Wall-clock ms when the run finished or aborted. */ + finishedAt: number; + status: WebDAVSyncLogStatus; + /** + * What kind of run this entry records. Defaults to 'sync' when + * absent so log entries persisted before this field was introduced + * keep rendering the same way they always did. 'cleanup' is set + * for entries written by the WebDAV browser's batch + * Delete-from-server action; renderers use this to swap the badge + * label and pick a cleanup-specific summary line. + */ + kind?: 'sync' | 'cleanup'; + /** + * What kicked off this run. v1 only writes 'manual' (the Sync now + * button is the only entry point). The reader-hook auto-pushes are + * intentionally NOT logged: they fire once per page-turn and would + * drown out the manual-run signal users care about. + */ + trigger: 'manual' | 'auto'; + /** Counters mirroring `SyncLibraryResult` — directly screenshot-friendly. */ + totalBooks: number; + booksDownloaded: number; + filesUploaded: number; + filesAlreadyInSync: number; + configsUploaded: number; + configsDownloaded: number; + coversUploaded: number; + /** + * Number of per-hash directories successfully removed from the + * server in a cleanup run. Only meaningful when `kind === 'cleanup'`; + * sync entries leave this undefined / zero. Kept optional to avoid + * a migration step on existing settings.json files. + */ + booksDeleted?: number; + failures: number; + /** The same one-liner shown in the toast. Kept for at-a-glance reading. */ + summary: string; + /** + * Top-level error message when the run aborted before processing + * books (auth, root not reachable, connectivity). Mutually exclusive + * with `failedBooks` in practice — a top-level abort means we never + * iterated, so per-book failures don't apply. + */ + errorMessage?: string; + /** Per-book failure breakdown when `failures > 0`. */ + failedBooks?: WebDAVSyncLogFailure[]; +} + +/** Maximum entries retained in {@link WebDAVSettings.syncLog}. */ +export const WEBDAV_SYNC_LOG_LIMIT = 10; + /** * User-facing sync categories. 'progress' gates the existing book-config * (reading progress) sync, 'note' gates annotations, 'book' gates book @@ -193,6 +308,7 @@ export interface SystemSettings { kosync: KOSyncSettings; readwise: ReadwiseSettings; hardcover: HardcoverSettings; + webdav: WebDAVSettings; aiSettings: AISettings; /** diff --git a/apps/readest-app/src/utils/serializer.ts b/apps/readest-app/src/utils/serializer.ts index 191009a7..49586a0e 100644 --- a/apps/readest-app/src/utils/serializer.ts +++ b/apps/readest-app/src/utils/serializer.ts @@ -19,8 +19,19 @@ export const serializeConfig = ( defaultSearchConfig: BookSearchConfig, ): string => { config = JSON.parse(JSON.stringify(config)); - const viewSettings = config.viewSettings as Partial<ViewSettings>; - const searchConfig = config.searchConfig as Partial<BookSearchConfig>; + // Tolerate configs that arrive without these fields. Two real-world + // call sites can produce that shape: + // 1. A freshly-initialised config (`INIT_BOOK_CONFIG`) that has + // never been touched by the reader yet. + // 2. The WebDAV sync download path, which merges `{ updatedAt: 0, + // booknotes: [] }` with a remote `compressConfig` payload — the + // latter omits viewSettings/searchConfig entirely when they + // match global defaults. + // Treating null/undefined as `{}` is semantically identical to "no + // overrides vs global", so the reduce below correctly emits an empty + // object that downstream `deserializeConfig` re-hydrates from globals. + const viewSettings = (config.viewSettings ?? {}) as Partial<ViewSettings>; + const searchConfig = (config.searchConfig ?? {}) as Partial<BookSearchConfig>; config.viewSettings = Object.entries(viewSettings).reduce( (acc: Partial<Record<keyof ViewSettings, unknown>>, [key, value]) => { if (globalViewSettings[key as keyof ViewSettings] !== value) {