Files
readest/apps/readest-app/src/components/settings/integrations/SyncHistoryPanel.tsx
T
loveheaven 5c82351ab9 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>
2026-05-22 18:55:12 +02:00

345 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;