e08622b416
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>
130 lines
4.9 KiB
TypeScript
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;
|