Files
readest/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx
T
Huang Xin 99b9adfe85 refactor(sync): provider-agnostic file-sync engine with incremental WebDAV sync (#4784)
* refactor(sync): extract provider-agnostic layout paths

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): extract wire envelope module

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): extract pure merge module with law tests

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): add FileSyncProvider and LocalStore interfaces

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): FileSyncEngine orchestration over a provider

Port WebDAVSync's per-book + library-wide sync onto FileSyncProvider +
LocalStore. Behavior preserved; the #4756 metadata-reconciliation test is
retargeted to drive the engine through a fake provider + store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): move WebDAV client + connect settings under providers/webdav

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): WebDAVProvider implementing FileSyncProvider

Wraps the WebDAV transport client, maps WebDAVRequestError to the neutral
FileSyncError, and owns Tauri streaming upload/download. Adds a
provider-conformance suite future backends can run against.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): shared appService-backed LocalStore bridge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reader): drive WebDAV sync through FileSyncEngine

Construct a WebDAVProvider + shared LocalStore + engine once per hook; the
inline buffered/streaming book-file loader collapses into the provider +
store, so the hook no longer imports tauriUpload or the file path helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(settings): drive WebDAV library sync + browse through the provider

WebDAVForm now builds a WebDAVProvider + shared LocalStore + engine and calls
engine.syncLibrary; the ~170-line inline callback block (buffered/streaming
loaders, URL+auth construction) is gone. WebDAVBrowsePane builds a provider for
the engine-level deleteRemoteBookDir cleanup helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): remove WebDAV-specific sync module, WebDAV is now a provider

Delete src/services/webdav (WebDAVSync/WebDAVPaths + the transitional client
and connect-settings shims). The superseded webdav-metadata-sync test is
replaced by engine-metadata-sync; webdav-delete now drives deleteRemoteBookDir
through a WebDAVProvider and asserts FileSyncError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): hydrate library before WebDAV Sync now to prevent clobber

Sync now while the library store was unloaded (app launched into reader/
settings without mounting the Library view) merged the engine's
addBookToLibrary / updateBookMetadata against an empty in-memory library,
persisting a downloaded book or a metadata update as the entire library and
wiping what was on disk. Pre-existing bug surfaced during the file-sync
review. Hydrate the store in handleSyncNow and harden the store bridge with a
load-if-unloaded guard (mirrors useLibraryStore.updateBooks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): make listDirectory honor the FileSyncError contract

listDirectory threw a plain Error (and let raw fetch failures escape), so
WebDAVProvider flattened every list() failure to FileSyncError(UNKNOWN). Throw
the same WebDAVRequestError taxonomy as the file-level helpers (AUTH_FAILED /
NOT_FOUND / NETWORK) so the provider maps them correctly. Add list() cases to
the provider-conformance suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(sync): cover streaming upload, discovery/download, and receive paths

The metadata-sync gate only exercised the buffered metadata + config-merge
paths. Add engine tests for streaming uploadStream (+ HEAD short-circuit +
one-shot retry), remote-only discovery -> streaming download -> addBook, and
the receive strategy (pull-only, no config or index writes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): incremental WebDAV Sync now + bounded concurrency

Sync now was a full walk of every book each run (675 round-trips even when
nothing changed). Default to incremental: diff the local library against the
shared library.json index per hash and only process books whose local copy is
newer (or absent). book.updatedAt bumps on every progress/notes/metadata save
(bookDataStore.saveConfig), so the index is a reliable per-book change marker.
Remote-newer books pull their config in the reconcile pass so peer progress
still propagates. A new 'Full Sync' toggle (default off) re-checks everything.

Also run the reconcile / download / push phases over a bounded worker pool
(default concurrency 4) instead of one book at a time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sync): simplify Sync now toast to a single book count

