feat(integrations): add WebDAV sync to Reading Sync settings (#4204)
* feat(integrations): add WebDAV sync to Reading Sync settings Adds a WebDAV entry under Settings -> Integrations -> Reading Sync with configure/browse UI, library-wide Sync now, and per-book sync of progress, annotations and (opt-in) book files + covers. Reading progress and annotations are always synced when WebDAV is enabled; only Sync Book Files stays as a toggle since it's bandwidth-heavy. * feat(webdav): add diagnostic sync history panel and document viewSettings invariant Surface a per-run history for the WebDAV "Sync now" button so users can self-triage failures without rummaging through the dev console — a screenshot of the panel is now enough to file a useful bug report. The same change tightens the docs around viewSettings so the "device-local UI preferences" boundary is impossible to misread on the next refactor pass. Sync history panel: * New WebDAVSettings.syncLog ring buffer (cap 10), persisted alongside the rest of settings so a screenshot survives across app restarts. WebDAVSyncLogEntry captures startedAt, finishedAt, status (success / partial / failure), trigger, the eight counters from SyncLibraryResult, the toast text, and an optional per-book failure list with a phase tag (download / upload-config / upload-file). * SyncLibraryResult gains a failedBooks: SyncFailureEntry[] field. The two existing failure points in syncLibrary (download catch, upload catch) now record per-book reason+phase via formatFailureReason(), which keeps the persisted blob small by stripping stacks/whitespace and capping length at 200 chars. * WebDAVForm.handleSyncNow now timestamps the run, builds an entry from the result on success/partial paths and from the caught error on failure paths, and appends through a fresh-read appendSyncLogEntry() so concurrent toggle changes can't clobber the log. * New SyncHistoryPanel + SyncStatusBadge + SyncHistoryDetails components render the log inline in the Settings page. The detail row groups counters into three semantic columns (activity, skipped, outcome) on a six-column grid so labels can wrap freely while numbers stay tabular and right-aligned. Per-book failures render as a separate stack below the counters. viewSettings invariant: * buildRemotePayload and pullBookConfig already implement the right thing — only progress/location/xpointer/booknotes travel; viewSettings stays device-local. Comments now spell out the contract on both sides so future contributors don't reintroduce viewSettings on the wire by mistake. * fix(webdav): preserve prior state across reconnect, drop stale closure in ensureDeviceId Two bugs in the WebDAV sync flow surfaced during review: 1. WebDAVForm.handleConnect rebuilt the entire `webdav` settings block from the four credential fields the user just typed, dropping `deviceId`, `syncBooks`, `strategy`, `syncProgress`, `syncNotes`, `lastSyncedAt`, and `syncLog` on every reconnect. Most concerning is the deviceId rotation: a disconnect + reconnect made the next sync look like a brand-new device, defeating the cross-device clobber detection encoded in `RemoteBookConfig.writerDeviceId`. Extract a pure helper `buildWebDAVConnectSettings` that spreads the previous webdav object first so reconnect is non-destructive, matching the sibling pattern in KOSyncForm. 2. useWebDAVSync.ensureDeviceId merged the new deviceId into the closure variable `settings`, which can be stale when `pullNow → pushNow` fires back-to-back on book open or when the settings panel writes a sibling field concurrently. Read latest settings via `useSettingsStore.getState()` to match the pattern already used in `updateLastSyncedAt` and `persistWebdav`. Adds three unit tests for the new helper, including the reconnect preservation invariant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(webdav): address review observations on encodePath, pull skip, and remote GC Three follow-ups from the review pass on top of 3f721d04. Each one was called out as a smaller observation the reviewer noted but did not push: * WebDAVClient.encodePath silently re-escaped literal % characters despite a comment claiming existing %-escapes are preserved. A caller that pre-encoded a space as %20 would see %20 become %2520 in the request URL, breaking any path that came in already escaped. Tokenise each segment into already-escaped %XX runs and everything-else, and only run encodeURIComponent on the latter. Add four unit tests exercising pure-unicode, pure-pre-escaped, mixed, and root-slash paths. Implementation note: two regexes are needed because a /g RegExp.test is stateful and would skip every other token in this map; the split regex has /g for the iteration, the classifier regex is anchored without /g for the per-token check. * OPEN_PULL_SKIP_MS doc-comment claimed it catches the close-then-reopen flow, but useWebDAVSync unmounts on reader close so lastPulledAtRef resets to 0 — the new instance always passes the cooldown check on remount. The guard actually only fires on re-invocations of the open-book effect inside one hook lifetime (book-to-book navigation, double-render before hasPulledOnce flips). Rewrite both the constant's doc-comment and the call-site comment to match the real semantics. * WebDAVSync push path doesn't DELETE the per-hash directory of a tombstoned book. The deletion *is* propagated through library.json so other devices hide the book, but storage on the WebDAV server grows monotonically. Add a TODO at the pushLibraryIndex call with a sketch of what a future garbage-collection sweep would need (a per- device acknowledgment field on RemoteLibraryIndex so we don't wipe data a peer hasn't seen the deletion for yet). * refactor(webdav): extract WebDAVBrowsePane and SyncHistoryPanel from WebDAVForm The WebDAV settings form was nearing 1500 lines and hosted three loosely related surfaces — credential entry, sync controls + manual trigger, and the in-app file browser — that didn't share much state. Reviewer flagged it as a refactor candidate; this commit does the actual split. * WebDAVBrowsePane (new, 534 lines): owns currentPath, the directory listing, per-entry download status, the navigation handlers and the per-file icon / filename helpers. Reads credentials from the settings prop and otherwise reaches for envConfig / useLibraryStore / useAuth itself rather than threading them through props (matches how the rest of the integrations panels are wired). * SyncHistoryPanel (new, 293 lines): the diagnostic history surface plus its three private helpers (SyncStatusBadge, formatSyncSummary Line, formatSyncTimestamp, SyncHistoryDetails). Moved verbatim from the inline definitions at the bottom of WebDAVForm — the component was already presentation-only and accepting the translation fn as a prop, so no API change. * WebDAVForm (676 lines, down from 1456): keeps the mode switch (configured vs. not), the credential form, the sync sub-controls (Upload Book Files / Sync Strategy / Sync now button), and the large handleSyncNow effect — those last two are intrinsically tied to the settings store and would have just been pushed back up the prop chain by any extraction. The standalone SyncHistoryPanel and WebDAVBrowsePane are now mounted as siblings inside the configured branch. No behavioural change — both new files run the same effects, build the same JSX, and read/write the same store fields as before. All existing webdav-related unit tests still pass. Resolves the last of the reviewer's smaller observations on 3f721d04 (file length). * fix(webdav): stream book uploads to avoid renderer OOM on large files Both syncLibrary (manual Sync now in WebDAVForm) and useWebDAVSync (per-book auto/manual sync triggered on book open) materialised the full book binary as an ArrayBuffer in the V8 heap before PUTting it. With multi-hundred-megabyte PDFs / scanned books, the renderer either accumulates buffers across sequential pushes (library sync) or blows its heap ceiling on a single book (per-book sync), surfacing as a blank white screen on desktop and a binder-OOM kill of the WebView on Android. Add a BookFileStreamingLoader option to pushBookFile that, on Tauri targets, hands the file path off to tauriUpload's Rust-side streamer so bytes never enter JS. The HEAD short-circuit is shared across both paths, so steady-state syncs still cost a single round-trip per book. Web targets keep the buffered fallback (no streaming HTTP primitive available there). Wire the streaming loader through SyncLibraryOptions.loadBookFileStreaming for the library Sync now path, and inline it in useWebDAVSync.pushBookFileNow for the per-book path. Covers stay on the buffered loader — they're capped at a few hundred KB and don't justify widening the API. * fix(webdav): keep Sync now state alive across Settings navigation/close WebDAVForm tracked the library-wide Sync now run in component state, so any navigation that unmounted the form (drilling back to the Integrations list, or closing the SettingsDialog entirely) destroyed the in-flight indicator while syncLibrary's promise kept running off-thread. On return the user saw a re-enabled button with no progress affordance, an empty Sync History (until the run finally finished), and could trigger a second concurrent syncLibrary against the server. Hoist isSyncing / progressLabel into a process-local zustand store (webdavSyncStore) and consume it from WebDAVForm. The store outlives any single mount, so re-mounting the form picks up the running sync's state on first render — button stays disabled, progress label keeps ticking, and the re-entrancy gate (now reading the live store rather than a stale closure) blocks duplicate clicks. Also surface 'Syncing…' in the IntegrationsPanel row so users get the cue without drilling into the sub-page. Not persisted: the store dies with the renderer, which is the right semantic — a sync killed by app exit shouldn't look like it's still going on next launch. * feat(webdav): cleanup mode for orphan book directories on the server WebDAV pushes set Book.deletedAt as a tombstone but never DELETE the per-hash directory on the server, so the remote Readest/books/ tree accumulates dead entries from books the user deleted long ago. Add a dedicated cleanup mode in the WebDAV browser to evict them in batch. Cleanup mode is reached via a new sweep button next to Refresh. Entering it pins the listing to Readest/books/, filters down to directories whose local Book carries deletedAt, and replaces the per-row icon with a checkbox. The footer carries a single right-aligned Delete from server action; selecting one or more rows and clicking it sends a confirm dialog (appService.ask, so it actually blocks on Tauri) and then runs sequential DELETEs against the server. Each row splices out of the listing the moment its DELETE returns, so the listing itself is the progress indicator; the button keeps a stable width by always reserving space for the spinner via the invisible class. The local library is left untouched. Book.deletedAt is the authoritative deletion signal in readest's sync model — clearing or rewriting it here would cause sibling devices to either resurrect the book or lose the deletion event. Restore is therefore not offered: the per-entry download button already provides full recovery (tauriDownload + ingestFile streams the file back, ingestFile clears deletedAt as a side-effect, and the next sync round-trip merges remote progress and notes), and a metadata-only restore would leave users staring at unopenable shelf rows whenever the bytes had been GCed off local disk. Browse mode is friendlier too. Per-hash subdirectory rows under Readest/books/ resolve their hash to the local library's title and short-form hash for skimmability; soft-deleted entries get a folder-off icon plus a 60% dimmed title (a redundant signal for touch platforms where the desktop-only hover tooltip doesn't fire). Cleanup runs are persisted into the existing sync history with a kind: 'cleanup' discriminator and a booksDeleted counter, so destructive batch operations are auditable alongside regular Sync now runs without polluting the common case (the new counter is zero-suppressed on plain sync entries). * test(webdav): cover deleteDirectory and deleteRemoteBookDir Pin the contract of the cleanup-mode delete plumbing: HTTP method, Depth: infinity header, Authorization header and target URL on the low-level deleteDirectory; success/failure/auth-failure routing and per-hash path construction on the high-level deleteRemoteBookDir. Status-code semantics are exercised end to end (200/204 ok, 404 idempotent, 401/403 AUTH_FAILED, 5xx generic, network throw NETWORK), so a future refactor can't silently drop the explicit Depth header or merge the auth-failure path into the per-book result struct without tripping a regression. --------- Co-authored-by: Huang Xin <chrox.huang@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/<hash>".
|
||||
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<typeof vi.fn>;
|
||||
|
||||
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<string, string>;
|
||||
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 <rootPath>/Readest/books', async () => {
|
||||
// The remote layout is documented in WebDAVPaths.ts; this test
|
||||
// pins the contract so neither side can drift. A custom
|
||||
// rootPath is used to make sure buildBookDirPath honours it
|
||||
// (a literal '/' would mask a "join always uses leading-slash
|
||||
// base" regression).
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
* `<rootPath>/Readest/books/<hash>/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<void> };
|
||||
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<string, string>,
|
||||
);
|
||||
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<boolean> => {
|
||||
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/<hash>/) 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;
|
||||
@@ -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<SubPage>(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 = () => {
|
||||
<KOSyncForm onBack={() => setSubPage(null)} />
|
||||
</div>
|
||||
);
|
||||
if (subPage === 'webdav')
|
||||
return (
|
||||
<div className='my-4 w-full'>
|
||||
<WebDAVForm onBack={() => setSubPage(null)} />
|
||||
</div>
|
||||
);
|
||||
if (subPage === 'readwise')
|
||||
return (
|
||||
<div className='my-4 w-full'>
|
||||
@@ -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')}
|
||||
/>
|
||||
<IntegrationRow
|
||||
icon={RiCloudLine}
|
||||
title={_('WebDAV')}
|
||||
status={webdavStatus}
|
||||
onClick={() => setSubPage('webdav')}
|
||||
/>
|
||||
<IntegrationRow
|
||||
icon={RiBookReadLine}
|
||||
title={_('Readwise')}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import clsx from 'clsx';
|
||||
import React, { useState } from 'react';
|
||||
import { WebDAVSyncLogEntry, WebDAVSyncLogStatus } from '@/types/settings';
|
||||
import { BoxedList, SettingsRow } from '../primitives';
|
||||
|
||||
/**
|
||||
* Diagnostic surface for the most-recent ten WebDAV manual runs —
|
||||
* Sync now from the form plus batch cleanups (Delete from server)
|
||||
* issued from the WebDAV browser. Auto-syncs triggered while reading
|
||||
* are intentionally NOT logged here; they fire once per page-turn
|
||||
* and would drown out the manual signal users care about.
|
||||
*
|
||||
* Why a separate component (rather than inline JSX in WebDAVForm):
|
||||
* - Keeps the outer form file legible; the panel has its own state
|
||||
* model (which entry is expanded) that doesn't belong in the parent.
|
||||
* - Co-locates the per-entry rendering (counters, failure list,
|
||||
* duration) with the component that owns it. The parent only knows
|
||||
* about "the log" as a whole and how to clear it.
|
||||
*
|
||||
* Presentational: all persistence happens in the parent
|
||||
* (`appendSyncLogEntry` / `handleClearSyncLog`). We accept the
|
||||
* translation function `t` rather than calling `useTranslation` here so
|
||||
* the parent stays the single source of locale truth for the page.
|
||||
*/
|
||||
export interface SyncHistoryPanelProps {
|
||||
entries: WebDAVSyncLogEntry[];
|
||||
onClear: () => void | Promise<void>;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const SyncHistoryPanel: React.FC<SyncHistoryPanelProps> = ({ 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<string | null>(null);
|
||||
|
||||
const hasEntries = entries.length > 0;
|
||||
|
||||
return (
|
||||
<BoxedList>
|
||||
<SettingsRow
|
||||
label={t('Sync History')}
|
||||
description={t(
|
||||
"Manual syncs and cleanups — automatic syncs while reading aren't logged here.",
|
||||
)}
|
||||
>
|
||||
{hasEntries ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => onClear()}
|
||||
className='btn btn-ghost btn-sm h-8 min-h-8 px-2'
|
||||
title={t('Clear Sync History')}
|
||||
aria-label={t('Clear Sync History')}
|
||||
>
|
||||
{t('Clear')}
|
||||
</button>
|
||||
) : (
|
||||
<span className='text-base-content/50 text-xs'>{t('No manual syncs yet')}</span>
|
||||
)}
|
||||
</SettingsRow>
|
||||
{hasEntries && (
|
||||
<ul className='divide-base-200 divide-y'>
|
||||
{entries.map((entry) => {
|
||||
const isExpanded = expandedId === entry.id;
|
||||
return (
|
||||
<li key={entry.id} className='px-4 py-3'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setExpandedId(isExpanded ? null : entry.id)}
|
||||
className='group flex w-full items-center gap-3 text-left'
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<SyncStatusBadge status={entry.status} t={t} />
|
||||
{entry.kind === 'cleanup' && <SyncKindBadge t={t} />}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
<span className='text-sm'>{formatSyncSummaryLine(entry, t)}</span>
|
||||
<span className='text-base-content/60 text-[0.75em]'>
|
||||
{formatSyncTimestamp(entry.startedAt, entry.finishedAt, t)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
'text-base-content/50 transition-transform',
|
||||
isExpanded && 'rotate-90',
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
{isExpanded && <SyncHistoryDetails entry={entry} t={t} />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</BoxedList>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<WebDAVSyncLogStatus, { label: string; className: string }> = {
|
||||
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 (
|
||||
<span
|
||||
className={clsx(
|
||||
'flex h-6 flex-shrink-0 items-center rounded px-2 text-[0.7rem] font-medium',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<span
|
||||
className={clsx(
|
||||
'flex h-6 flex-shrink-0 items-center rounded px-2 text-[0.7rem] font-medium',
|
||||
'bg-info/15 text-info',
|
||||
)}
|
||||
>
|
||||
{t('Cleanup')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className='mt-3 flex flex-col gap-3 pl-9'>
|
||||
{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.
|
||||
<div
|
||||
className={clsx(
|
||||
'border-base-200 grid rounded border',
|
||||
'gap-x-3 gap-y-2 px-3 py-2 text-xs',
|
||||
)}
|
||||
style={{
|
||||
gridTemplateColumns: 'minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr) auto',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// 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(
|
||||
<div
|
||||
key={`l-${groupIdx}-${row}`}
|
||||
className={clsx(
|
||||
'text-base-content/60 leading-tight',
|
||||
// Group separator: every group except the first
|
||||
// gets a left border on its label column. The
|
||||
// negative left margin offsets the gap so the
|
||||
// line falls inside the gutter rather than
|
||||
// beside the text itself.
|
||||
groupIdx > 0 && 'border-base-200 -ml-3 border-l pl-3',
|
||||
)}
|
||||
>
|
||||
{c?.label ?? ''}
|
||||
</div>,
|
||||
);
|
||||
cells.push(
|
||||
<div key={`v-${groupIdx}-${row}`} className='text-end font-medium tabular-nums'>
|
||||
{c?.value ?? ''}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
return cells;
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{entry.errorMessage && (
|
||||
<div className='text-error/90 break-words text-xs'>
|
||||
<span className='text-base-content/60 mr-1'>{t('Error:')}</span>
|
||||
{entry.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
{entry.failedBooks && entry.failedBooks.length > 0 && (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-base-content/60 text-xs'>{t('Failed books')}</span>
|
||||
<ul className='flex flex-col gap-1 text-xs'>
|
||||
{entry.failedBooks.map((f) => (
|
||||
<li key={f.hash} className='border-base-200 break-words rounded border px-2 py-1.5'>
|
||||
<div className='font-medium'>{f.title}</div>
|
||||
<div className='text-base-content/70 mt-0.5'>{f.reason}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncHistoryPanel;
|
||||
@@ -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, string | number>) => string;
|
||||
/** Persist a cleanup run into the parent's sync log when supplied. */
|
||||
onAppendSyncLogEntry?: (entry: WebDAVSyncLogEntry) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const WebDAVBrowsePane: React.FC<WebDAVBrowsePaneProps> = ({
|
||||
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<string>(savedRoot);
|
||||
const [entries, setEntries] = useState<WebDAVEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(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<string, 'downloading' | 'done' | 'error'>
|
||||
>({});
|
||||
|
||||
// —— 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<Set<string>>(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<string, Book>();
|
||||
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<string>();
|
||||
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/<hash>/, 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 (
|
||||
<>
|
||||
<div className='flex items-center justify-between gap-3 px-1'>
|
||||
<div className='flex min-w-0 items-center gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleNavigateUp}
|
||||
// Lock in cleanup mode so users use the explicit close button.
|
||||
disabled={currentPath === savedRoot || cleanupMode}
|
||||
className={clsx(
|
||||
'btn btn-ghost btn-sm h-8 min-h-8 gap-1 px-2',
|
||||
(currentPath === savedRoot || cleanupMode) && 'opacity-40',
|
||||
)}
|
||||
title={t('Up')}
|
||||
aria-label={t('Up')}
|
||||
>
|
||||
<MdArrowBack className='h-4 w-4' />
|
||||
</button>
|
||||
<span className='truncate text-sm' title={currentPath}>
|
||||
{cleanupMode
|
||||
? t('Cleanup · {{count}} book(s)', { count: displayedEntries.length })
|
||||
: currentPath}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
{cleanupMode ? (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleExitCleanup}
|
||||
// Locked during an in-flight batch so the user can't
|
||||
// unmount the progress affordance mid-delete.
|
||||
disabled={isDeleting}
|
||||
className={clsx('btn btn-ghost btn-sm h-8 min-h-8 px-2', isDeleting && 'opacity-40')}
|
||||
title={t('Exit cleanup')}
|
||||
aria-label={t('Exit cleanup')}
|
||||
>
|
||||
<MdClose className='h-4 w-4' />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleEnterCleanup}
|
||||
className='btn btn-ghost btn-sm h-8 min-h-8 px-2'
|
||||
title={t('Cleanup')}
|
||||
aria-label={t('Cleanup')}
|
||||
>
|
||||
<MdDeleteSweep className='h-4 w-4' />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleRefresh}
|
||||
// Refresh during a delete would race with the per-item splice.
|
||||
disabled={isDeleting}
|
||||
className={clsx('btn btn-ghost btn-sm h-8 min-h-8 px-2', isDeleting && 'opacity-40')}
|
||||
title={t('Refresh')}
|
||||
aria-label={t('Refresh')}
|
||||
>
|
||||
<MdRefresh className='h-4 w-4' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='card eink-bordered border-base-200 bg-base-100 overflow-hidden border'>
|
||||
{isLoading ? (
|
||||
<div className='flex min-h-32 items-center justify-center py-8'>
|
||||
<span className='loading loading-spinner loading-md' />
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<div className='text-error px-4 py-6 text-center text-sm'>{loadError}</div>
|
||||
) : displayedEntries.length === 0 ? (
|
||||
<div className='text-base-content/60 px-4 py-6 text-center text-sm'>
|
||||
{cleanupMode ? t('All clear · no books') : t('Empty directory')}
|
||||
</div>
|
||||
) : (
|
||||
<ul className='divide-base-200 divide-y'>
|
||||
{/* 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 (
|
||||
<li key={entry.path}>
|
||||
<div
|
||||
role={rowClickable ? 'button' : undefined}
|
||||
tabIndex={rowClickable ? 0 : -1}
|
||||
title={rowTitle}
|
||||
onClick={
|
||||
// In cleanup mode the row click toggles
|
||||
// selection — navigating into the dir would
|
||||
// break the GC scope.
|
||||
cleanupMode
|
||||
? () => 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.
|
||||
<span className='flex h-8 w-8 flex-shrink-0 items-center justify-center'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='checkbox checkbox-sm'
|
||||
checked={selected.has(entry.path)}
|
||||
// Stop the checkbox's own click from
|
||||
// bubbling into a row-level double-toggle.
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => toggleRowSelected(entry.path)}
|
||||
aria-label={t('Select')}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={clsx(
|
||||
'flex h-8 w-8 flex-shrink-0 items-center justify-center rounded',
|
||||
'bg-base-200 text-base-content/70',
|
||||
)}
|
||||
>
|
||||
<FileIcon className='h-4 w-4' />
|
||||
</span>
|
||||
)}
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||
{/* Primary line: matched books show their
|
||||
title, everything else shows the raw name.
|
||||
line-clamp-none + break-all let unicode-only
|
||||
names wrap. */}
|
||||
<SettingLabel
|
||||
className={clsx(
|
||||
'line-clamp-none whitespace-normal break-all',
|
||||
// Dimmed title is the platform-independent
|
||||
// sibling of the MdFolderOff icon (touch
|
||||
// platforms can't see the hover tooltip).
|
||||
isLocallyDeleted && 'text-base-content/60',
|
||||
)}
|
||||
// Hover-show the full hash for matched books,
|
||||
// but yield the slot to the row-level
|
||||
// "Deleted locally" tooltip when applicable
|
||||
// (browsers pick the innermost `title`).
|
||||
title={matchedBook && !isLocallyDeleted ? entry.name : undefined}
|
||||
>
|
||||
{matchedBook ? matchedBook.title || entry.name : entry.name}
|
||||
</SettingLabel>
|
||||
{/* 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) && (
|
||||
<span className='text-base-content/60 flex flex-wrap gap-x-2 text-[0.75em]'>
|
||||
{matchedBook && (
|
||||
<span title={entry.name} className='font-mono'>
|
||||
{formatShortHash(entry.name)}
|
||||
</span>
|
||||
)}
|
||||
{!entry.isDirectory && typeof entry.size === 'number' && (
|
||||
<span>{formatSize(entry.size)}</span>
|
||||
)}
|
||||
{entry.lastModified && (
|
||||
<span title={entry.lastModified}>
|
||||
{formatLastModified(entry.lastModified)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canDownload && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
// Stop propagation defensively — the parent
|
||||
// div is non-clickable for files today, but
|
||||
// keeps us safe if that ever changes.
|
||||
e.stopPropagation();
|
||||
handleDownloadEntry(entry);
|
||||
}}
|
||||
disabled={dlState === 'downloading' || dlState === 'done'}
|
||||
className={clsx(
|
||||
'btn btn-ghost btn-sm h-8 min-h-8 flex-shrink-0 px-2',
|
||||
(dlState === 'downloading' || dlState === 'done') && 'opacity-60',
|
||||
)}
|
||||
title={
|
||||
dlState === 'done'
|
||||
? t('Already downloaded in this session')
|
||||
: dlState === 'downloading'
|
||||
? t('Downloading…')
|
||||
: t('Download to library')
|
||||
}
|
||||
aria-label={
|
||||
dlState === 'done'
|
||||
? t('Already downloaded in this session')
|
||||
: dlState === 'downloading'
|
||||
? t('Downloading…')
|
||||
: t('Download to library')
|
||||
}
|
||||
>
|
||||
{dlState === 'downloading' ? (
|
||||
<span className='loading loading-spinner loading-xs' />
|
||||
) : dlState === 'done' ? (
|
||||
<MdCheck className='h-4 w-4' />
|
||||
) : (
|
||||
<MdDownload className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<div className='border-base-200 bg-base-100/70 flex flex-col gap-2 border-t px-4 py-3'>
|
||||
{/* Top row: scope. */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
// Toggle: clear if everything's selected, else select all.
|
||||
const allSelected =
|
||||
displayedEntries.length > 0 && selected.size === displayedEntries.length;
|
||||
setSelected(
|
||||
allSelected ? new Set() : new Set(displayedEntries.map((e) => e.path)),
|
||||
);
|
||||
}}
|
||||
disabled={displayedEntries.length === 0 || isDeleting}
|
||||
className={clsx(
|
||||
'btn btn-ghost btn-xs flex-shrink-0',
|
||||
(displayedEntries.length === 0 || isDeleting) && 'opacity-40',
|
||||
)}
|
||||
>
|
||||
{displayedEntries.length > 0 && selected.size === displayedEntries.length
|
||||
? t('Deselect all')
|
||||
: t('Select all')}
|
||||
</button>
|
||||
<span className='text-base-content/60 truncate text-xs'>
|
||||
{t('{{n}} selected', { n: selected.size })}
|
||||
</span>
|
||||
</div>
|
||||
{/* Bottom row: action. The per-entry download button
|
||||
provides recovery (ingestFile clears deletedAt on
|
||||
re-import), so cleanup needs no Restore counterpart. */}
|
||||
<div className='flex items-center justify-end gap-2'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleDelete}
|
||||
disabled={selected.size === 0 || isDeleting}
|
||||
className={clsx(
|
||||
'btn btn-error btn-sm flex-shrink-0 gap-1',
|
||||
(selected.size === 0 || isDeleting) && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* Always-rendered spinner span keeps the button
|
||||
width stable; `invisible` hides the pixels when
|
||||
idle without yielding the layout slot. */}
|
||||
<span
|
||||
className={clsx('loading loading-spinner loading-xs', !isDeleting && 'invisible')}
|
||||
/>
|
||||
{t('Delete from server')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebDAVBrowsePane;
|
||||
@@ -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<WebDAVFormProps> = ({ 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<typeof stored>) => {
|
||||
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<HTMLSelectElement>) => {
|
||||
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<void> };
|
||||
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<String, String>. The internal `headers ??
|
||||
// {}` default already proves a plain object works,
|
||||
// so cast and pass the headers object directly
|
||||
// rather than building a Map (which Tauri's IPC
|
||||
// serialiser handles less consistently).
|
||||
await tauriUpload(
|
||||
remoteUrl,
|
||||
dst,
|
||||
'PUT',
|
||||
undefined,
|
||||
headers as unknown as Map<string, string>,
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('WD library sync: tauriUpload failed', book.hash, e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
: undefined,
|
||||
loadBookCover: async (book) => {
|
||||
// Covers are best-effort — books without one (TXT/MD without
|
||||
// metadata, custom imports without art) just return null and
|
||||
// syncLibrary skips them silently.
|
||||
if (!appService) return null;
|
||||
const fp = getCoverFilename(book);
|
||||
if (!(await appService.exists(fp, 'Books'))) return null;
|
||||
const file = await appService.openFile(fp, 'Books');
|
||||
const bytes = await file.arrayBuffer();
|
||||
return { bytes, size: bytes.byteLength };
|
||||
},
|
||||
saveBookFile: async (book, bytes) => {
|
||||
if (!appService) return;
|
||||
const fp = getLocalBookFilename(book);
|
||||
await appService.writeFile(fp, 'Books', bytes);
|
||||
},
|
||||
// Tauri-only: stream the book straight to disk via the Rust
|
||||
// side instead of slurping it into a JS ArrayBuffer first. The
|
||||
// WebView<->Tauri IPC bridge cannot handle multi-megabyte
|
||||
// buffers on Android (the renderer is binder-killed mid-write),
|
||||
// so for any non-trivial epub/pdf this is the *only* path that
|
||||
// works reliably on mobile.
|
||||
downloadBookFile: isTauriAppPlatform()
|
||||
? async (book, remotePath) => {
|
||||
if (!appService) return false;
|
||||
const url = buildRequestUrl(stored.serverUrl, remotePath);
|
||||
const headers = {
|
||||
Authorization: buildBasicAuthHeader(stored.username, stored.password),
|
||||
};
|
||||
// The Rust downloader writes the file verbatim and does
|
||||
// NOT create parent dirs — make sure the per-hash folder
|
||||
// under Books exists before kicking off the stream.
|
||||
try {
|
||||
if (!(await appService.exists(book.hash, 'Books'))) {
|
||||
await appService.createDir(book.hash, 'Books', true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('WD library sync: mkdir failed', book.hash, e);
|
||||
}
|
||||
const dst = await appService.resolveFilePath(getLocalBookFilename(book), 'Books');
|
||||
try {
|
||||
await tauriDownload(url, dst, undefined, headers);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('WD library sync: tauriDownload failed', book.hash, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
saveBookCover: async (book, bytes) => {
|
||||
if (!appService) return;
|
||||
const fp = getCoverFilename(book);
|
||||
await appService.writeFile(fp, 'Books', bytes);
|
||||
},
|
||||
saveBookConfig: async (book, config) => {
|
||||
if (!appService) return;
|
||||
await appService.saveBookConfig(book, config, settings);
|
||||
},
|
||||
addBookToLibrary: async (book) => {
|
||||
if (!appService) return;
|
||||
try {
|
||||
book.coverImageUrl = await appService.generateCoverImageUrl(book);
|
||||
} catch (e) {
|
||||
// Missing or broken cover shouldn't block adding the book —
|
||||
// the bookshelf renders a placeholder when coverImageUrl
|
||||
// is empty.
|
||||
console.warn('WD library sync: cover URL generation failed', book.hash, e);
|
||||
book.coverImageUrl = null;
|
||||
}
|
||||
book.syncedAt = Date.now();
|
||||
book.downloadedAt = Date.now();
|
||||
if (!book.metaHash) book.metaHash = book.hash;
|
||||
const { library, setLibrary } = useLibraryStore.getState();
|
||||
// Avoid duplicates if the user runs Sync now twice quickly.
|
||||
if (library.find((b) => b.hash === book.hash)) return;
|
||||
const newLibrary = [...library, book];
|
||||
await appService.saveLibraryBooks(newLibrary);
|
||||
// Update the store last so subscribers re-render against a
|
||||
// library that's already persisted on disk.
|
||||
setLibrary(newLibrary);
|
||||
},
|
||||
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 <br>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 (
|
||||
<div className='w-full'>
|
||||
<SubPageHeader
|
||||
parentLabel={_('Integrations')}
|
||||
currentLabel={_('WebDAV')}
|
||||
description={description}
|
||||
onBack={onBack}
|
||||
/>
|
||||
|
||||
{isConfigured ? (
|
||||
<div className='space-y-5'>
|
||||
{/* 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. */}
|
||||
<BoxedList>
|
||||
<SettingsSwitchRow
|
||||
label={_('Upload Book Files')}
|
||||
description={_(
|
||||
'This toggle only controls ' +
|
||||
'whether this device contributes the books. ' +
|
||||
'Reading progress and annotations are always synced both ways, and books ' +
|
||||
'already on the server are always downloaded.',
|
||||
)}
|
||||
checked={stored.syncBooks ?? false}
|
||||
onChange={handleToggleSyncBooks}
|
||||
/>
|
||||
<SettingsRow label={_('Sync Strategy')}>
|
||||
<SettingsSelect
|
||||
value={stored.strategy ?? 'silent'}
|
||||
onChange={handleStrategyChange}
|
||||
ariaLabel={_('Sync Strategy')}
|
||||
options={[
|
||||
{ value: 'silent', label: _('Always use latest') },
|
||||
{ value: 'send', label: _('Send changes only') },
|
||||
{ value: 'receive', label: _('Receive changes only') },
|
||||
]}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label={
|
||||
syncProgressLabel
|
||||
? syncProgressLabel
|
||||
: stored.lastSyncedAt
|
||||
? _('Last synced {{when}}', {
|
||||
when: new Date(stored.lastSyncedAt).toLocaleString(),
|
||||
})
|
||||
: _('Never synced')
|
||||
}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleSyncNow}
|
||||
disabled={isSyncing}
|
||||
className={clsx(
|
||||
'btn btn-ghost btn-sm h-8 min-h-8 gap-1 px-2',
|
||||
isSyncing && 'opacity-60',
|
||||
)}
|
||||
title={_('Sync now')}
|
||||
aria-label={_('Sync now')}
|
||||
>
|
||||
{isSyncing ? (
|
||||
<span className='loading loading-spinner loading-xs' />
|
||||
) : (
|
||||
<MdCloudSync className='h-4 w-4' />
|
||||
)}
|
||||
{_('Sync now')}
|
||||
</button>
|
||||
</SettingsRow>
|
||||
</BoxedList>
|
||||
|
||||
{/* 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. */}
|
||||
<SyncHistoryPanel entries={stored.syncLog ?? []} onClear={handleClearSyncLog} t={_} />
|
||||
|
||||
<WebDAVBrowsePane settings={stored} t={_} onAppendSyncLogEntry={appendSyncLogEntry} />
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleDisconnect}
|
||||
className={clsx(
|
||||
'eink-bordered',
|
||||
'h-10 rounded-lg px-4 text-sm font-medium',
|
||||
'text-error hover:bg-error/10',
|
||||
'transition-colors duration-150',
|
||||
'focus-visible:ring-error/40 focus-visible:outline-none focus-visible:ring-2',
|
||||
)}
|
||||
>
|
||||
{_('Disconnect')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-5'>
|
||||
<form
|
||||
className='space-y-4'
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleConnect();
|
||||
}}
|
||||
>
|
||||
<div className='space-y-1.5'>
|
||||
<SectionTitle as='label' htmlFor='webdav-server-url' className='block'>
|
||||
{_('Server URL')}
|
||||
</SectionTitle>
|
||||
<input
|
||||
id='webdav-server-url'
|
||||
type='text'
|
||||
placeholder='https://dav.example.com'
|
||||
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
|
||||
spellCheck='false'
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<SectionTitle as='label' htmlFor='webdav-username' className='block'>
|
||||
{_('Username')}
|
||||
</SectionTitle>
|
||||
<input
|
||||
id='webdav-username'
|
||||
type='text'
|
||||
placeholder={_('Your Username')}
|
||||
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
|
||||
spellCheck='false'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete='username'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<SectionTitle as='label' htmlFor='webdav-password' className='block'>
|
||||
{_('Password')}
|
||||
</SectionTitle>
|
||||
<div className='relative'>
|
||||
<input
|
||||
id='webdav-password'
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder={_('Your Password')}
|
||||
className='input input-bordered eink-bordered h-11 w-full pe-11 text-sm focus:outline-none'
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete='current-password'
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className={clsx(
|
||||
'absolute end-2 top-1/2 -translate-y-1/2',
|
||||
'flex h-8 w-8 items-center justify-center rounded',
|
||||
'text-base-content/60 hover:text-base-content',
|
||||
'hover:bg-base-200/60 transition-colors duration-150',
|
||||
'focus-visible:ring-base-content/15 focus-visible:outline-none focus-visible:ring-2',
|
||||
)}
|
||||
aria-label={showPassword ? _('Hide password') : _('Show password')}
|
||||
title={showPassword ? _('Hide password') : _('Show password')}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<MdVisibilityOff className='h-4 w-4' />
|
||||
) : (
|
||||
<MdVisibility className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<SectionTitle as='label' htmlFor='webdav-root' className='block'>
|
||||
{_('Root Directory')}
|
||||
</SectionTitle>
|
||||
<input
|
||||
id='webdav-root'
|
||||
type='text'
|
||||
placeholder='/'
|
||||
className='input input-bordered eink-bordered h-11 w-full text-sm focus:outline-none'
|
||||
spellCheck='false'
|
||||
value={rootPath}
|
||||
onChange={(e) => setRootPath(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end pt-1'>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isConnecting || !url || !username}
|
||||
className={clsx(
|
||||
'btn btn-primary',
|
||||
'h-10 min-h-10 rounded-lg border-0 px-5 text-sm font-medium',
|
||||
'focus-visible:ring-primary/40 focus-visible:outline-none focus-visible:ring-2',
|
||||
isConnecting && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<span className='loading loading-spinner loading-sm' />
|
||||
) : (
|
||||
_('Connect')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebDAVForm;
|
||||
@@ -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<string>(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;
|
||||
}
|
||||
};
|
||||
@@ -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<SystemSettings> = {
|
||||
keepLogin: false,
|
||||
autoUpload: true,
|
||||
@@ -129,6 +144,7 @@ export const DEFAULT_SYSTEM_SETTINGS: Partial<SystemSettings> = {
|
||||
kosync: DEFAULT_KOSYNC_SETTINGS,
|
||||
readwise: DEFAULT_READWISE_SETTINGS,
|
||||
hardcover: DEFAULT_HARDCOVER_SETTINGS,
|
||||
webdav: DEFAULT_WEBDAV_SETTINGS,
|
||||
aiSettings: DEFAULT_AI_SETTINGS,
|
||||
|
||||
lastSyncedAtBooks: 0,
|
||||
|
||||
@@ -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 = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:displayname/>
|
||||
<D:resourcetype/>
|
||||
<D:getcontentlength/>
|
||||
<D:getlastmodified/>
|
||||
</D:prop>
|
||||
</D:propfind>`;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* `<resourcetype>` contains zero or one `<collection/>` 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 `<href>`. 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<WebDAVConnectResult> => {
|
||||
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
|
||||
* `<displayname>` (some servers omit it for the self-entry).
|
||||
*/
|
||||
export const listDirectory = async (
|
||||
config: WebDAVConfig,
|
||||
rootPath: string,
|
||||
): Promise<WebDAVEntry[]> => {
|
||||
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 <displayname>: 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<string, string>; body?: BodyInit | null } = {},
|
||||
): Promise<Response> => {
|
||||
const url = buildUrl(config.serverUrl, path);
|
||||
const fetchFn = getFetch();
|
||||
const headers: Record<string, string> = {
|
||||
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<string | null> => {
|
||||
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<ArrayBuffer | null> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<boolean> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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);
|
||||
};
|
||||
@@ -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:
|
||||
* <rootPath>/
|
||||
* Readest/
|
||||
* library.json ← shared index
|
||||
* books/
|
||||
* <hash>/
|
||||
* <safe-title>.<ext> ← 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 "<sanitized title>.<ext>" 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 `<hash>/<title>.<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;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
};
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user