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>
189 lines
8.2 KiB
TypeScript
189 lines
8.2 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { useEnv } from '@/context/EnvContext';
|
|
import { useTranslation } from '@/hooks/useTranslation';
|
|
import { useQuotaStats } from '@/hooks/useQuotaStats';
|
|
import { useSettingsStore } from '@/store/settingsStore';
|
|
import { useLibraryStore } from '@/store/libraryStore';
|
|
import { useFileSyncStore } from '@/store/fileSyncStore';
|
|
import { isCloudSyncAllowed } from '@/utils/access';
|
|
import { debounce } from '@/utils/debounce';
|
|
import { FileSyncEngine } from '@/services/sync/file/engine';
|
|
import { createAppLocalStore } from '@/services/sync/file/appLocalStore';
|
|
import {
|
|
createFileSyncProvider,
|
|
type FileSyncBackendKind,
|
|
} from '@/services/sync/file/providerRegistry';
|
|
import { getCloudSyncProvider } from '@/services/sync/cloudSyncProvider';
|
|
|
|
/**
|
|
* Library-scoped auto-sync for the active third-party cloud provider (WebDAV /
|
|
* Google Drive) — the parity counterpart of {@link useBooksSync} (native cloud).
|
|
*
|
|
* The reader's per-book `useFileSync` keeps a book's progress/notes in sync while
|
|
* reading, but never touches the shared `library.json` index. This hook fills
|
|
* that gap: it runs the active provider's `engine.syncLibrary` whenever the
|
|
* library changes — importing, deleting, or closing a book all mutate the
|
|
* library array — so `library.json` (book metadata + tombstones) stays current
|
|
* across devices, exactly as native sync does. Mount once on the library page.
|
|
*
|
|
* Behaviour:
|
|
* - Debounced to collapse import bursts (and the engine's own
|
|
* pull-merge writes, which re-fire this effect — debounce + the engine's
|
|
* incremental cursor make repeats converge to a no-op).
|
|
* - Gated on the global file-sync mutex so it never collides with a manual
|
|
* "Sync now" or another backend reconciling the same local library.
|
|
* - Honours the provider's Sync Strategy (send / receive / silent) and the
|
|
* "Upload Book Files" toggle, same as the manual sync.
|
|
* - Passes the FULL library, including soft-deleted books, so deletions
|
|
* propagate as tombstones in `library.json` and deleted books are not
|
|
* re-discovered and re-downloaded.
|
|
*/
|
|
|
|
/** Quiet window before an auto library sync fires; collapses import bursts. */
|
|
const SYNC_DEBOUNCE_MS = 5_000;
|
|
|
|
const settingsKeyFor = (kind: FileSyncBackendKind): 'webdav' | 'googleDrive' =>
|
|
kind === 'gdrive' ? 'googleDrive' : 'webdav';
|
|
|
|
export const useLibraryFileSync = () => {
|
|
const _ = useTranslation();
|
|
const { envConfig, appService } = useEnv();
|
|
const { setSettings, saveSettings } = useSettingsStore();
|
|
const settings = useSettingsStore((s) => s.settings);
|
|
const library = useLibraryStore((s) => s.library);
|
|
const libraryLoaded = useLibraryStore((s) => s.libraryLoaded);
|
|
const { userProfilePlan } = useQuotaStats();
|
|
|
|
// The single active cloud provider (WebDAV and Google Drive are exclusive).
|
|
const provider = getCloudSyncProvider(settings);
|
|
const activeKind: FileSyncBackendKind | null = provider === 'readest' ? null : provider;
|
|
|
|
const isAllowed = isCloudSyncAllowed(userProfilePlan ?? 'free');
|
|
const isReady = useMemo(() => {
|
|
if (!isAllowed) return false;
|
|
if (activeKind === 'webdav') {
|
|
const w = settings.webdav;
|
|
return !!(w?.enabled && w?.serverUrl && w?.username);
|
|
}
|
|
if (activeKind === 'gdrive') return !!settings.googleDrive?.enabled;
|
|
return false;
|
|
}, [isAllowed, activeKind, settings.webdav, settings.googleDrive]);
|
|
|
|
// Build the engine async (Drive probes the OS keychain). Keyed on the
|
|
// connection-relevant settings so an unrelated write (e.g. lastSyncedAt)
|
|
// doesn't rebuild it — which for Drive would re-probe the keychain.
|
|
const engineKey = useMemo(() => {
|
|
if (activeKind === 'webdav') {
|
|
const w = settings.webdav;
|
|
return `webdav:${w?.enabled}:${w?.serverUrl}:${w?.username}:${w?.password}:${w?.rootPath}`;
|
|
}
|
|
if (activeKind === 'gdrive') return `gdrive:${settings.googleDrive?.enabled}`;
|
|
return 'none';
|
|
}, [activeKind, settings.webdav, settings.googleDrive]);
|
|
|
|
const [engine, setEngine] = useState<FileSyncEngine | null>(null);
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setEngine(null);
|
|
if (!isReady || !appService || activeKind === null) return;
|
|
(async () => {
|
|
const current = useSettingsStore.getState().settings;
|
|
const provider = await createFileSyncProvider(activeKind, current);
|
|
if (cancelled || !provider) return;
|
|
const store = createAppLocalStore({ appService, settings: current, envConfig });
|
|
setEngine(new FileSyncEngine(provider, store));
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [engineKey, isReady, activeKind, appService, envConfig]);
|
|
|
|
const ensureDeviceId = useCallback((): string => {
|
|
const latest = useSettingsStore.getState().settings;
|
|
const key = activeKind ? settingsKeyFor(activeKind) : 'webdav';
|
|
let id = latest[key]?.deviceId;
|
|
if (!id) {
|
|
id = uuidv4();
|
|
const next = { ...latest, [key]: { ...latest[key], deviceId: id } };
|
|
setSettings(next);
|
|
saveSettings(envConfig, next);
|
|
}
|
|
return id;
|
|
}, [activeKind, envConfig, setSettings, saveSettings]);
|
|
|
|
const updateLastSyncedAt = useCallback(
|
|
async (ts: number) => {
|
|
const latest = useSettingsStore.getState().settings;
|
|
const key = activeKind ? settingsKeyFor(activeKind) : 'webdav';
|
|
const next = { ...latest, [key]: { ...latest[key], lastSyncedAt: ts } };
|
|
setSettings(next);
|
|
await saveSettings(envConfig, next);
|
|
},
|
|
[activeKind, envConfig, setSettings, saveSettings],
|
|
);
|
|
|
|
const runSync = useCallback(async () => {
|
|
if (!engine || activeKind === null || !isReady) return;
|
|
// NEVER sync before the library is loaded from disk: syncing a transient
|
|
// empty library could push an empty index and clobber the remote.
|
|
if (!useLibraryStore.getState().libraryLoaded) return;
|
|
|
|
const kind = activeKind;
|
|
const latest = useSettingsStore.getState().settings;
|
|
const ps = kind === 'gdrive' ? latest.googleDrive : latest.webdav;
|
|
const strategy = ps?.strategy ?? 'silent';
|
|
|
|
const syncStore = useFileSyncStore.getState();
|
|
// Honour the global library-sync mutex: a manual "Sync now" (or another
|
|
// backend) already reconciling the same local library wins; this auto-run
|
|
// skips and the next library change re-triggers it.
|
|
if (!syncStore.beginSync(kind, _('Syncing…'))) return;
|
|
try {
|
|
const books = useLibraryStore.getState().library;
|
|
const deviceId = ensureDeviceId();
|
|
await engine.syncLibrary(books, {
|
|
strategy: strategy === 'prompt' ? 'silent' : strategy,
|
|
syncBooks: ps?.syncBooks ?? false,
|
|
fullSync: false,
|
|
deviceId,
|
|
onProgress: ({ index, total, action }) => {
|
|
const label = action === 'downloading' ? _('Downloading') : _('Uploading');
|
|
useFileSyncStore
|
|
.getState()
|
|
.updateProgress(
|
|
kind,
|
|
_('{{action}} {{n}} / {{total}}', { action: label, n: index + 1, total }),
|
|
);
|
|
},
|
|
});
|
|
await updateLastSyncedAt(Date.now());
|
|
useFileSyncStore.getState().setLastError(kind, null);
|
|
} catch (e) {
|
|
useFileSyncStore.getState().setLastError(kind, e instanceof Error ? e.message : String(e));
|
|
console.warn('library file sync failed', e);
|
|
} finally {
|
|
useFileSyncStore.getState().endSync(kind);
|
|
}
|
|
}, [engine, activeKind, isReady, ensureDeviceId, updateLastSyncedAt, _]);
|
|
|
|
// Keep one stable debounced trigger that always calls the latest runSync (via
|
|
// ref), so it isn't recreated — and lost — on every settings/engine change.
|
|
const runSyncRef = useRef(runSync);
|
|
runSyncRef.current = runSync;
|
|
const debouncedSync = useMemo(
|
|
() => debounce(() => void runSyncRef.current(), SYNC_DEBOUNCE_MS),
|
|
[],
|
|
);
|
|
useEffect(() => () => debouncedSync.cancel(), [debouncedSync]);
|
|
|
|
// Library changes — import (adds a row), delete (sets deletedAt), book close
|
|
// (bumps updatedAt) — all mutate `library`, so this single effect covers them
|
|
// plus the initial load pull.
|
|
useEffect(() => {
|
|
if (!isReady || !engine || !libraryLoaded) return;
|
|
debouncedSync();
|
|
}, [library, libraryLoaded, isReady, engine, debouncedSync]);
|
|
};
|