Files
readest/apps/readest-app/src/components/settings/integrations/WebDAVForm.tsx
T
Huang Xin e80ab1762b refactor(settings): polish sync and integration panels (#4774)
- Background Image: move the Library/Reader scope into the section title
  ("Background Image (Library)" and "Background Image (Reader)") instead
  of a separate "Applies to ..." sublabel line.
- Send to Readest: render approved-sender emails monospace to match the
  inbound address, and wrap long addresses to at most two lines instead
  of truncating on one line.
- WebDAV: split the "Uploading X / Y" progress into a status line plus a
  one-line book title.
- WebDAV: reword the "Upload Book Files" description to "Uploads book
  files to your other devices."
- WebDAV: rename the "Always use latest" strategy to "Send and receive".
  KOSync keeps "Always use latest" since it must contrast with its
  "Ask on conflict" option.
- WebDAV: remove the Sync History section and its persisted log model;
  the sync engine still reports per-book failures in its result.

Updated i18n across all 33 locales.

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

693 lines
29 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 { isTauriAppPlatform } from '@/services/environment';
import { tauriDownload, tauriUpload } from '@/utils/transfer';
import { eventDispatcher } from '@/utils/event';
import {
buildBasicAuthHeader,
buildRequestUrl,
checkConnection,
normalizeRootPath,
WebDAVConnectResult,
WebDAVRequestError,
} from '@/services/webdav/WebDAVClient';
import { type TranslationFunc } from '@/hooks/useTranslation';
import { syncLibrary } from '@/services/webdav/WebDAVSync';
import { buildWebDAVConnectSettings } from '@/services/webdav/webdavConnectSettings';
import { getCoverFilename, getLocalBookFilename } from '@/utils/book';
import 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 WebDAVRequestError) {
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 handleStrategyChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
await persistWebdav({ strategy: e.target.value as typeof stored.strategy });
};
/**
* Manual "Sync now" — push every book in the local library up to the
* remote in a single sequential pass. We don't pull here; the per-
* book Reader hook handles incoming changes when the user opens a
* book.
*
* 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 {{n}} / {{total}}', { n: 0, total: eligibleBooks.length }));
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;
// In-place imports live outside Books/<hash>/; resolve to
// (book.filePath, 'None') when set. Hash-copy books fall
// through to the original Books-relative path. Same fallback
// pattern as cloudService.uploadBook so library Sync now
// treats in-place books as first-class.
const fp = book.filePath ?? getLocalBookFilename(book);
const base = book.filePath ? 'None' : 'Books';
if (!(await appService.exists(fp, base))) return null;
const file = await appService.openFile(fp, base);
const bytes = await file.arrayBuffer();
return { bytes, size: bytes.byteLength };
},
// Tauri-only: stream the book file straight from disk to the
// WebDAV server via Rust-side `upload_file`, never letting the
// bytes land in the JS heap. Without this, syncing a library
// with multiple multi-hundred-megabyte PDFs accumulates
// ArrayBuffers that V8 can't free fast enough between
// sequential `pushBookFile` calls — the renderer eventually
// hits its heap ceiling and the WebView crashes mid-sync,
// surfacing as a blank white screen on desktop and as a
// binder-OOM kill on Android. The metadata-only fast path
// (open file just to read `.size`) keeps the HEAD short-
// circuit working the same way the buffered path does.
loadBookFileStreaming: isTauriAppPlatform()
? async (book) => {
if (!appService) return null;
const fp = book.filePath ?? getLocalBookFilename(book);
const base = book.filePath ? 'None' : 'Books';
if (!(await appService.exists(fp, base))) return null;
const file = await appService.openFile(fp, base);
const size = file.size;
// openFile returns a File-like handle; close eagerly when
// the platform exposes it so the Tauri side can re-open
// the path for the streamed PUT without holding two FDs.
const closable = file as { close?: () => Promise<void> };
if (closable.close) await closable.close();
const dst = await appService.resolveFilePath(fp, base);
return {
size,
upload: async (remoteUrl, headers) => {
try {
// tauriUpload's TS type says Map, but its Tauri
// command on the Rust side accepts a JSON object →
// HashMap<String, String>. The internal `headers ??
// {}` default already proves a plain object works,
// so cast and pass the headers object directly
// rather than building a Map (which Tauri's IPC
// serialiser handles less consistently).
await tauriUpload(
remoteUrl,
dst,
'PUT',
undefined,
headers as unknown as Map<string, string>,
);
return true;
} catch (e) {
console.warn('WD library sync: tauriUpload failed', book.hash, e);
return false;
}
},
};
}
: undefined,
loadBookCover: async (book) => {
// Covers are best-effort — books without one (TXT/MD without
// metadata, custom imports without art) just return null and
// syncLibrary skips them silently.
if (!appService) return null;
const fp = getCoverFilename(book);
if (!(await appService.exists(fp, 'Books'))) return null;
const file = await appService.openFile(fp, 'Books');
const bytes = await file.arrayBuffer();
return { bytes, size: bytes.byteLength };
},
saveBookFile: async (book, bytes) => {
if (!appService) return;
const fp = getLocalBookFilename(book);
await appService.writeFile(fp, 'Books', bytes);
},
// Tauri-only: stream the book straight to disk via the Rust
// side instead of slurping it into a JS ArrayBuffer first. The
// WebView<->Tauri IPC bridge cannot handle multi-megabyte
// buffers on Android (the renderer is binder-killed mid-write),
// so for any non-trivial epub/pdf this is the *only* path that
// works reliably on mobile.
downloadBookFile: isTauriAppPlatform()
? async (book, remotePath) => {
if (!appService) return false;
const url = buildRequestUrl(stored.serverUrl, remotePath);
const headers = {
Authorization: buildBasicAuthHeader(stored.username, stored.password),
};
// The Rust downloader writes the file verbatim and does
// NOT create parent dirs — make sure the per-hash folder
// under Books exists before kicking off the stream.
try {
if (!(await appService.exists(book.hash, 'Books'))) {
await appService.createDir(book.hash, 'Books', true);
}
} catch (e) {
console.warn('WD library sync: mkdir failed', book.hash, e);
}
const dst = await appService.resolveFilePath(getLocalBookFilename(book), 'Books');
try {
await tauriDownload(url, dst, undefined, headers);
return true;
} catch (e) {
console.warn('WD library sync: tauriDownload failed', book.hash, e);
return false;
}
}
: undefined,
saveBookCover: async (book, bytes) => {
if (!appService) return;
const fp = getCoverFilename(book);
await appService.writeFile(fp, 'Books', bytes);
},
saveBookConfig: async (book, config) => {
if (!appService) return;
await appService.saveBookConfig(book, config, settings);
},
addBookToLibrary: async (book) => {
if (!appService) return;
try {
book.coverImageUrl = await appService.generateCoverImageUrl(book);
} catch (e) {
// Missing or broken cover shouldn't block adding the book —
// the bookshelf renders a placeholder when coverImageUrl
// is empty.
console.warn('WD library sync: cover URL generation failed', book.hash, e);
book.coverImageUrl = null;
}
book.syncedAt = Date.now();
book.downloadedAt = Date.now();
if (!book.metaHash) book.metaHash = book.hash;
const { library, setLibrary } = useLibraryStore.getState();
// Avoid duplicates if the user runs Sync now twice quickly.
if (library.find((b) => b.hash === book.hash)) return;
const newLibrary = [...library, book];
await appService.saveLibraryBooks(newLibrary);
// Update the store last so subscribers re-render against a
// library that's already persisted on disk.
setLibrary(newLibrary);
},
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() });
// Build a compact, accurate summary. Downloads happen regardless
// of the `syncBooks` toggle, so they're always part of the toast;
// the upload counters are only included when there was anything
// to push (otherwise they'd just be a wall of zeros).
const parts: string[] = [];
if (result.booksDownloaded > 0) {
parts.push(_('downloaded {{n}} book(s)', { n: result.booksDownloaded }));
}
if (result.configsDownloaded > 0) {
parts.push(_('pulled progress for {{n}} book(s)', { n: result.configsDownloaded }));
}
if (result.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,
});
} 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}
/>
<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;