cd3a53f507
* fix(sync): pull newer WebDAV book metadata to devices that already hold the book (#4756) syncLibrary only pulled title/author/cover for books missing from the local library. For a book a device already held it only pushed, so a peer's metadata edit never propagated back, and the final library.json re-push clobbered the peer's newer metadata with this device's stale copy. Add a last-writer-wins reconciliation pass keyed on book.updatedAt: when the shared index has a strictly newer copy of a locally-held book, merge its metadata, re-pull the cover, persist it via a new updateBookMetadata callback, and keep the merged copy authoritative for the index re-push so neither direction loses the edit. Surface a "metadata updated" counter in the sync toast and history, and translate the new strings across all locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(sync): merge remote config before pushing in WebDAV Sync now (#4756) The manual library "Sync now" pushed each book's config.json blind, so it could overwrite a peer's booknotes (element-set CRDT) or regress newer remote progress (per-config LWW) that this device had not pulled yet. The reader hook already pull-merges before pushing; the library path did not, so notes and progress could diverge or regress on the remote until a device happened to open the book. Give syncLibrary's config push the same read-merge-write cycle: pull-merge then push the merged superset, persisting it locally so the device converges too. Gated on canPull so 'silent' converges while 'send' keeps the local copy authoritative and 'receive' still never pushes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
710 lines
30 KiB
TypeScript
710 lines
30 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);
|
|
},
|
|
updateBookMetadata: async (book) => {
|
|
if (!appService) return;
|
|
// The cover bytes were just refreshed via saveBookCover, so
|
|
// regenerate the device-local blob URL the bookshelf renders.
|
|
try {
|
|
book.coverImageUrl = await appService.generateCoverImageUrl(book);
|
|
} catch (e) {
|
|
console.warn('WD library sync: cover URL generation failed', book.hash, e);
|
|
}
|
|
book.syncedAt = Date.now();
|
|
// updateBook persists via saveLibraryBooks and refreshes the store,
|
|
// so the new title / author / cover show up without a reload.
|
|
await useLibraryStore.getState().updateBook(envConfig, book);
|
|
},
|
|
onProgress: ({ book, index, total, action }) => {
|
|
const actionStr = action === 'downloading' ? _('Downloading') : _('Uploading');
|
|
updateProgress(
|
|
_('{{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.metadataUpdated > 0) {
|
|
parts.push(_('updated metadata for {{n}} book(s)', { n: result.metadataUpdated }));
|
|
}
|
|
if (result.configsUploaded > 0) {
|
|
parts.push(_('pushed {{n}} config(s)', { n: result.configsUploaded }));
|
|
}
|
|
if (stored.syncBooks && result.filesUploaded > 0) {
|
|
parts.push(_('uploaded {{n}} new file(s)', { n: result.filesUploaded }));
|
|
}
|
|
// Build the toast in two pieces so we can render the details on
|
|
// their own lines on mobile. The Toast component truncates
|
|
// single-line `info` messages (max-width + `truncate`), which
|
|
// chops the long detail string on small screens. Two ways out:
|
|
// 1. Use `success` type, which renders multi-line and shows a
|
|
// dismiss button — picked when there's actionable detail.
|
|
// 2. Stick with `info` for the short "everything up to date"
|
|
// string, which always fits in one line anyway.
|
|
// The detail bullets are joined with `\n` because Toast's
|
|
// renderer (Toast.tsx) already splits on newlines into <br>s.
|
|
let toastType: 'info' | 'success' | 'warning' = 'info';
|
|
let summary: string;
|
|
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;
|