The completion toast built a multi-line success bullet list (downloaded /
pulled / pushed / uploaded). Replace it with the same single-line info toast
the native cloud sync uses: '{{count}} book(s) synced'. Add a booksSynced
counter to the engine result (a Set of distinct hashes touched in any
direction, since the per-action counters overlap under Full Sync). Failures
still surface as a warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ui): raise toasts above modals so they aren't hidden by open dialogs

Toasts rendered at z-50, below the Settings dialog (z-110) and ModalPortal
(z-120), so a toast dispatched from an open dialog (e.g. WebDAV 'Sync now')
was buried. The documented overlay scale already places toast at 130; the
component just hadn't followed it. Move the toast to z-[130] and extend the
zIndexScale invariant test to guard TOAST > MODAL/SETTINGS and APP_LOCK > TOAST.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:57:52 +02:00

531 lines
21 KiB
TypeScript

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 { eventDispatcher } from '@/utils/event';
import {
checkConnection,
normalizeRootPath,
WebDAVConnectResult,
} from '@/services/sync/providers/webdav/client';
import { type TranslationFunc } from '@/hooks/useTranslation';
import { createWebDAVProvider } from '@/services/sync/providers/webdav/WebDAVProvider';
import { buildWebDAVConnectSettings } from '@/services/sync/providers/webdav/connectSettings';
import { FileSyncEngine } from '@/services/sync/file/engine';
import { FileSyncError } from '@/services/sync/file/provider';
import { createAppLocalStore } from '@/services/sync/file/appLocalStore';
import SubPageHeader from '../SubPageHeader';
import {
BoxedList,
SectionTitle,
SettingsRow,
SettingsSwitchRow,
SettingsSelect,
} from '../primitives';
import WebDAVBrowsePane from './WebDAVBrowsePane';
interface WebDAVFormProps {
onBack: () => void;
}
/**
* Translate a connection-probe failure into a user-facing string.
*
* Each branch must be a literal `_('...')` call so the i18next-scanner
* picks the keys up — that's why this is a switch on `result.code`
* rather than the previous `_(result.message || 'Connection error')`
* pattern, which the scanner couldn't see into.
*/
const formatConnectError = (_: TranslationFunc, result: WebDAVConnectResult): string => {
switch (result.code) {
case 'SERVER_URL_REQUIRED':
return _('Server URL is required');
case 'AUTH_FAILED':
return _('Authentication failed');
case 'ROOT_NOT_FOUND':
return _('Root directory not found');
case 'UNEXPECTED_STATUS':
return _('Unexpected server response (status {{status}})', {
status: result.status ?? 0,
});
case 'NETWORK':
default:
return _('Network error');
}
};
/**
* Translate a sync-time error into a user-facing string. WebDAVRequestError
* carries a `code` that lets us map to a specific message without ever
* showing the raw English `e.message` to the user.
*/
const formatSyncError = (_: TranslationFunc, e: unknown): string => {
if (e instanceof FileSyncError) {
switch (e.code) {
case 'AUTH_FAILED':
return _('WebDAV authentication failed. Reconnect in Settings.');
case 'NOT_FOUND':
return _('Remote resource not found');
case 'NETWORK':
return _('Network error');
}
if (typeof e.status === 'number') {
return _('Sync failed (status {{status}})', { status: e.status });
}
}
return _('Sync failed.');
};
/**
* 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 syncProgressDetail = useWebDAVSyncStore((s) => s.progressDetail);
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')}: ${formatConnectError(_, result)}`,
});
setIsConnecting(false);
return;
}
// Spread previous webdav state so a reconnect preserves bookkeeping
// fields earned by prior use — deviceId, syncBooks, strategy,
// syncProgress, syncNotes, lastSyncedAt. 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`. Several persistWebdav calls can
// land back-to-back — e.g. `handleSyncNow` writes `deviceId` up front
// and `lastSyncedAt` when it finishes, and the user may flip a toggle
// in between. The closure's `settings` was captured before those
// writes, so a closure-based merge would rebuild the webdav object
// from a stale snapshot and clobber a freshly-written field. 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 handleToggleFullSync = () => persistWebdav({ fullSync: !(stored?.fullSync ?? false) });
const handleStrategyChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
await persistWebdav({ strategy: e.target.value as typeof stored.strategy });
};
/**
* Manual "Sync now" — reconcile the local library with the remote over a
* bounded-concurrency pool. By default this is incremental: only books whose
* local copy differs from the shared library.json index are processed
* (`book.updatedAt` is the per-book change marker). The "Full Sync" toggle
* re-checks every book. The engine pulls peers' changes and pushes ours; the
* per-book Reader hook still handles live changes as the user reads.
*
* Concurrency is capped (engine default 4) so shared WebDAV servers
* (NextCloud, Synology, …) aren't hammered while still hiding per-request
* latency. The run is async relative to the UI — we surface a status string
* and disable the button.
*/
const handleSyncNow = async () => {
// Re-entrancy gate must read the live store, not the closure: a
// 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();
// Hydrate the store before syncing. The engine's addBookToLibrary /
// updateBookMetadata merge against the in-memory library; if it were
// still empty here, a downloaded book or a metadata update would persist
// as the *entire* library and clobber what's on disk. setLibrary also
// flips libraryLoaded so the per-book store calls see a loaded store.
useLibraryStore.getState().setLibrary(currentLibrary);
}
const eligibleBooks = currentLibrary.filter((b) => !b.deletedAt);
// 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 {{n}} / {{total}}', { n: 0, total: eligibleBooks.length }));
try {
// The provider owns the WebDAV URL + auth + streaming transport; the
// shared local-store bridge owns all on-disk book/cover/config I/O
// (including the in-place vs hash-copy path resolution and the Tauri
// streaming fast path). This form no longer knows any WebDAV specifics.
const provider = createWebDAVProvider(stored);
const store = createAppLocalStore({ appService, settings, envConfig });
const engine = new FileSyncEngine(provider, store);
const result = await engine.syncLibrary(eligibleBooks, {
strategy: stored.strategy === 'prompt' ? 'silent' : stored.strategy,
syncBooks: stored.syncBooks ?? false,
fullSync: stored.fullSync ?? false,
deviceId: deviceId as string,
onProgress: ({ book, index, total, action }) => {
const actionStr = action === 'downloading' ? _('Downloading') : _('Uploading');
updateProgress(
_('{{action}} {{n}} / {{total}}', { action: actionStr, n: index + 1, total }),
book.title || book.hash.slice(0, 8),
);
},
});
await persistWebdav({ lastSyncedAt: Date.now() });
// Keep the toast as simple as the native cloud sync: a single-line
// "{{count}} book(s) synced" info message. Failures still surface as a
// warning so a partial sync isn't reported as a clean success.
if (result.failures > 0) {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Sync finished with {{failed}} failure(s). {{ok}} ok.', {
failed: result.failures,
ok: Math.max(0, result.totalBooks - result.failures),
}),
});
} else {
eventDispatcher.dispatch('toast', {
type: 'info',
message: _('{{count}} book(s) synced', { count: result.booksSynced }),
});
}
} catch (e) {
const message = formatSyncError(_, e);
eventDispatcher.dispatch('toast', { type: 'error', message });
} 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={_('Uploads book files to your other devices.')}
checked={stored.syncBooks ?? false}
onChange={handleToggleSyncBooks}
/>
<SettingsSwitchRow
label={_('Full Sync')}
description={_('Re-check every book instead of only changed ones.')}
checked={stored.fullSync ?? false}
onChange={handleToggleFullSync}
/>
<SettingsRow label={_('Sync Strategy')}>
<SettingsSelect
value={stored.strategy ?? 'silent'}
onChange={handleStrategyChange}
ariaLabel={_('Sync Strategy')}
options={[
{ value: 'silent', label: _('Send and receive') },
{ 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')
}
description={
syncProgressDetail ? (
<span className='line-clamp-1'>{syncProgressDetail}</span>
) : undefined
}
>
<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>
<WebDAVBrowsePane settings={stored} />
<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;