Files
readest/apps/readest-app/src/store/fileSyncStore.ts
T
Huang Xin e08622b416 feat(sync): route library sync exclusively to the selected cloud provider (#4380) (#4975)
While WebDAV or Google Drive is the selected cloud sync backend, the
native Readest Cloud book/progress/note channels are gated off and the
file-sync engine owns library data end to end:

- isSyncCategoryEnabled returns false for book/progress/note (and their
  legacy aliases) when a third-party provider is selected. A runtime
  override, deliberately not written into syncCategories: the user's own
  toggles persist and take effect again on switch-back. Account channels
  (settings, stats, dictionaries, fonts, textures, OPDS catalogs) always
  stay native.
- persistActiveCloudProvider is the single write path for provider
  switching, used by the chooser, both connect/disconnect flows, and the
  Drive OAuth callback (which previously bypassed the cross-window
  broadcast). The broadcast carries ONLY the enabled flags plus
  providerSelectedAt, never credentials or sync cursors, and only on
  switch events, so a stale window's routine save cannot revert a switch.
- buildWebDAVConnectSettings no longer pre-sets enabled: activation
  belongs to withActiveCloudProvider, so the fresh-connect path now gets
  the syncBooks auto-flip and the providerSelectedAt stamp.
- Sync health: fileSyncStore records lastError per backend; the durable
  lastSyncedAt stays in provider settings. The SettingsMenu sync row
  reads Synced via provider / Sync failed and its tap (with pull to
  refresh and BackupWindow, all routed through pullLibrary) runs the
  file engine via the shared runActiveFileLibrarySync helper instead of
  a gated native pull that would toast undefined book(s) synced.
- Mixed-fleet detection: while gated, the auto-sync interval runs a
  read-only probe of /api/sync since providerSelectedAt; any newer book
  row means another device still syncs natively, and a once-per-session
  notice explains the fork instead of leaving it silent.
- Readest-Cloud-only affordances hide while a third-party provider is
  selected: the quota row becomes a caption naming the active provider,
  Auto Upload disappears from the menu and command palette, the
  BookItem upload badge and the Transfer Queue Upload All button hide.
- providerSelectedAt added to both provider settings types and the
  backup blacklist.

Stacked on the quota-decoupling change for #4959; requires the
metadata-parity change so gated channels lose nothing users can see.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:05:54 +02:00

130 lines
4.9 KiB
TypeScript

import { create } from 'zustand';
import type { FileSyncBackendKind } from '@/services/sync/file/providerRegistry';
/**
* Shared in-flight state for the library-wide file-sync "Sync now" run,
* generalised across backends (WebDAV, Google Drive, ...). Was `webdavSyncStore`.
*
* Lives outside React component state so the sync survives navigation inside the
* Settings dialog (drilling out to the Integrations list, or closing the dialog
* and reopening it) — a component `useState` would be destroyed on unmount,
* leaving a re-enabled "Sync now" button while the original `syncLibrary`
* promise was still running, with no progress affordance.
*
* Two responsibilities:
* - **Per-backend progress.** Each backend has its own progress entry under
* {@link byKind} so its Integrations row + form can render independently.
* - **A global library-sync mutex.** Every backend's `syncLibrary` mutates the
* SAME local library (adds downloaded books, reconciles metadata), so two
* backends must not run a manual Sync now at once. {@link beginSync} acquires
* the lock and returns `false` when another backend already holds it.
*
* Scope is deliberately narrow: only the manual library Sync now path uses this
* (the per-book reader hook tracks its own refs and surfaces no button), and it
* is process-local — never persisted, so an app killed mid-sync starts fresh.
*/
export interface ProviderSyncProgress {
/** True while this backend's library-wide Sync now is running. */
isSyncing: boolean;
/** Localised status line (e.g. "Uploading 3 / 12"), or null when idle. */
progressLabel: string | null;
/** Secondary line — the current book's title — or null. */
progressDetail: string | null;
/** Wall-clock millis when this backend's run kicked off, or null. */
startedAt: number | null;
}
/** Stable idle snapshot, so absent-backend selectors keep a constant identity. */
const IDLE: ProviderSyncProgress = Object.freeze({
isSyncing: false,
progressLabel: null,
progressDetail: null,
startedAt: null,
});
interface FileSyncState {
/** Per-backend progress; absent entries are idle (see {@link IDLE}). */
byKind: Partial<Record<FileSyncBackendKind, ProviderSyncProgress>>;
/** The backend currently holding the library-sync lock, or null when free. */
activeKind: FileSyncBackendKind | null;
/**
* Last terminal sync error per backend, surviving `endSync` so health
* surfaces (the SettingsMenu sync row, the chooser row status) can show
* "Sync failed" after the run finished. Cleared (set to null) by the
* next successful run. Process-local like the rest of this store — the
* durable "last synced" timestamp lives in the provider settings slice.
*/
lastErrorByKind: Partial<Record<FileSyncBackendKind, string | null>>;
/** Once-per-session latch for the mixed-fleet notice (see fleetDetection.ts). */
fleetNoticeShown: boolean;
/**
* Acquire the library-sync mutex for `kind` and mark it syncing. Returns
* `true` on success; `false` (leaving state untouched) when another backend
* already holds the lock. Callers MUST honour a `false` return and not sync.
*/
beginSync: (kind: FileSyncBackendKind, initialLabel: string) => boolean;
updateProgress: (kind: FileSyncBackendKind, label: string, detail?: string | null) => void;
endSync: (kind: FileSyncBackendKind) => void;
setLastError: (kind: FileSyncBackendKind, message: string | null) => void;
setFleetNoticeShown: () => void;
}
export const useFileSyncStore = create<FileSyncState>((set, get) => ({
byKind: {},
activeKind: null,
lastErrorByKind: {},
fleetNoticeShown: false,
beginSync: (kind, initialLabel) => {
// Global mutex: only one backend's library sync at a time, since they all
// mutate the same local library.
if (get().activeKind !== null) return false;
set((s) => ({
activeKind: kind,
byKind: {
...s.byKind,
[kind]: {
isSyncing: true,
progressLabel: initialLabel,
progressDetail: null,
startedAt: Date.now(),
},
},
}));
return true;
},
updateProgress: (kind, label, detail = null) =>
set((s) => ({
byKind: {
...s.byKind,
[kind]: {
...(s.byKind[kind] ?? IDLE),
isSyncing: true,
progressLabel: label,
progressDetail: detail,
},
},
})),
endSync: (kind) =>
set((s) => ({
activeKind: s.activeKind === kind ? null : s.activeKind,
byKind: { ...s.byKind, [kind]: IDLE },
})),
setLastError: (kind, message) =>
set((s) => ({
lastErrorByKind: { ...s.lastErrorByKind, [kind]: message },
})),
setFleetNoticeShown: () => set({ fleetNoticeShown: true }),
}));
/** Per-backend progress, idle when the backend has never started a run. */
export const selectProviderSyncProgress =
(kind: FileSyncBackendKind) =>
(state: FileSyncState): ProviderSyncProgress =>
state.byKind[kind] ?? IDLE;