forked from akai/readest
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>
193 lines
6.2 KiB
TypeScript
193 lines
6.2 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
import {
|
|
buildLocalDictFromRow,
|
|
filesFromManifest,
|
|
unwrapDictionaryFields,
|
|
} from '@/services/sync/replicaDictionaryApply';
|
|
import { hlcPack } from '@/libs/crdt';
|
|
import type { Hlc, Manifest, ReplicaRow } from '@/types/replica';
|
|
|
|
const NOW = 1_700_000_000_000;
|
|
const DEV = 'dev-a';
|
|
|
|
const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
|
|
user_id: 'u1',
|
|
kind: 'dictionary',
|
|
replica_id: 'content-hash-abc',
|
|
fields_jsonb: {
|
|
name: { v: 'Webster', t: hlcPack(NOW, 0, DEV) as Hlc, s: DEV },
|
|
kind: { v: 'mdict', t: hlcPack(NOW, 1, DEV) as Hlc, s: DEV },
|
|
lang: { v: 'en', t: hlcPack(NOW, 2, DEV) as Hlc, s: DEV },
|
|
addedAt: { v: 1700000000000, t: hlcPack(NOW, 3, DEV) as Hlc, s: DEV },
|
|
},
|
|
manifest_jsonb: null,
|
|
deleted_at_ts: null,
|
|
reincarnation: null,
|
|
updated_at_ts: hlcPack(NOW, 3, DEV) as Hlc,
|
|
schema_version: 1,
|
|
...overrides,
|
|
});
|
|
|
|
const manifest = (
|
|
files: { filename: string; byteSize?: number; partialMd5?: string }[],
|
|
): Manifest => ({
|
|
files: files.map((f) => ({
|
|
filename: f.filename,
|
|
byteSize: f.byteSize ?? 0,
|
|
partialMd5: f.partialMd5 ?? '',
|
|
})),
|
|
schemaVersion: 1,
|
|
});
|
|
|
|
describe('unwrapDictionaryFields', () => {
|
|
test('extracts plain values from envelope fields', () => {
|
|
const row = baseRow();
|
|
expect(unwrapDictionaryFields(row.fields_jsonb)).toEqual({
|
|
name: 'Webster',
|
|
kind: 'mdict',
|
|
lang: 'en',
|
|
addedAt: 1700000000000,
|
|
});
|
|
});
|
|
|
|
test('returns undefined for missing fields', () => {
|
|
const row = baseRow();
|
|
delete (row.fields_jsonb as Record<string, unknown>)['lang'];
|
|
expect(unwrapDictionaryFields(row.fields_jsonb).lang).toBeUndefined();
|
|
});
|
|
|
|
test('handles unsupportedReason if present', () => {
|
|
const row = baseRow();
|
|
row.fields_jsonb['unsupportedReason'] = {
|
|
v: 'encrypted',
|
|
t: hlcPack(NOW, 4, DEV) as Hlc,
|
|
s: DEV,
|
|
};
|
|
row.fields_jsonb['unsupported'] = { v: true, t: hlcPack(NOW, 5, DEV) as Hlc, s: DEV };
|
|
const fields = unwrapDictionaryFields(row.fields_jsonb);
|
|
expect(fields.unsupported).toBe(true);
|
|
expect(fields.unsupportedReason).toBe('encrypted');
|
|
});
|
|
});
|
|
|
|
describe('filesFromManifest', () => {
|
|
test('mdict manifest: classifies mdx + mdd + css', () => {
|
|
const m = manifest([
|
|
{ filename: 'webster.mdx' },
|
|
{ filename: 'webster.mdd' },
|
|
{ filename: 'webster.1.mdd' },
|
|
{ filename: 'webster.css' },
|
|
]);
|
|
expect(filesFromManifest(m, 'mdict')).toEqual({
|
|
mdx: 'webster.mdx',
|
|
mdd: ['webster.mdd', 'webster.1.mdd'],
|
|
css: ['webster.css'],
|
|
});
|
|
});
|
|
|
|
test('stardict manifest: classifies ifo + idx + dict + syn (skips offsets)', () => {
|
|
const m = manifest([
|
|
{ filename: 'd.ifo' },
|
|
{ filename: 'd.idx' },
|
|
{ filename: 'd.dict.dz' },
|
|
{ filename: 'd.syn' },
|
|
]);
|
|
expect(filesFromManifest(m, 'stardict')).toEqual({
|
|
ifo: 'd.ifo',
|
|
idx: 'd.idx',
|
|
dict: 'd.dict.dz',
|
|
syn: 'd.syn',
|
|
});
|
|
});
|
|
|
|
test('dict manifest: classifies dict + index', () => {
|
|
const m = manifest([{ filename: 'w.dict.dz' }, { filename: 'w.index' }]);
|
|
expect(filesFromManifest(m, 'dict')).toEqual({
|
|
dict: 'w.dict.dz',
|
|
index: 'w.index',
|
|
});
|
|
});
|
|
|
|
test('slob manifest: classifies single .slob', () => {
|
|
const m = manifest([{ filename: 'w.slob' }]);
|
|
expect(filesFromManifest(m, 'slob')).toEqual({ slob: 'w.slob' });
|
|
});
|
|
|
|
test('null manifest returns empty files object', () => {
|
|
expect(filesFromManifest(null, 'mdict')).toEqual({});
|
|
});
|
|
|
|
test('empty manifest returns empty files object', () => {
|
|
expect(filesFromManifest(manifest([]), 'mdict')).toEqual({});
|
|
});
|
|
});
|
|
|
|
describe('buildLocalDictFromRow', () => {
|
|
test('builds a complete ImportedDictionary from a row + bundleDir', () => {
|
|
const row = baseRow({
|
|
manifest_jsonb: manifest([
|
|
{ filename: 'webster.mdx', byteSize: 1000 },
|
|
{ filename: 'webster.mdd', byteSize: 5000 },
|
|
]),
|
|
});
|
|
const dict = buildLocalDictFromRow(row, 'local-bundle-1');
|
|
expect(dict).not.toBe(null);
|
|
expect(dict!.id).toBe('local-bundle-1');
|
|
expect(dict!.contentId).toBe('content-hash-abc');
|
|
expect(dict!.kind).toBe('mdict');
|
|
expect(dict!.name).toBe('Webster');
|
|
expect(dict!.lang).toBe('en');
|
|
expect(dict!.addedAt).toBe(1700000000000);
|
|
expect(dict!.bundleDir).toBe('local-bundle-1');
|
|
expect(dict!.files).toEqual({ mdx: 'webster.mdx', mdd: ['webster.mdd'] });
|
|
expect(dict!.unavailable).toBe(true);
|
|
});
|
|
|
|
test('null manifest produces an empty files object (still unavailable)', () => {
|
|
const row = baseRow({ manifest_jsonb: null });
|
|
const dict = buildLocalDictFromRow(row, 'b');
|
|
expect(dict!.files).toEqual({});
|
|
expect(dict!.unavailable).toBe(true);
|
|
});
|
|
|
|
test('returns null when fields are malformed (missing kind)', () => {
|
|
const row = baseRow();
|
|
delete (row.fields_jsonb as Record<string, unknown>)['kind'];
|
|
expect(buildLocalDictFromRow(row, 'b')).toBe(null);
|
|
});
|
|
|
|
test('returns null when fields are malformed (missing name)', () => {
|
|
const row = baseRow();
|
|
delete (row.fields_jsonb as Record<string, unknown>)['name'];
|
|
expect(buildLocalDictFromRow(row, 'b')).toBe(null);
|
|
});
|
|
|
|
test('propagates reincarnation token from the row to the dict', () => {
|
|
const row = baseRow({ reincarnation: 'epoch-1' });
|
|
expect(buildLocalDictFromRow(row, 'b')!.reincarnation).toBe('epoch-1');
|
|
});
|
|
|
|
test('propagates unsupported flags', () => {
|
|
const row = baseRow();
|
|
row.fields_jsonb['unsupported'] = { v: true, t: hlcPack(NOW, 4, DEV) as Hlc, s: DEV };
|
|
row.fields_jsonb['unsupportedReason'] = {
|
|
v: 'encrypted MDX',
|
|
t: hlcPack(NOW, 5, DEV) as Hlc,
|
|
s: DEV,
|
|
};
|
|
const dict = buildLocalDictFromRow(row, 'b')!;
|
|
expect(dict.unsupported).toBe(true);
|
|
expect(dict.unsupportedReason).toBe('encrypted MDX');
|
|
});
|
|
|
|
test('falls back to current time when addedAt missing', () => {
|
|
const row = baseRow();
|
|
delete (row.fields_jsonb as Record<string, unknown>)['addedAt'];
|
|
const before = Date.now();
|
|
const dict = buildLocalDictFromRow(row, 'b')!;
|
|
const after = Date.now();
|
|
expect(dict.addedAt).toBeGreaterThanOrEqual(before);
|
|
expect(dict.addedAt).toBeLessThanOrEqual(after);
|
|
});
|
|
});
|