forked from akai/readest
59d4f0aa33
setProgress was called multiple times per swipe burst, each call writing into readerStore.viewStates[key].progress. ~65 places in the reader subtree subscribed to useReaderStore() without a selector, so every setProgress fan-out re-rendered all of them -- even the 51 that didn't care about progress. On Android release builds this showed up as Layout = 9.8% and Function Call = 9.6% of main-thread self time in Chrome DevTools' Bottom-Up profile during a reading session. Fix: - New tiny store store/readerProgressStore.ts holds the per-book BookProgress map. setBookProgress only fires its own subscribers. - readerStore.setProgress now writes progress to the new store and only touches bookDataStore for the primary view (secondary parallel views shouldn't overwrite the shared config). - readerStore.getProgress is kept as a delegating facade so existing imperative call sites don't break. - Components / hooks that genuinely need to react to progress changes subscribe via the new useBookProgress(bookKey) hook. The handful of call sites that just want a one-shot read use getBookProgress(key) so they don't subscribe at all. - readerStore.clearViewState calls clearBookProgress so the map doesn't grow unbounded across book opens/closes. See store/readerProgressStore.ts header for the full rationale.
605 lines
25 KiB
TypeScript
605 lines
25 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import { useEnv } from '@/context/EnvContext';
|
||
import { useBookDataStore } from '@/store/bookDataStore';
|
||
import { useReaderStore } from '@/store/readerStore';
|
||
import { useBookProgress } from '@/store/readerProgressStore';
|
||
import { useSettingsStore } from '@/store/settingsStore';
|
||
import { useTranslation } from '@/hooks/useTranslation';
|
||
import { debounce } from '@/utils/debounce';
|
||
import { eventDispatcher } from '@/utils/event';
|
||
import {
|
||
pullBookConfig,
|
||
pushBookConfig,
|
||
pushBookCover,
|
||
pushBookFile,
|
||
} from '@/services/webdav/WebDAVSync';
|
||
import { WebDAVRequestError } from '@/services/webdav/WebDAVClient';
|
||
import { isTauriAppPlatform } from '@/services/environment';
|
||
import { tauriUpload } from '@/utils/transfer';
|
||
import { getCoverFilename, getLocalBookFilename } from '@/utils/book';
|
||
import { removeBookNoteOverlays } from '../utils/annotatorUtil';
|
||
import { useWindowActiveChanged } from './useWindowActiveChanged';
|
||
|
||
/**
|
||
* WebDAV per-book sync hook.
|
||
*
|
||
* Mirrors the architecture of `useKOSync` / `useProgressSync`: a single
|
||
* Reader-level hook drives both progress and booknote sync against
|
||
* `<rootPath>/Readest/books/<hash>/config.json`. Pull-once on book open,
|
||
* debounced push on progress / booknote changes, manual flush on
|
||
* `flush-webdav-sync` event.
|
||
*
|
||
* Energy budget — these constants are deliberately tuned for mobile:
|
||
* - Push debounce: 15 s. Real reading sessions involve continuous
|
||
* page-turns, so a longer window collapses many turns into one PUT.
|
||
* - Pull cooldown: 60 s. Window focus shouldn't trigger a fresh PROPFIND
|
||
* on every alt-tab; once a minute is plenty for cross-device drift.
|
||
* - Open-pull skip: 30 s. Quickly closing/reopening a book shouldn't
|
||
* re-fetch the same config that's already current in memory.
|
||
*
|
||
* Gating:
|
||
* - `settings.webdav.enabled` must be true (master switch on the WebDAV
|
||
* sub-page in Integrations)
|
||
* - `settings.webdav.serverUrl` and `settings.webdav.username` must be
|
||
* non-empty (the Connect flow guarantees this when enabled is true,
|
||
* but defensive check is cheap)
|
||
*
|
||
* Strategy semantics — same vocabulary as KOSync so users only learn one:
|
||
* - 'silent' (default): always push and always pull, latest writer wins
|
||
* - 'send': push only, never pull (this device feeds others)
|
||
* - 'receive': pull only, never push (this device follows others)
|
||
* - 'prompt': not implemented in v1 — falls back to 'silent'
|
||
*/
|
||
|
||
/** Debounce window for auto-push triggered by progress / booknote churn. */
|
||
const PUSH_DEBOUNCE_MS = 15_000;
|
||
/** Minimum gap between automatic pulls (e.g. window-focus, open-book). */
|
||
const PULL_COOLDOWN_MS = 60_000;
|
||
/**
|
||
* If this hook ran a successful pull less than this long ago for the
|
||
* current book, skip the open-book pull entirely.
|
||
*
|
||
* Note: `lastPulledAtRef` is component-instance state, so closing the
|
||
* reader unmounts the hook and resets the ref. A real "close-then-
|
||
* reopen" therefore *doesn't* trigger this guard — the new instance
|
||
* starts at 0 and proceeds to pull. The skip only fires when the
|
||
* open-book effect re-runs within a single hook lifetime (e.g.
|
||
* navigating between two books in the same reader window, or
|
||
* progress arriving in two ticks before `hasPulledOnce` is set),
|
||
* which is the common case we actually want to deduplicate.
|
||
*/
|
||
const OPEN_PULL_SKIP_MS = 30_000;
|
||
|
||
export const useWebDAVSync = (bookKey: string) => {
|
||
const _ = useTranslation();
|
||
const { envConfig, appService } = useEnv();
|
||
const { settings, setSettings, saveSettings } = useSettingsStore();
|
||
const getViewsById = useReaderStore((s) => s.getViewsById);
|
||
const getView = useReaderStore((s) => s.getView);
|
||
const getConfig = useBookDataStore((s) => s.getConfig);
|
||
const setConfig = useBookDataStore((s) => s.setConfig);
|
||
const getBookData = useBookDataStore((s) => s.getBookData);
|
||
const saveConfig = useBookDataStore((s) => s.saveConfig);
|
||
// Reactive: triggers the auto-push effect on page turns. Imperative reads
|
||
// elsewhere in this hook still go through useReaderStore.getState() /
|
||
// getBookProgress() via callbacks where they are bound directly.
|
||
const progress = useBookProgress(bookKey);
|
||
|
||
/**
|
||
* `dirtyRef` flips to true on the first locally-driven change after a
|
||
* successful push, and back to false right before each push fires. We
|
||
* use it to skip no-op flushes (e.g., user just opens then closes a
|
||
* book without reading) so mobile doesn't burn a PUT for no reason.
|
||
*/
|
||
const dirtyRef = useRef(false);
|
||
/** Last successful pull timestamp; gates window-focus and open-book pulls. */
|
||
const lastPulledAtRef = useRef(0);
|
||
const hasPulledOnce = useRef(false);
|
||
/**
|
||
* Per-instance lock for the book-file uploader. Once we've HEAD-probed
|
||
* (and possibly uploaded) the binary for this book in this hook
|
||
* lifetime, we never re-do it — the file's content is hash-keyed so
|
||
* the only thing that could change is the friendly filename, which is
|
||
* a metadata-only operation handled elsewhere.
|
||
*/
|
||
const fileSyncedRef = useRef(false);
|
||
/**
|
||
* Per-instance lock for the cover uploader. Same shape as
|
||
* `fileSyncedRef` but tracked separately because covers are gated
|
||
* differently than book files: cover sync runs whenever we're allowed
|
||
* to push at all (independent of `syncBooks`), so they need their own
|
||
* "already done in this hook lifetime" bit.
|
||
*/
|
||
const coverSyncedRef = useRef(false);
|
||
|
||
// The deviceId is generated lazily on first push so users who never
|
||
// enable WebDAV don't carry it around.
|
||
//
|
||
// Read latest settings from the store rather than the closure for the
|
||
// same reason `updateLastSyncedAt` does: `pullNow → pushNow` can fire
|
||
// back-to-back when a book opens, and the closure's `settings` may
|
||
// not reflect a sibling write that just landed (e.g. the settings
|
||
// panel flipping `syncBooks`). A closure-based merge here would
|
||
// rebuild the webdav block from a stale snapshot and silently
|
||
// clobber that write.
|
||
const ensureDeviceId = useCallback((): string => {
|
||
const latest = useSettingsStore.getState().settings;
|
||
let id = latest.webdav?.deviceId;
|
||
if (!id) {
|
||
id = uuidv4();
|
||
const next = { ...latest, webdav: { ...latest.webdav, deviceId: id } };
|
||
setSettings(next);
|
||
saveSettings(envConfig, next);
|
||
}
|
||
return id;
|
||
}, [envConfig, setSettings, saveSettings]);
|
||
|
||
const updateLastSyncedAt = useCallback(
|
||
async (ts: number) => {
|
||
// Read the latest settings from the store rather than the
|
||
// closure: pullNow → pushNow → pushBookFileNow can fire
|
||
// back-to-back when a book opens, and the closure's `settings`
|
||
// doesn't reflect interim writes by the prior call. Using the
|
||
// closure here would let a second `updateLastSyncedAt` rebuild
|
||
// the webdav object from a stale snapshot, clobbering whatever
|
||
// the first call (or a sibling write like `syncLog` from the
|
||
// settings panel) just committed.
|
||
const latest = useSettingsStore.getState().settings;
|
||
const next = { ...latest, webdav: { ...latest.webdav, lastSyncedAt: ts } };
|
||
setSettings(next);
|
||
await saveSettings(envConfig, next);
|
||
},
|
||
[envConfig, setSettings, saveSettings],
|
||
);
|
||
|
||
const isReady = useMemo(() => {
|
||
const w = settings.webdav;
|
||
return !!(w?.enabled && w?.serverUrl && w?.username);
|
||
}, [settings.webdav]);
|
||
|
||
const strategy = settings.webdav?.strategy ?? 'silent';
|
||
const allowPush = isReady && strategy !== 'receive';
|
||
const allowPull = isReady && strategy !== 'send';
|
||
|
||
/**
|
||
* Push the latest config (progress + booknotes) to the remote.
|
||
* Skips while the user is previewing a deep-link target — the in-memory
|
||
* position there reflects the annotation, not actual reading.
|
||
*/
|
||
const pushNow = useCallback(async () => {
|
||
if (!allowPush) return;
|
||
if (useReaderStore.getState().getViewState(bookKey)?.previewMode) return;
|
||
// Default-on semantics for older settings.json files that predate
|
||
// these keys (undefined in storage → opt in, not opt out).
|
||
const wantProgress = settings.webdav?.syncProgress ?? true;
|
||
const wantNotes = settings.webdav?.syncNotes ?? true;
|
||
if (!wantProgress && !wantNotes) return;
|
||
|
||
const config = getConfig(bookKey);
|
||
const book = getBookData(bookKey)?.book;
|
||
if (!config || !book) return;
|
||
|
||
try {
|
||
const deviceId = ensureDeviceId();
|
||
// We always push the full envelope; sub-toggles only gate _which_
|
||
// fields the local writer applies on pull. This keeps the wire
|
||
// schema stable across users with different toggle combinations.
|
||
await pushBookConfig(settings.webdav!, book, config, deviceId);
|
||
dirtyRef.current = false;
|
||
await updateLastSyncedAt(Date.now());
|
||
} catch (e) {
|
||
if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') {
|
||
eventDispatcher.dispatch('toast', {
|
||
type: 'error',
|
||
message: _('WebDAV authentication failed. Reconnect in Settings.'),
|
||
});
|
||
} else {
|
||
console.warn('WD push failed', e);
|
||
}
|
||
}
|
||
}, [
|
||
allowPush,
|
||
bookKey,
|
||
getConfig,
|
||
getBookData,
|
||
ensureDeviceId,
|
||
settings.webdav,
|
||
updateLastSyncedAt,
|
||
_,
|
||
]);
|
||
|
||
/**
|
||
* Upload the book binary if syncBooks is on and the remote doesn't
|
||
* already have a same-sized copy. Designed to be cheap on the steady
|
||
* state — the underlying `pushBookFile` does a single HEAD before any
|
||
* PUT, so for already-mirrored books we burn just one round-trip per
|
||
* book per session. Re-runs of this callback within the same hook
|
||
* instance no-op via `fileSyncedRef`.
|
||
*/
|
||
const pushBookFileNow = useCallback(async () => {
|
||
if (!allowPush) return;
|
||
if (!(settings.webdav?.syncBooks ?? false)) return;
|
||
if (fileSyncedRef.current) return;
|
||
fileSyncedRef.current = true;
|
||
|
||
const book = getBookData(bookKey)?.book;
|
||
if (!book || !appService) return;
|
||
|
||
try {
|
||
const result = await pushBookFile(
|
||
settings.webdav!,
|
||
book,
|
||
async () => {
|
||
// Buffered fallback: read the local book file off disk lazily
|
||
// so we only do the expensive ArrayBuffer materialisation when
|
||
// the HEAD probe says we actually need to upload. Used on web
|
||
// targets where streaming PUTs aren't available.
|
||
// In-place imports keep their bytes outside Books/<hash>/, so
|
||
// resolve to (book.filePath, 'None') when the field is set —
|
||
// mirrors the same fallback in cloudService.uploadBook so
|
||
// syncBooks 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
|
||
// server via Rust-side `upload_file`, never letting the bytes
|
||
// land in the JS heap. Without this, opening a multi-hundred-
|
||
// megabyte PDF / scanned book would buffer the whole file into
|
||
// V8 just to PUT it, blowing the renderer's heap and freezing
|
||
// the reader to a blank screen mid-open. Same flow as the
|
||
// library Sync now path in WebDAVForm.
|
||
isTauriAppPlatform()
|
||
? async () => {
|
||
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;
|
||
// Release the FD before streaming so the Tauri side can
|
||
// re-open the path for the PUT without contending.
|
||
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 {
|
||
await tauriUpload(
|
||
remoteUrl,
|
||
dst,
|
||
'PUT',
|
||
undefined,
|
||
headers as unknown as Map<string, string>,
|
||
);
|
||
return true;
|
||
} catch (e) {
|
||
console.warn('WD per-book push: tauriUpload failed', book.hash, e);
|
||
return false;
|
||
}
|
||
},
|
||
};
|
||
}
|
||
: undefined,
|
||
);
|
||
if (result.uploaded) {
|
||
await updateLastSyncedAt(Date.now());
|
||
}
|
||
} catch (e) {
|
||
// Reset the lock on failure so a manual Sync now or a subsequent
|
||
// open retries — otherwise a transient hiccup would mark this
|
||
// book "synced" for the rest of the session.
|
||
fileSyncedRef.current = false;
|
||
if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') {
|
||
eventDispatcher.dispatch('toast', {
|
||
type: 'error',
|
||
message: _('WebDAV authentication failed. Reconnect in Settings.'),
|
||
});
|
||
} else {
|
||
console.warn('WD book file push failed', e);
|
||
}
|
||
}
|
||
}, [allowPush, settings.webdav, getBookData, bookKey, appService, updateLastSyncedAt, _]);
|
||
|
||
/**
|
||
* Push the local cover image to the remote, independent of
|
||
* `syncBooks`. Covers are part of the book's metadata: at ~30–60 KB
|
||
* each (after the import-time downscale) the bandwidth cost is
|
||
* negligible, but the receiving device cannot regenerate them when
|
||
* `syncBooks=false` (it has no book bytes to extract from), so a
|
||
* user who only opts into progress + notes still needs the covers
|
||
* ride-along to see proper bookshelf art.
|
||
*
|
||
* Failures are best-effort warnings: a missing local cover (TXT/MD
|
||
* imports without metadata, etc.) silently no-ops, and a network
|
||
* blip is logged but doesn't surface a toast.
|
||
*/
|
||
const pushBookCoverNow = useCallback(async () => {
|
||
if (!allowPush) return;
|
||
if (coverSyncedRef.current) return;
|
||
coverSyncedRef.current = true;
|
||
|
||
const book = getBookData(bookKey)?.book;
|
||
if (!book || !appService) return;
|
||
|
||
try {
|
||
await pushBookCover(settings.webdav!, book.hash, async () => {
|
||
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 };
|
||
});
|
||
} catch (e) {
|
||
// Reset the lock so a manual "Sync now" or a subsequent open
|
||
// can retry, mirroring `pushBookFileNow`'s recovery model.
|
||
coverSyncedRef.current = false;
|
||
if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') {
|
||
eventDispatcher.dispatch('toast', {
|
||
type: 'error',
|
||
message: _('WebDAV authentication failed. Reconnect in Settings.'),
|
||
});
|
||
} else {
|
||
console.warn('WD book cover push failed', e);
|
||
}
|
||
}
|
||
}, [allowPush, settings.webdav, getBookData, bookKey, appService, _]);
|
||
|
||
/**
|
||
* Pull, merge, and persist. Uses the same per-config / per-note merge
|
||
* semantics as the native cloud sync so a user running both feels the
|
||
* same behaviour from each.
|
||
*
|
||
* Returns `true` when remote already had a payload (merge happened),
|
||
* `false` when remote was empty. Callers use that to decide whether to
|
||
* follow up with an initial push (which is how the per-book directory
|
||
* actually gets created on first use).
|
||
*/
|
||
const pullNow = useCallback(async (): Promise<boolean> => {
|
||
if (!allowPull) return false;
|
||
const wantProgress = settings.webdav?.syncProgress ?? true;
|
||
const wantNotes = settings.webdav?.syncNotes ?? true;
|
||
if (!wantProgress && !wantNotes) return false;
|
||
|
||
const config = getConfig(bookKey);
|
||
const book = getBookData(bookKey)?.book;
|
||
if (!config || !book) return false;
|
||
|
||
try {
|
||
const result = await pullBookConfig(settings.webdav!, book, config);
|
||
lastPulledAtRef.current = Date.now();
|
||
if (!result.applied || !result.mergedConfig) return false;
|
||
|
||
// Surface merged notes through the live view so highlights re-appear /
|
||
// disappear without waiting for the next render pass.
|
||
if (wantNotes && result.mergedNotes) {
|
||
const view = getView(bookKey);
|
||
const previousById = new Map((config.booknotes ?? []).map((n) => [n.id, n]));
|
||
for (const note of result.mergedNotes) {
|
||
const prev = previousById.get(note.id);
|
||
if (note.deletedAt && (!prev || !prev.deletedAt)) {
|
||
// Newly soft-deleted on the remote — strip overlays locally.
|
||
getViewsById(bookKey.split('-')[0]!).forEach((v) => removeBookNoteOverlays(v, note));
|
||
} else if (!note.deletedAt && note.cfi && view) {
|
||
// Newly added or re-surrected; only render in the live spine.
|
||
try {
|
||
view.addAnnotation(note);
|
||
} catch {
|
||
// The annotation may not belong to the current spine index;
|
||
// it'll get rendered when that section is loaded.
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Honour sub-toggles: drop the parts the user opted out of before
|
||
// writing back to the local config store.
|
||
const toApply = { ...result.mergedConfig };
|
||
if (!wantProgress) {
|
||
toApply.progress = config.progress;
|
||
toApply.location = config.location;
|
||
toApply.xpointer = config.xpointer;
|
||
}
|
||
if (!wantNotes) {
|
||
toApply.booknotes = config.booknotes;
|
||
}
|
||
|
||
setConfig(bookKey, toApply);
|
||
// Persist locally so a later session sees the merged state even if
|
||
// the user closes the book without further interaction.
|
||
const latest = getConfig(bookKey);
|
||
if (latest) await saveConfig(envConfig, bookKey, latest, settings);
|
||
await updateLastSyncedAt(Date.now());
|
||
return true;
|
||
} catch (e) {
|
||
if (e instanceof WebDAVRequestError && e.code === 'AUTH_FAILED') {
|
||
eventDispatcher.dispatch('toast', {
|
||
type: 'error',
|
||
message: _('WebDAV authentication failed. Reconnect in Settings.'),
|
||
});
|
||
} else {
|
||
console.warn('WD pull failed', e);
|
||
}
|
||
return false;
|
||
}
|
||
}, [
|
||
allowPull,
|
||
bookKey,
|
||
getConfig,
|
||
getBookData,
|
||
getView,
|
||
getViewsById,
|
||
setConfig,
|
||
saveConfig,
|
||
envConfig,
|
||
settings,
|
||
updateLastSyncedAt,
|
||
_,
|
||
]);
|
||
|
||
// Stash the latest pull/push callbacks in a ref so the event-bridge
|
||
// useEffect below doesn't have to re-bind on every render. Pattern
|
||
// taken from useKOSync.
|
||
const syncRefs = useRef({ pushNow, pullNow, pushBookFileNow, pushBookCoverNow });
|
||
useEffect(() => {
|
||
syncRefs.current = { pushNow, pullNow, pushBookFileNow, pushBookCoverNow };
|
||
}, [pushNow, pullNow, pushBookFileNow, pushBookCoverNow]);
|
||
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
const debouncedPush = useCallback(
|
||
debounce(() => {
|
||
// Skip the network round-trip when nothing has changed since the
|
||
// last successful push (the dirtyRef.current = false in pushNow's
|
||
// success path).
|
||
if (!dirtyRef.current) return;
|
||
syncRefs.current.pushNow();
|
||
}, PUSH_DEBOUNCE_MS),
|
||
[],
|
||
);
|
||
|
||
/**
|
||
* Mark dirty + schedule a debounced push. Centralises the pattern so
|
||
* progress / booknote effects don't both have to remember to flip the
|
||
* dirty bit.
|
||
*/
|
||
const markDirtyAndSchedule = useCallback(() => {
|
||
dirtyRef.current = true;
|
||
debouncedPush();
|
||
}, [debouncedPush]);
|
||
|
||
// Pull once on book open, then push if remote was empty so the per-book
|
||
// directory gets created on first use rather than waiting for the user
|
||
// to scroll several pages. We hold off until progress.location is known
|
||
// so the merge has a real local side to compare against. The book file
|
||
// upload is gated by syncBooks and rides along on the same trigger.
|
||
useEffect(() => {
|
||
if (!isReady) return;
|
||
if (!progress?.location) return;
|
||
if (hasPulledOnce.current) return;
|
||
hasPulledOnce.current = true;
|
||
// Same-instance dedupe — if we already pulled for this book within
|
||
// the cooldown window, skip the second pull (and its bootstrap push)
|
||
// since the remote almost certainly hasn't moved. See the comment
|
||
// on `OPEN_PULL_SKIP_MS`: this guard only fires on re-runs of this
|
||
// effect within one hook lifetime, not on close-then-reopen.
|
||
if (Date.now() - lastPulledAtRef.current < OPEN_PULL_SKIP_MS) return;
|
||
(async () => {
|
||
const merged = await syncRefs.current.pullNow();
|
||
if (!merged) {
|
||
// Remote had nothing for this book yet — bootstrap the directory
|
||
// structure (Readest/books/<hash>/) by uploading the local config.
|
||
// Force-push (mark dirty first) so the bootstrap actually fires.
|
||
dirtyRef.current = true;
|
||
await syncRefs.current.pushNow();
|
||
}
|
||
// Cover sync is independent of `syncBooks`: even users who keep
|
||
// book bytes off the wire still want their shelf art mirrored.
|
||
// Run it in parallel with the file upload — they hit different
|
||
// remote paths and the cover is tiny, so there's no reason to
|
||
// serialize them. The HEAD probe inside each makes the steady
|
||
// state near-free for already-mirrored books.
|
||
await Promise.all([syncRefs.current.pushBookCoverNow(), syncRefs.current.pushBookFileNow()]);
|
||
})();
|
||
}, [isReady, progress?.location]);
|
||
|
||
// Auto-push on progress changes. The debounce holds the network call
|
||
// off until the user has stopped turning pages for PUSH_DEBOUNCE_MS,
|
||
// and the dirty check inside debouncedPush short-circuits no-op flushes.
|
||
useEffect(() => {
|
||
if (!isReady) return;
|
||
if (!progress?.location) return;
|
||
markDirtyAndSchedule();
|
||
}, [isReady, progress?.location, markDirtyAndSchedule]);
|
||
|
||
// Booknote mutations: hash on length + max(updatedAt, deletedAt) so a
|
||
// pure re-render that produces a fresh `booknotes` array reference
|
||
// without any real change doesn't fire a push. The hash is cheap
|
||
// enough to recompute every render and keeps the effect dependency
|
||
// primitive.
|
||
const config = getConfig(bookKey);
|
||
const booknoteFingerprint = useMemo(() => {
|
||
const notes = config?.booknotes ?? [];
|
||
let max = 0;
|
||
for (const n of notes) {
|
||
const t = Math.max(n.updatedAt ?? 0, n.deletedAt ?? 0);
|
||
if (t > max) max = t;
|
||
}
|
||
return `${notes.length}:${max}`;
|
||
}, [config?.booknotes]);
|
||
useEffect(() => {
|
||
if (!isReady) return;
|
||
// The very first render after a pull populates the booknotes; we
|
||
// shouldn't treat that as a local edit. lastPulledAtRef being recent
|
||
// is a sufficient proxy.
|
||
if (Date.now() - lastPulledAtRef.current < 1_000) return;
|
||
markDirtyAndSchedule();
|
||
}, [isReady, booknoteFingerprint, markDirtyAndSchedule]);
|
||
|
||
// Manual triggers: settings UI dispatches these via "Sync now" buttons,
|
||
// and the reader emits flush-webdav-sync on close so we don't lose the
|
||
// last few seconds of reading progress.
|
||
useEffect(() => {
|
||
const handlePush = (event: CustomEvent) => {
|
||
if (event.detail?.bookKey && event.detail.bookKey !== bookKey) return;
|
||
// User-triggered push is unconditional — flip dirty so the flush
|
||
// actually does something, and re-run the book-file + cover
|
||
// upload checks so a freshly-toggled "Sync Book Files" picks up
|
||
// the binary, and any cover that wasn't on the wire yet (e.g.
|
||
// hit a transient failure earlier in the session) gets retried.
|
||
dirtyRef.current = true;
|
||
fileSyncedRef.current = false;
|
||
coverSyncedRef.current = false;
|
||
debouncedPush.flush();
|
||
syncRefs.current.pushBookFileNow();
|
||
syncRefs.current.pushBookCoverNow();
|
||
};
|
||
const handlePull = (event: CustomEvent) => {
|
||
if (event.detail?.bookKey && event.detail.bookKey !== bookKey) return;
|
||
lastPulledAtRef.current = 0; // bypass cooldown for explicit pulls
|
||
hasPulledOnce.current = false;
|
||
syncRefs.current.pullNow();
|
||
};
|
||
eventDispatcher.on('push-webdav-sync', handlePush);
|
||
eventDispatcher.on('pull-webdav-sync', handlePull);
|
||
eventDispatcher.on('flush-webdav-sync', handlePush);
|
||
return () => {
|
||
eventDispatcher.off('push-webdav-sync', handlePush);
|
||
eventDispatcher.off('pull-webdav-sync', handlePull);
|
||
eventDispatcher.off('flush-webdav-sync', handlePush);
|
||
};
|
||
}, [bookKey, debouncedPush]);
|
||
|
||
// Window blur ⇒ push pending changes if any. Window focus ⇒ pull, but
|
||
// only if we haven't pulled within PULL_COOLDOWN_MS — important on
|
||
// mobile where alt-tab equivalents (notifications, app switch) can
|
||
// fire many times per minute.
|
||
useWindowActiveChanged((isActive) => {
|
||
if (!isReady) return;
|
||
if (isActive) {
|
||
if (Date.now() - lastPulledAtRef.current < PULL_COOLDOWN_MS) return;
|
||
syncRefs.current.pullNow();
|
||
} else if (dirtyRef.current) {
|
||
debouncedPush.flush();
|
||
}
|
||
});
|
||
|
||
// Flush any pending debounced push when the hook unmounts (book closed,
|
||
// user navigated away). Without this, a quick read-then-close session
|
||
// can lose its tail-end progress because the debounce timer never
|
||
// fires. The dirty check inside debouncedPush keeps no-op flushes out
|
||
// of the wire.
|
||
useEffect(() => {
|
||
return () => {
|
||
debouncedPush.flush();
|
||
};
|
||
}, [debouncedPush]);
|
||
|
||
return { pushNow, pullNow };
|
||
};
|
||
|
||
export default useWebDAVSync;
|