cbdc3b8f52
* feat(sync): cross-device dictionary sync Custom MDict / StarDict / DICT / SLOB dictionaries now sync across signed-in devices via the replica layer. - Store mutations publish replica rows with field-level LWW + tombstones. - Re-importing the same content (renamed or after delete) preserves the user's label and reincarnates the server row instead of duplicating. - Manifest commits after binary upload so other devices never see a row whose binaries aren't on cloud storage yet. - Pull-side orchestrator creates a placeholder dict, queues the binaries via TransferManager, and clears the unavailable flag on completion. - Toast copy branches by transfer kind so dict uploads don't read "Book uploaded". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): boot pull and binary download path - Defer the boot pull until TransferManager is initialized so download enqueues aren't dropped. - Auto-persist the local dict store after applyRemoteDictionary; otherwise the next loadCustomDictionaries wipes the in-memory rows. - Boot pull passes since=null so a device whose cursor advanced past unpersisted rows can still recover. - Skip pulling when not authenticated instead of logging "SyncError: Not authenticated" on every boot of a signed-out device. - downloadReplicaFile resolves the destination against the kind's base dir; binaries previously landed at the literal lfp and openFile then failed with "File not found". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(sync): per-page useReplicaPull hook Lifts the boot-time pull out of EnvContext into a hook each page mounts for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }). Library page and the shared Reader component opt in. The hook fires 10s after page load (so feature mounts hydrate first), dedups per-kind across navigation, and releases the slot on failure so a later mount can retry. Future kinds plug into the hook's per-kind switch. Also closes two refresh-loop bugs: - Hydrate the dict store from settings BEFORE the apply loop, so the auto-persist doesn't clobber persisted rows that the in-memory store hadn't yet read. Library-page refresh was the visible victim. - Skip the download queue when every manifest file is already on disk under the resolved bundle dir. Refreshing is a no-op; partial- download recovery still queues because some files would be missing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
72 lines
2.2 KiB
TypeScript
72 lines
2.2 KiB
TypeScript
import type { AppService } from '@/types/system';
|
|
import type { Hlc } from '@/types/replica';
|
|
import type { CursorStore } from './replicaSyncManager';
|
|
|
|
export interface SettingsCursorStoreOpts {
|
|
/** Debounce window for the load-merge-save flush. Defaults to 1s. */
|
|
debounceMs?: number;
|
|
/** Test seam — defaults to the platform setTimeout. */
|
|
setTimeoutFn?: typeof setTimeout;
|
|
/** Test seam. */
|
|
clearTimeoutFn?: typeof clearTimeout;
|
|
}
|
|
|
|
/**
|
|
* Production CursorStore backed by appService.loadSettings + saveSettings.
|
|
*
|
|
* Cursors are cached in memory so get() is sync. set() debounces a save
|
|
* that does a fresh load-merge-save round-trip — this keeps us from
|
|
* clobbering fields written elsewhere in the same session (e.g., the
|
|
* library page's setSettings).
|
|
*
|
|
* Cursor advance is best-effort: a lost save just means the next pull
|
|
* re-fetches a few rows. We never block the sync flow on disk IO.
|
|
*/
|
|
export const createSettingsCursorStore = (
|
|
appService: AppService,
|
|
opts: SettingsCursorStoreOpts = {},
|
|
): CursorStore => {
|
|
const cache = new Map<string, Hlc>();
|
|
const setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
|
|
const clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
|
|
const debounceMs = opts.debounceMs ?? 1000;
|
|
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
void (async () => {
|
|
try {
|
|
const settings = await appService.loadSettings();
|
|
for (const [k, v] of Object.entries(settings.lastSyncedAtReplicas ?? {})) {
|
|
cache.set(k, v as Hlc);
|
|
}
|
|
} catch (err) {
|
|
console.warn('replica cursor hydrate failed', err);
|
|
}
|
|
})();
|
|
|
|
const flush = async () => {
|
|
try {
|
|
const settings = await appService.loadSettings();
|
|
settings.lastSyncedAtReplicas = Object.fromEntries(cache);
|
|
await appService.saveSettings(settings);
|
|
} catch (err) {
|
|
console.warn('replica cursor save failed', err);
|
|
}
|
|
};
|
|
|
|
const scheduleFlush = () => {
|
|
if (pending !== null) clearTimeoutFn(pending);
|
|
pending = setTimeoutFn(() => {
|
|
pending = null;
|
|
void flush();
|
|
}, debounceMs);
|
|
};
|
|
|
|
return {
|
|
get: (kind) => cache.get(kind) ?? null,
|
|
set: (kind, hlc) => {
|
|
cache.set(kind, hlc);
|
|
scheduleFlush();
|
|
},
|
|
};
|
|
};
|