feat(sync): cross-device custom font sync (#4077)

* refactor(sync): kind-agnostic replica primitives

Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sync): cross-device custom font sync

Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.

Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-05-07 14:28:44 +08:00
committed by GitHub
parent cbdc3b8f52
commit 981579c255
62 changed files with 2066 additions and 526 deletions
@@ -3,17 +3,37 @@ import { act, cleanup, renderHook } from '@testing-library/react';
const pullSpy = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
const getReplicaSyncSpy = vi.fn();
const readyListeners = new Set<() => void>();
const subscribeReplicaSyncReadySpy = vi.fn((listener: () => void) => {
if (getReplicaSyncSpy()) {
listener();
return () => {};
}
readyListeners.add(listener);
return () => {
readyListeners.delete(listener);
};
});
const fireReplicaSyncReady = () => {
for (const l of [...readyListeners]) l();
readyListeners.clear();
};
let envValue: { envConfig: unknown; appService: unknown } = {
envConfig: { name: 'env' },
appService: null,
};
vi.mock('@/services/sync/replicaPullDictionaries', () => ({
pullDictionariesAndApply: (...args: unknown[]) => pullSpy(...args),
vi.mock('@/services/sync/replicaPullAndApply', () => ({
replicaPullAndApply: (...args: unknown[]) => pullSpy(...args),
}));
vi.mock('@/services/sync/adapters/dictionary', () => ({
dictionaryAdapter: { kind: 'dictionary' },
}));
vi.mock('@/services/sync/replicaSync', () => ({
getReplicaSync: () => getReplicaSyncSpy(),
subscribeReplicaSyncReady: (listener: () => void) => subscribeReplicaSyncReadySpy(listener),
}));
vi.mock('@/context/EnvContext', () => ({
@@ -52,6 +72,8 @@ beforeEach(() => {
pullSpy.mockClear();
pullSpy.mockResolvedValue(undefined);
getReplicaSyncSpy.mockReset();
subscribeReplicaSyncReadySpy.mockClear();
readyListeners.clear();
__resetReplicaPullForTests();
envValue = { envConfig: { name: 'env' }, appService: fakeService };
});
@@ -90,12 +112,43 @@ describe('useReplicaPull', () => {
expect(pullSpy).not.toHaveBeenCalled();
});
test('skips when replica sync context is not initialized', () => {
test('does not pull yet when replica sync context is uninitialized — subscribes for ready', () => {
getReplicaSyncSpy.mockReturnValue(null);
renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 }));
vi.advanceTimersByTime(500);
expect(pullSpy).not.toHaveBeenCalled();
expect(subscribeReplicaSyncReadySpy).toHaveBeenCalledOnce();
});
test('hard-refresh race: schedules pull once initReplicaSync finishes (deferred subscriber fires)', async () => {
// Hard refresh: appService landed first, replica-sync singleton
// arrives after a microtask. The hook must catch up via the
// ready-signal subscription rather than silently dropping the pull.
getReplicaSyncSpy.mockReturnValue(null);
renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 }));
expect(subscribeReplicaSyncReadySpy).toHaveBeenCalledOnce();
expect(pullSpy).not.toHaveBeenCalled();
// initReplicaSync now finishes; getReplicaSync starts returning the
// singleton, and the ready listener fires.
getReplicaSyncSpy.mockReturnValue({ manager: {} });
fireReplicaSyncReady();
await act(async () => {
vi.advanceTimersByTime(150);
await Promise.resolve();
});
expect(pullSpy).toHaveBeenCalledOnce();
});
test('cleanup unsubscribes from ready listener if hook unmounts before init', () => {
getReplicaSyncSpy.mockReturnValue(null);
const view = renderHook(() => useReplicaPull({ kinds: ['dictionary'], delayMs: 100 }));
expect(subscribeReplicaSyncReadySpy).toHaveBeenCalledOnce();
expect(readyListeners.size).toBe(1);
view.unmount();
expect(readyListeners.size).toBe(0);
});
test('only pulls once per kind across multiple mounts', async () => {
@@ -28,9 +28,9 @@ const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
});
describe('isAllowedKind', () => {
test('PR 1 allowlist contains only dictionary', () => {
test('current allowlist contains dictionary + font', () => {
expect(isAllowedKind('dictionary')).toBe(true);
expect(isAllowedKind('font')).toBe(false);
expect(isAllowedKind('font')).toBe(true);
expect(isAllowedKind('texture')).toBe(false);
expect(isAllowedKind('opds_catalog')).toBe(false);
});
@@ -145,7 +145,7 @@ describe('validatePullParams', () => {
});
test('rejects unknown kind', () => {
const result = validatePullParams('font', null);
const result = validatePullParams('texture', null);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(422);
@@ -196,8 +196,8 @@ describe('BaseAppService', () => {
});
test('copyFile delegates to fs', async () => {
await service.copyFile('src.epub', 'dst.epub', 'Books');
expect(mockFs.copyFile).toHaveBeenCalledWith('src.epub', 'dst.epub', 'Books');
await service.copyFile('src.epub', 'None', 'dst.epub', 'Books');
expect(mockFs.copyFile).toHaveBeenCalledWith('src.epub', 'None', 'dst.epub', 'Books');
});
test('readFile delegates to fs', async () => {
@@ -40,7 +40,7 @@ describe('NodeAppService', () => {
it('should copy files from absolute path', async () => {
const srcPath = path.join(tmpDir, 'source.txt');
await fsp.writeFile(srcPath, 'copy me');
await service.copyFile(srcPath, 'copied.txt', 'Data');
await service.copyFile(srcPath, 'None', 'copied.txt', 'Data');
const content = await service.readFile('copied.txt', 'Data', 'text');
expect(content).toBe('copy me');
});
@@ -0,0 +1,187 @@
import { describe, expect, test } from 'vitest';
import { computeFontContentId, fontAdapter } from '@/services/sync/adapters/font';
import { hlcPack } from '@/libs/crdt';
import type { CustomFont } from '@/styles/fonts';
import type { Hlc, ReplicaRow } from '@/types/replica';
const NOW = 1_700_000_000_000;
const DEV = 'dev-a';
const HLC = hlcPack(NOW, 0, DEV) as Hlc;
const baseFont = (overrides: Partial<CustomFont> = {}): CustomFont => ({
id: 'placeholder',
name: 'Inter Regular',
path: 'bundle-1/Inter-Regular.ttf',
bundleDir: 'bundle-1',
contentId: 'content-hash-123',
byteSize: 102400,
family: 'Inter',
style: 'normal',
weight: 400,
variable: false,
downloadedAt: NOW,
...overrides,
});
describe('fontAdapter contract', () => {
test('kind is "font"', () => {
expect(fontAdapter.kind).toBe('font');
});
test('schemaVersion is 1', () => {
expect(fontAdapter.schemaVersion).toBe(1);
});
test('binary capability uses BaseDir "Fonts"', () => {
expect(fontAdapter.binary?.localBaseDir).toBe('Fonts');
});
test('computeId returns the contentId when set', async () => {
const f = baseFont({ contentId: 'content-hash-xyz' });
expect(await fontAdapter.computeId(f)).toBe('content-hash-xyz');
});
});
describe('pack ∘ unpack = identity for the synced subset', () => {
test('synced fields round-trip', () => {
const f = baseFont({
name: 'Inter Bold',
family: 'Inter',
style: 'normal',
weight: 700,
variable: false,
byteSize: 99999,
downloadedAt: NOW,
});
const packed = fontAdapter.pack(f);
const unpacked = fontAdapter.unpack(packed);
expect(unpacked.name).toBe('Inter Bold');
expect(unpacked.family).toBe('Inter');
expect(unpacked.style).toBe('normal');
expect(unpacked.weight).toBe(700);
expect(unpacked.variable).toBeUndefined();
expect(unpacked.byteSize).toBe(99999);
expect(unpacked.downloadedAt).toBe(NOW);
});
test('per-device fields (path, bundleDir) are NOT in the synced fields object', () => {
const f = baseFont({ bundleDir: 'device-local-uniqueId-123', path: 'bundleDir/Inter.ttf' });
const packed = fontAdapter.pack(f);
expect(packed['bundleDir']).toBeUndefined();
expect(packed['path']).toBeUndefined();
});
test('unavailable / deletedAt are NOT synced (tombstones handle delete)', () => {
const f = baseFont({ unavailable: true, deletedAt: 999 });
const packed = fontAdapter.pack(f);
expect(packed['unavailable']).toBeUndefined();
expect(packed['deletedAt']).toBeUndefined();
});
test('variable=true round-trips', () => {
const f = baseFont({ variable: true });
const packed = fontAdapter.pack(f);
const unpacked = fontAdapter.unpack(packed);
expect(unpacked.variable).toBe(true);
});
});
describe('unpackRow', () => {
const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
user_id: 'u1',
kind: 'font',
replica_id: 'content-hash-123',
fields_jsonb: {
name: { v: 'Inter Bold', t: HLC, s: DEV },
family: { v: 'Inter', t: HLC, s: DEV },
weight: { v: 700, t: HLC, s: DEV },
byteSize: { v: 102400, t: HLC, s: DEV },
},
manifest_jsonb: {
files: [{ filename: 'Inter-Bold.ttf', byteSize: 102400, partialMd5: 'x' }],
schemaVersion: 1,
},
deleted_at_ts: null,
reincarnation: null,
updated_at_ts: HLC,
schema_version: 1,
...overrides,
});
test('builds a placeholder font with bundleDir + path + unavailable=true', () => {
const font = fontAdapter.unpackRow(baseRow(), 'fresh-bundle');
expect(font).not.toBe(null);
expect(font!.id).toBe('fresh-bundle');
expect(font!.bundleDir).toBe('fresh-bundle');
expect(font!.contentId).toBe('content-hash-123');
expect(font!.path).toBe('fresh-bundle/Inter-Bold.ttf');
expect(font!.unavailable).toBe(true);
expect(font!.name).toBe('Inter Bold');
expect(font!.family).toBe('Inter');
expect(font!.weight).toBe(700);
expect(font!.byteSize).toBe(102400);
});
test('returns null when name is missing', () => {
const row = baseRow();
delete (row.fields_jsonb as Record<string, unknown>)['name'];
expect(fontAdapter.unpackRow(row, 'b')).toBe(null);
});
test('returns null when manifest is absent (no filename to construct path)', () => {
expect(fontAdapter.unpackRow(baseRow({ manifest_jsonb: null }), 'b')).toBe(null);
});
test('reincarnation token propagates', () => {
const font = fontAdapter.unpackRow(baseRow({ reincarnation: 'epoch-1' }), 'b');
expect(font?.reincarnation).toBe('epoch-1');
});
});
describe('binary.enumerateFiles', () => {
test('returns one entry with logical filename + lfp + byteSize', () => {
const f = baseFont({ path: 'b1/Inter-Bold.ttf', byteSize: 102400 });
const out = fontAdapter.binary!.enumerateFiles(f);
expect(out).toEqual([
{ logical: 'Inter-Bold.ttf', lfp: 'b1/Inter-Bold.ttf', byteSize: 102400 },
]);
});
test('handles legacy flat-path fonts (no slash)', () => {
const f = baseFont({ path: 'Inter-Regular.ttf', bundleDir: undefined, byteSize: 50000 });
const out = fontAdapter.binary!.enumerateFiles(f);
expect(out).toEqual([
{ logical: 'Inter-Regular.ttf', lfp: 'Inter-Regular.ttf', byteSize: 50000 },
]);
});
});
describe('computeFontContentId', () => {
test('deterministic over (partialMd5, byteSize, filename)', () => {
const a = computeFontContentId('abc123', 1024, 'Inter.ttf');
const b = computeFontContentId('abc123', 1024, 'Inter.ttf');
expect(a).toBe(b);
});
test('different partialMd5 → different id', () => {
expect(computeFontContentId('abc', 1024, 'a.ttf')).not.toBe(
computeFontContentId('def', 1024, 'a.ttf'),
);
});
test('different byteSize → different id', () => {
expect(computeFontContentId('abc', 1024, 'a.ttf')).not.toBe(
computeFontContentId('abc', 2048, 'a.ttf'),
);
});
test('different filename → different id', () => {
expect(computeFontContentId('abc', 1024, 'a.ttf')).not.toBe(
computeFontContentId('abc', 1024, 'b.ttf'),
);
});
test('returns 32-hex md5', () => {
expect(computeFontContentId('abc', 1024, 'a.ttf')).toMatch(/^[0-9a-f]{32}$/);
});
});
@@ -9,6 +9,8 @@ vi.mock('@/services/transferManager', () => ({
import { transferManager } from '@/services/transferManager';
import { queueDictionaryBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import { clearReplicaAdapters, registerReplicaAdapter } from '@/services/sync/replicaRegistry';
import { dictionaryAdapter } from '@/services/sync/adapters/dictionary';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { AppService } from '@/types/system';
@@ -35,10 +37,13 @@ const baseDict = (overrides: Partial<ImportedDictionary> = {}): ImportedDictiona
beforeEach(() => {
vi.clearAllMocks();
clearReplicaAdapters();
registerReplicaAdapter(dictionaryAdapter);
});
afterEach(() => {
vi.restoreAllMocks();
clearReplicaAdapters();
});
describe('queueDictionaryBinaryUpload', () => {
@@ -1,4 +1,13 @@
import { afterEach, describe, expect, test } from 'vitest';
import { afterEach, describe, expect, test, vi } from 'vitest';
vi.mock('@/store/customDictionaryStore', () => ({
useCustomDictionaryStore: { getState: () => ({ markAvailableByContentId: vi.fn() }) },
}));
vi.mock('@/store/customFontStore', () => ({
useCustomFontStore: { getState: () => ({ markAvailableByContentId: vi.fn() }) },
}));
import {
__resetBootstrapForTests,
bootstrapReplicaAdapters,
@@ -8,11 +17,13 @@ import {
getReplicaAdapter,
listReplicaAdapters,
} from '@/services/sync/replicaRegistry';
import { __resetReplicaTransferIntegrationForTests } from '@/services/sync/replicaTransferIntegration';
import { dictionaryAdapter } from '@/services/sync/adapters/dictionary';
afterEach(() => {
clearReplicaAdapters();
__resetBootstrapForTests();
__resetReplicaTransferIntegrationForTests();
});
describe('bootstrapReplicaAdapters', () => {
@@ -24,12 +35,12 @@ describe('bootstrapReplicaAdapters', () => {
test('is idempotent: calling twice is a no-op (does not throw)', () => {
bootstrapReplicaAdapters();
bootstrapReplicaAdapters();
expect(listReplicaAdapters()).toHaveLength(1);
expect(listReplicaAdapters()).toHaveLength(2);
});
test('only registers the kinds in the PR-1 allowlist', () => {
test('registers the current allowlist (dictionary, font)', () => {
bootstrapReplicaAdapters();
const kinds = listReplicaAdapters().map((a) => a.kind);
expect(kinds).toEqual(['dictionary']);
expect(kinds).toEqual(['dictionary', 'font']);
});
});
@@ -11,10 +11,12 @@ vi.mock('@/services/sync/replicaSync', () => ({
import { getUserID } from '@/utils/access';
import { getReplicaSync } from '@/services/sync/replicaSync';
import {
publishDictionaryDelete,
publishDictionaryManifest,
publishDictionaryUpsert,
publishReplicaDelete,
publishReplicaManifest,
publishReplicaUpsert,
} from '@/services/sync/replicaPublish';
import { clearReplicaAdapters, registerReplicaAdapter } from '@/services/sync/replicaRegistry';
import { dictionaryAdapter } from '@/services/sync/adapters/dictionary';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import { HlcGenerator, hlcPack } from '@/libs/crdt';
import type { Hlc, ReplicaRow } from '@/types/replica';
@@ -47,26 +49,33 @@ const makeFakeCtx = () => {
return { manager, hlc, deviceId: DEV };
};
const upsertDict = (dict: ImportedDictionary): Promise<void> =>
publishReplicaUpsert('dictionary', dict, dict.contentId!, dict.reincarnation);
beforeEach(() => {
vi.clearAllMocks();
clearReplicaAdapters();
registerReplicaAdapter(dictionaryAdapter);
});
afterEach(() => {
vi.restoreAllMocks();
clearReplicaAdapters();
});
describe('publishDictionaryUpsert', () => {
describe('publishReplicaUpsert (dictionary adapter)', () => {
test('no-ops when replicaSync is not initialized', async () => {
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(null);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryUpsert(baseDict());
await upsertDict(baseDict());
});
test('no-ops when contentId is absent (legacy bundle)', async () => {
test('no-ops when no adapter registered for the kind', async () => {
clearReplicaAdapters();
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryUpsert(baseDict({ contentId: undefined }));
await upsertDict(baseDict());
expect(ctx.manager.markDirty).not.toHaveBeenCalled();
});
@@ -74,7 +83,7 @@ describe('publishDictionaryUpsert', () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue(null);
await publishDictionaryUpsert(baseDict());
await upsertDict(baseDict());
expect(ctx.manager.markDirty).not.toHaveBeenCalled();
});
@@ -83,7 +92,7 @@ describe('publishDictionaryUpsert', () => {
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
const dict = baseDict({ name: 'Webster Concise', lang: 'en' });
await publishDictionaryUpsert(dict);
await upsertDict(dict);
expect(ctx.manager.markDirty).toHaveBeenCalledOnce();
const row = ctx.manager.markDirty.mock.calls[0]![0];
@@ -101,7 +110,7 @@ describe('publishDictionaryUpsert', () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryUpsert(baseDict());
await upsertDict(baseDict());
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
for (const env of Object.values(row.fields_jsonb)) {
expect(env.s).toBe(DEV);
@@ -113,44 +122,44 @@ describe('publishDictionaryUpsert', () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryUpsert(baseDict());
await upsertDict(baseDict());
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
const fieldHlcs = Object.values(row.fields_jsonb).map((e) => e.t);
const maxField = fieldHlcs.reduce((a, b) => (a > b ? a : b));
expect(row.updated_at_ts >= maxField).toBe(true);
});
test('reincarnation field on the dict propagates to the row (revives a tombstoned row)', async () => {
test('reincarnation token propagates to the row (revives a tombstoned row)', async () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryUpsert(baseDict({ reincarnation: 'epoch-1' }));
await upsertDict(baseDict({ reincarnation: 'epoch-1' }));
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
expect(row.reincarnation).toBe('epoch-1');
});
test('reincarnation defaults to null when absent on the dict (first import or live re-import)', async () => {
test('reincarnation defaults to null when absent (first import or live re-import)', async () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryUpsert(baseDict());
await upsertDict(baseDict());
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
expect(row.reincarnation).toBe(null);
});
});
describe('publishDictionaryDelete', () => {
describe('publishReplicaDelete', () => {
test('no-ops when replicaSync is not initialized', async () => {
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(null);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryDelete('content-hash-abc');
await publishReplicaDelete('dictionary', 'content-hash-abc');
});
test('no-ops when user not authenticated', async () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue(null);
await publishDictionaryDelete('content-hash-abc');
await publishReplicaDelete('dictionary', 'content-hash-abc');
expect(ctx.manager.markDirty).not.toHaveBeenCalled();
});
@@ -158,7 +167,7 @@ describe('publishDictionaryDelete', () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryDelete('content-hash-abc');
await publishReplicaDelete('dictionary', 'content-hash-abc');
expect(ctx.manager.markDirty).toHaveBeenCalledOnce();
const row = ctx.manager.markDirty.mock.calls[0]![0];
expect(row.replica_id).toBe('content-hash-abc');
@@ -170,23 +179,24 @@ describe('publishDictionaryDelete', () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryDelete('content-hash-abc');
await publishReplicaDelete('dictionary', 'content-hash-abc');
const row = ctx.manager.markDirty.mock.calls[0]![0];
expect(row.updated_at_ts).toBe(row.deleted_at_ts);
});
});
describe('publishDictionaryManifest', () => {
describe('publishReplicaManifest', () => {
test('no-ops when replicaSync is not initialized', async () => {
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(null);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryManifest('content-hash-abc', []);
await publishReplicaManifest('dictionary', 'content-hash-abc', []);
});
test('no-ops when user not authenticated', async () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue(null);
await publishDictionaryManifest('content-hash-abc', []);
await publishReplicaManifest('dictionary', 'content-hash-abc', []);
expect(ctx.manager.markDirty).not.toHaveBeenCalled();
});
@@ -198,7 +208,7 @@ describe('publishDictionaryManifest', () => {
{ filename: 'webster.mdx', byteSize: 1_000_000, partialMd5: 'abc123' },
{ filename: 'webster.mdd', byteSize: 5_000_000, partialMd5: 'def456' },
];
await publishDictionaryManifest('content-hash-abc', files);
await publishReplicaManifest('dictionary', 'content-hash-abc', files);
expect(ctx.manager.markDirty).toHaveBeenCalledOnce();
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
expect(row.replica_id).toBe('content-hash-abc');
@@ -212,7 +222,7 @@ describe('publishDictionaryManifest', () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryManifest('content-hash-abc', [], 'epoch-1');
await publishReplicaManifest('dictionary', 'content-hash-abc', [], 'epoch-1');
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
expect(row.reincarnation).toBe('epoch-1');
});
@@ -221,7 +231,7 @@ describe('publishDictionaryManifest', () => {
const ctx = makeFakeCtx();
(getReplicaSync as ReturnType<typeof vi.fn>).mockReturnValue(ctx);
(getUserID as ReturnType<typeof vi.fn>).mockResolvedValue('user-1');
await publishDictionaryManifest('content-hash-abc', []);
await publishReplicaManifest('dictionary', 'content-hash-abc', []);
const row = ctx.manager.markDirty.mock.calls[0]![0] as ReplicaRow;
expect(row.manifest_jsonb?.files).toEqual([]);
});
@@ -1,8 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
pullDictionariesAndApply,
type PullDictionariesDeps,
} from '@/services/sync/replicaPullDictionaries';
import { replicaPullAndApply, type PullAndApplyDeps } from '@/services/sync/replicaPullAndApply';
import { dictionaryAdapter } from '@/services/sync/adapters/dictionary';
import { hlcPack } from '@/libs/crdt';
import type { Hlc, Manifest, ReplicaRow } from '@/types/replica';
import type { ImportedDictionary } from '@/services/dictionaries/types';
@@ -46,9 +44,10 @@ const baseDict = (overrides: Partial<ImportedDictionary> = {}): ImportedDictiona
const makeDeps = () => {
const findByContentId = vi.fn((_id: string): ImportedDictionary | undefined => undefined);
const deps = {
adapter: dictionaryAdapter,
pull: vi.fn(async () => [] as ReplicaRow[]),
findByContentId,
applyRemoteDictionary: vi.fn(),
applyRemote: vi.fn<(record: ImportedDictionary) => void>(),
softDeleteByContentId: vi.fn(),
createBundleDir: vi.fn(async () => 'fresh-bundle-dir-1'),
queueReplicaDownload: vi.fn(() => 'transfer-id-1'),
@@ -56,7 +55,7 @@ const makeDeps = () => {
// downloads. Tests that exercise the "binaries already on disk"
// path override this.
filesExist: vi.fn(async () => false),
} satisfies PullDictionariesDeps;
} satisfies PullAndApplyDeps<ImportedDictionary>;
return deps;
};
@@ -68,11 +67,11 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe('pullDictionariesAndApply', () => {
describe('replicaPullAndApply (dictionary adapter)', () => {
test('no-op when pull returns no rows', async () => {
const deps = makeDeps();
await pullDictionariesAndApply(deps);
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
await replicaPullAndApply(deps);
expect(deps.applyRemote).not.toHaveBeenCalled();
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
});
@@ -80,27 +79,27 @@ describe('pullDictionariesAndApply', () => {
const deps = {
...makeDeps(),
isAuthenticated: vi.fn(async () => false),
} satisfies PullDictionariesDeps;
await pullDictionariesAndApply(deps);
} satisfies PullAndApplyDeps<ImportedDictionary>;
await replicaPullAndApply(deps);
expect(deps.isAuthenticated).toHaveBeenCalledOnce();
expect(deps.pull).not.toHaveBeenCalled();
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
expect(deps.applyRemote).not.toHaveBeenCalled();
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
});
test('hydrateLocalStore runs before pull so applyRemoteDictionary auto-persist does not wipe persisted entries', async () => {
test('hydrateLocalStore runs before pull so applyRemote auto-persist does not wipe persisted entries', async () => {
const order: string[] = [];
const deps = {
...makeDeps(),
hydrateLocalStore: vi.fn(async () => {
order.push('hydrate');
}),
} satisfies PullDictionariesDeps;
} satisfies PullAndApplyDeps<ImportedDictionary>;
(deps.pull as ReturnType<typeof vi.fn>).mockImplementation(async () => {
order.push('pull');
return [];
});
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(order).toEqual(['hydrate', 'pull']);
});
@@ -109,12 +108,12 @@ describe('pullDictionariesAndApply', () => {
const deps = {
...makeDeps(),
isAuthenticated: vi.fn(async () => true),
} satisfies PullDictionariesDeps;
} satisfies PullAndApplyDeps<ImportedDictionary>;
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.pull).toHaveBeenCalledOnce();
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
expect(deps.applyRemote).toHaveBeenCalledOnce();
});
test('alive-and-new row: creates bundle dir, applies dict, queues download', async () => {
@@ -123,11 +122,11 @@ describe('pullDictionariesAndApply', () => {
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.createBundleDir).toHaveBeenCalledOnce();
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
const applied = (deps.applyRemoteDictionary as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(deps.applyRemote).toHaveBeenCalledOnce();
const applied = (deps.applyRemote as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(applied.contentId).toBe('content-hash-abc');
expect(applied.bundleDir).toBe('fresh-bundle-dir-1');
expect(applied.unavailable).toBe(true);
@@ -143,15 +142,61 @@ describe('pullDictionariesAndApply', () => {
expect(downloadArgs![3]).toBe('fresh-bundle-dir-1');
});
test('alive-and-already-local WITHOUT manifest: queues local binary upload to repair the row', async () => {
// Row exists on the server with manifest_jsonb=null — typically the
// device that originally wrote the metadata never managed to upload
// the binaries (e.g., TransferManager not ready, transient error).
// On the next pull we reconcile by re-queuing the upload from the
// device that owns the local copy.
const row = baseRow({ manifest_jsonb: null });
const local = baseDict();
const queueLocalBinaryUpload = vi.fn<(record: ImportedDictionary) => Promise<void>>(
async () => {},
);
const deps = {
...makeDeps(),
queueLocalBinaryUpload,
} satisfies PullAndApplyDeps<ImportedDictionary>;
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(local);
await replicaPullAndApply(deps);
expect(queueLocalBinaryUpload).toHaveBeenCalledOnce();
expect(queueLocalBinaryUpload).toHaveBeenCalledWith(local);
expect(deps.applyRemote).not.toHaveBeenCalled();
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
});
test('alive-and-new-row WITHOUT manifest: does NOT queue an upload (no local copy)', async () => {
// No local record → nothing to upload. The other device will
// commit the manifest and we'll pick it up on a later pull.
const row = baseRow({ manifest_jsonb: null });
const queueLocalBinaryUpload = vi.fn<(record: ImportedDictionary) => Promise<void>>(
async () => {},
);
const deps = {
...makeDeps(),
queueLocalBinaryUpload,
} satisfies PullAndApplyDeps<ImportedDictionary>;
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
await replicaPullAndApply(deps);
expect(queueLocalBinaryUpload).not.toHaveBeenCalled();
expect(deps.applyRemote).toHaveBeenCalledOnce();
});
test('alive-and-new row WITHOUT manifest: applies dict but skips download (binaries pending server-side)', async () => {
const row = baseRow({ manifest_jsonb: null });
const deps = makeDeps();
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
expect(deps.applyRemote).toHaveBeenCalledOnce();
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
});
@@ -163,10 +208,10 @@ describe('pullDictionariesAndApply', () => {
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(local);
(deps.filesExist as ReturnType<typeof vi.fn>).mockResolvedValue(true);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.createBundleDir).not.toHaveBeenCalled();
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
expect(deps.applyRemote).not.toHaveBeenCalled();
expect(deps.filesExist).toHaveBeenCalledWith('local-1', ['webster.mdx']);
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
});
@@ -179,10 +224,10 @@ describe('pullDictionariesAndApply', () => {
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(local);
// Default filesExist returns false → recovery path.
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.createBundleDir).not.toHaveBeenCalled();
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
expect(deps.applyRemote).not.toHaveBeenCalled();
expect(deps.queueReplicaDownload).toHaveBeenCalledOnce();
const downloadArgs = (deps.queueReplicaDownload as ReturnType<typeof vi.fn>).mock.calls[0];
// bundleDir is the existing local entry's, NOT a fresh one.
@@ -200,9 +245,9 @@ describe('pullDictionariesAndApply', () => {
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
(deps.filesExist as ReturnType<typeof vi.fn>).mockResolvedValue(true);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
expect(deps.applyRemote).toHaveBeenCalledOnce();
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
});
@@ -218,10 +263,10 @@ describe('pullDictionariesAndApply', () => {
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(local);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.softDeleteByContentId).toHaveBeenCalledWith('content-hash-abc');
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
expect(deps.applyRemote).not.toHaveBeenCalled();
});
test('tombstoned row already gone locally: no-op (idempotent)', async () => {
@@ -235,10 +280,10 @@ describe('pullDictionariesAndApply', () => {
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.softDeleteByContentId).not.toHaveBeenCalled();
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
expect(deps.applyRemote).not.toHaveBeenCalled();
});
test('reincarnated row (alive again): treated as alive — creates locally if absent', async () => {
@@ -253,10 +298,10 @@ describe('pullDictionariesAndApply', () => {
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
const applied = (deps.applyRemoteDictionary as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(deps.applyRemote).toHaveBeenCalledOnce();
const applied = (deps.applyRemote as ReturnType<typeof vi.fn>).mock.calls[0]![0];
expect(applied.reincarnation).toBe('epoch-1');
expect(deps.queueReplicaDownload).toHaveBeenCalledOnce();
});
@@ -267,9 +312,9 @@ describe('pullDictionariesAndApply', () => {
const deps = makeDeps();
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([row]);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.applyRemoteDictionary).not.toHaveBeenCalled();
expect(deps.applyRemote).not.toHaveBeenCalled();
expect(deps.queueReplicaDownload).not.toHaveBeenCalled();
});
@@ -298,9 +343,9 @@ describe('pullDictionariesAndApply', () => {
async () => `bundle-${++bundleCounter}`,
);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.applyRemoteDictionary).toHaveBeenCalledTimes(2);
expect(deps.applyRemote).toHaveBeenCalledTimes(2);
expect(deps.queueReplicaDownload).toHaveBeenCalledTimes(2);
});
@@ -312,8 +357,8 @@ describe('pullDictionariesAndApply', () => {
(deps.pull as ReturnType<typeof vi.fn>).mockResolvedValue([badRow, goodRow]);
(deps.findByContentId as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(deps);
expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce();
expect(deps.applyRemote).toHaveBeenCalledOnce();
});
});
@@ -19,6 +19,7 @@ const dictionaryAdapter: ReplicaAdapter<DictRecord> = {
pack: (r) => ({ id: r.id, name: r.name, enabled: r.enabled }),
unpack: (f) => ({ id: String(f['id']), name: String(f['name']), enabled: Boolean(f['enabled']) }),
computeId: async (r: DictRecord) => r.id,
unpackRow: () => null,
};
afterEach(() => clearReplicaAdapters());
@@ -5,6 +5,7 @@ import {
initReplicaSync,
isReplicaSyncReady,
} from '@/services/sync/replicaSync';
import { __resetSettledEventsForTests } from '@/utils/event';
import { InMemoryHlcStore } from '@/libs/hlcStore';
import type { ReplicaRow, Hlc } from '@/types/replica';
import type { CursorStore } from '@/services/sync/replicaSyncManager';
@@ -26,6 +27,7 @@ const makeFakeClient = () => ({
afterEach(() => {
__resetReplicaSyncForTests();
__resetSettledEventsForTests();
});
describe('replicaSync singleton', () => {
@@ -1,25 +1,35 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
vi.mock('@/services/sync/replicaPublish', () => ({
publishDictionaryManifest: vi.fn(),
}));
const markAvailableByContentId = vi.fn();
vi.mock('@/store/customDictionaryStore', () => ({
useCustomDictionaryStore: {
getState: () => ({ markAvailableByContentId }),
},
publishReplicaManifest: vi.fn(),
}));
import { eventDispatcher } from '@/utils/event';
import { publishDictionaryManifest } from '@/services/sync/replicaPublish';
import { publishReplicaManifest } from '@/services/sync/replicaPublish';
import {
__resetReplicaTransferIntegrationForTests,
registerReplicaDownloadHandler,
startReplicaTransferIntegration,
} from '@/services/sync/replicaTransferIntegration';
import { clearReplicaAdapters, registerReplicaAdapter } from '@/services/sync/replicaRegistry';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
import type { AppService } from '@/types/system';
const mockPublish = publishDictionaryManifest as ReturnType<typeof vi.fn>;
const mockPublish = publishReplicaManifest as ReturnType<typeof vi.fn>;
const downloadHandler = vi.fn();
const fakeDictionaryAdapter: ReplicaAdapter<unknown> = {
kind: 'dictionary',
schemaVersion: 1,
pack: () => ({}),
unpack: () => ({}),
computeId: async () => '',
unpackRow: () => ({}),
binary: {
localBaseDir: 'Dictionaries',
enumerateFiles: () => [],
},
};
const makeFakeAppService = () => {
const close = vi.fn();
@@ -34,16 +44,20 @@ const makeFakeAppService = () => {
beforeEach(() => {
__resetReplicaTransferIntegrationForTests();
clearReplicaAdapters();
vi.clearAllMocks();
registerReplicaAdapter(fakeDictionaryAdapter);
registerReplicaDownloadHandler('dictionary', downloadHandler);
});
afterEach(() => {
__resetReplicaTransferIntegrationForTests();
clearReplicaAdapters();
vi.restoreAllMocks();
});
describe('replicaTransferIntegration', () => {
test('upload event triggers publishDictionaryManifest', async () => {
test('upload event triggers publishReplicaManifest', async () => {
const appService = makeFakeAppService() as unknown as AppService;
startReplicaTransferIntegration(appService);
mockPublish.mockResolvedValue(undefined);
@@ -59,7 +73,9 @@ describe('replicaTransferIntegration', () => {
});
expect(mockPublish).toHaveBeenCalledOnce();
const [contentId, manifestFiles] = mockPublish.mock.calls[0]!;
// Args: (kind, contentId, manifestFiles, reincarnation?)
const [kind, contentId, manifestFiles] = mockPublish.mock.calls[0]!;
expect(kind).toBe('dictionary');
expect(contentId).toBe('content-hash-abc');
expect(manifestFiles).toHaveLength(2);
expect(manifestFiles[0]!.filename).toBe('webster.mdx');
@@ -67,6 +83,21 @@ describe('replicaTransferIntegration', () => {
expect(manifestFiles[0]!.partialMd5).toMatch(/^[0-9a-f]{32}$/);
});
test('upload openFile uses the adapter binary base dir', async () => {
const appService = makeFakeAppService() as unknown as AppService;
startReplicaTransferIntegration(appService);
mockPublish.mockResolvedValue(undefined);
await eventDispatcher.dispatch('replica-transfer-complete', {
kind: 'dictionary',
replicaId: 'content-hash-abc',
type: 'upload',
files: [{ logical: 'webster.mdx', lfp: 'b/webster.mdx', byteSize: 1 }],
});
expect(appService.openFile).toHaveBeenCalledWith('b/webster.mdx', 'Dictionaries');
});
test('upload event carries reincarnation token into manifest publish', async () => {
const appService = makeFakeAppService() as unknown as AppService;
startReplicaTransferIntegration(appService);
@@ -81,7 +112,7 @@ describe('replicaTransferIntegration', () => {
});
expect(mockPublish).toHaveBeenCalledOnce();
expect(mockPublish.mock.calls[0]![2]).toBe('epoch-1');
expect(mockPublish.mock.calls[0]![3]).toBe('epoch-1');
});
test('download event does NOT publish a manifest (publish is upload-side only)', async () => {
@@ -97,7 +128,7 @@ describe('replicaTransferIntegration', () => {
expect(mockPublish).not.toHaveBeenCalled();
});
test('download event marks the local dict available (clears unavailable flag)', async () => {
test('download event invokes the per-kind handler with the replicaId', async () => {
const appService = makeFakeAppService() as unknown as AppService;
startReplicaTransferIntegration(appService);
@@ -107,11 +138,11 @@ describe('replicaTransferIntegration', () => {
type: 'download',
files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 10 }],
});
expect(markAvailableByContentId).toHaveBeenCalledOnce();
expect(markAvailableByContentId).toHaveBeenCalledWith('content-hash-abc');
expect(downloadHandler).toHaveBeenCalledOnce();
expect(downloadHandler).toHaveBeenCalledWith('content-hash-abc');
});
test('upload event does NOT mark available (only download finishes the placeholder lifecycle)', async () => {
test('upload event does NOT invoke the download handler', async () => {
const appService = makeFakeAppService() as unknown as AppService;
startReplicaTransferIntegration(appService);
mockPublish.mockResolvedValue(undefined);
@@ -122,20 +153,39 @@ describe('replicaTransferIntegration', () => {
type: 'upload',
files: [{ logical: 'x.mdx', lfp: 'b/x.mdx', byteSize: 10 }],
});
expect(markAvailableByContentId).not.toHaveBeenCalled();
expect(downloadHandler).not.toHaveBeenCalled();
});
test('non-dictionary download event is ignored (no markAvailable)', async () => {
test('event for an unknown kind (no registered adapter) is ignored', async () => {
const appService = makeFakeAppService() as unknown as AppService;
startReplicaTransferIntegration(appService);
await eventDispatcher.dispatch('replica-transfer-complete', {
kind: 'unregistered-kind',
replicaId: 'x',
type: 'download',
files: [{ logical: 'r.ttf', lfp: 'f/r.ttf', byteSize: 1 }],
});
expect(downloadHandler).not.toHaveBeenCalled();
expect(mockPublish).not.toHaveBeenCalled();
});
test('event for a kind without a registered download handler is ignored cleanly', async () => {
const appService = makeFakeAppService() as unknown as AppService;
// Register a SECOND adapter without a download handler.
registerReplicaAdapter({
...fakeDictionaryAdapter,
kind: 'font',
});
startReplicaTransferIntegration(appService);
await eventDispatcher.dispatch('replica-transfer-complete', {
kind: 'font',
replicaId: 'font-hash',
type: 'download',
files: [{ logical: 'r.ttf', lfp: 'f/r.ttf', byteSize: 1 }],
});
expect(markAvailableByContentId).not.toHaveBeenCalled();
expect(downloadHandler).not.toHaveBeenCalled();
});
test('delete event is ignored', async () => {
@@ -149,19 +199,7 @@ describe('replicaTransferIntegration', () => {
filenames: ['x.mdx'],
});
expect(mockPublish).not.toHaveBeenCalled();
});
test('non-dictionary kind is ignored (this slice ships dictionary only)', async () => {
const appService = makeFakeAppService() as unknown as AppService;
startReplicaTransferIntegration(appService);
await eventDispatcher.dispatch('replica-transfer-complete', {
kind: 'font',
replicaId: 'font-hash',
type: 'upload',
files: [{ logical: 'r.ttf', lfp: 'f/r.ttf', byteSize: 1 }],
});
expect(mockPublish).not.toHaveBeenCalled();
expect(downloadHandler).not.toHaveBeenCalled();
});
test('upload event with no files is ignored', async () => {
@@ -191,6 +229,12 @@ describe('replicaTransferIntegration', () => {
expect(mockPublish).toHaveBeenCalledTimes(1);
});
test('registering a download handler twice for the same kind throws', () => {
expect(() => registerReplicaDownloadHandler('dictionary', vi.fn())).toThrowError(
/already registered/,
);
});
test('publish error is caught (does not bubble up to event dispatcher)', async () => {
const appService = makeFakeAppService() as unknown as AppService;
startReplicaTransferIntegration(appService);
@@ -39,7 +39,7 @@ describe('getTransferMessages', () => {
expect(m.failure.delete).toBe('Failed to delete cloud backup of the book: Moby Dick');
});
test('dictionary replica transfer uses "Dictionary" copy', () => {
test('dictionary replica transfer uses generic "File" copy', () => {
const m = getTransferMessages(
baseTransfer({
kind: 'replica',
@@ -49,23 +49,21 @@ describe('getTransferMessages', () => {
}),
passthroughT,
);
expect(m.success.upload).toBe('Dictionary uploaded: Longman Phrasal Verbs');
expect(m.success.download).toBe('Dictionary downloaded: Longman Phrasal Verbs');
expect(m.success.delete).toBe('Deleted cloud copy of the dictionary: Longman Phrasal Verbs');
expect(m.failure.upload).toBe('Failed to upload dictionary: Longman Phrasal Verbs');
expect(m.failure.download).toBe('Failed to download dictionary: Longman Phrasal Verbs');
expect(m.failure.delete).toBe(
'Failed to delete cloud copy of the dictionary: Longman Phrasal Verbs',
);
expect(m.success.upload).toBe('File uploaded: Longman Phrasal Verbs');
expect(m.success.download).toBe('File downloaded: Longman Phrasal Verbs');
expect(m.success.delete).toBe('Deleted cloud copy of the file: Longman Phrasal Verbs');
expect(m.failure.upload).toBe('Failed to upload file: Longman Phrasal Verbs');
expect(m.failure.download).toBe('Failed to download file: Longman Phrasal Verbs');
expect(m.failure.delete).toBe('Failed to delete cloud copy of the file: Longman Phrasal Verbs');
});
test('replica transfer with unknown replicaKind falls back to book copy (defensive)', () => {
test('font replica transfer also uses generic "File" copy', () => {
const m = getTransferMessages(
baseTransfer({ kind: 'replica', replicaKind: 'font', bookTitle: 'Roboto' }),
passthroughT,
);
// Until 'font' ships explicit copy, the safe fallback is the existing
// book strings. Updating per-kind copy is paired with adding the kind.
expect(m.success.upload).toBe('Book uploaded: Roboto');
expect(m.success.upload).toBe('File uploaded: Roboto');
expect(m.success.download).toBe('File downloaded: Roboto');
expect(m.success.delete).toBe('Deleted cloud copy of the file: Roboto');
});
});
@@ -1,23 +1,20 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
vi.mock('@/services/sync/replicaPublish', () => ({
publishDictionaryDelete: vi.fn(),
publishDictionaryUpsert: vi.fn(),
publishReplicaDelete: vi.fn(),
publishReplicaUpsert: vi.fn(),
}));
import {
useCustomDictionaryStore,
enableReplicaAutoPersist,
findDictionaryByContentId,
} from '@/store/customDictionaryStore';
import { useCustomDictionaryStore, findDictionaryByContentId } from '@/store/customDictionaryStore';
import { enableReplicaAutoPersist } from '@/services/sync/replicaPersist';
import { BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types';
import { publishDictionaryUpsert } from '@/services/sync/replicaPublish';
import { publishReplicaUpsert } from '@/services/sync/replicaPublish';
import { useSettingsStore } from '@/store/settingsStore';
import type { EnvConfigType } from '@/services/environment';
import type { ImportedDictionary } from '@/services/dictionaries/types';
const ZERO = (s: string) => s.startsWith('web:builtin:');
const mockPublishDictionaryUpsert = vi.mocked(publishDictionaryUpsert);
const mockPublishReplicaUpsert = vi.mocked(publishReplicaUpsert);
describe('customDictionaryStore — web search CRUD', () => {
beforeEach(() => {
@@ -284,13 +281,15 @@ describe('customDictionaryStore — web search CRUD', () => {
reincarnation: 'epoch-1',
});
mockPublishDictionaryUpsert.mockClear();
mockPublishReplicaUpsert.mockClear();
updateDictionary('mdict:abc', { name: 'New title' });
expect(mockPublishDictionaryUpsert).toHaveBeenCalledOnce();
expect(mockPublishDictionaryUpsert.mock.calls[0]![0]).toMatchObject({
name: 'New title',
reincarnation: 'epoch-1',
});
expect(mockPublishReplicaUpsert).toHaveBeenCalledOnce();
// Args: (kind, record, contentId, reincarnation?)
const call = mockPublishReplicaUpsert.mock.calls[0]!;
expect(call[0]).toBe('dictionary');
expect(call[1]).toMatchObject({ name: 'New title', reincarnation: 'epoch-1' });
expect(call[2]).toBe('content-abc');
expect(call[3]).toBe('epoch-1');
});
});
@@ -1,9 +1,32 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import { useCustomFontStore } from '@/store/customFontStore';
vi.mock('@/services/sync/replicaPublish', () => ({
publishReplicaDelete: vi.fn(),
publishReplicaUpsert: vi.fn(),
}));
vi.mock('@/utils/md5', async () => {
const actual = await vi.importActual<typeof import('@/utils/md5')>('@/utils/md5');
return {
...actual,
partialMD5: vi.fn(async () => 'partial-md5-stub'),
};
});
vi.mock('@/utils/misc', async () => {
const actual = await vi.importActual<typeof import('@/utils/misc')>('@/utils/misc');
return {
...actual,
uniqueId: vi.fn(() => 'fresh-bundle-1'),
};
});
import { useCustomFontStore, migrateLegacyFonts } from '@/store/customFontStore';
import { useSettingsStore } from '@/store/settingsStore';
import { CustomFont } from '@/styles/fonts';
import { SystemSettings } from '@/types/settings';
import { EnvConfigType } from '@/services/environment';
import { publishReplicaUpsert } from '@/services/sync/replicaPublish';
const mockPublishReplicaUpsert = vi.mocked(publishReplicaUpsert);
function makeFont(overrides: Partial<CustomFont> & { id: string; name: string }): CustomFont {
return {
@@ -386,4 +409,133 @@ describe('customFontStore', () => {
expect(savedFonts![0]).not.toHaveProperty('error');
});
});
describe('migrateLegacyFonts', () => {
interface FakeAppService {
exists: ReturnType<typeof vi.fn>;
openFile: ReturnType<typeof vi.fn>;
createDir: ReturnType<typeof vi.fn>;
copyFile: ReturnType<typeof vi.fn>;
deleteFile: ReturnType<typeof vi.fn>;
}
const buildEnv = (svc: FakeAppService): EnvConfigType =>
({ getAppService: vi.fn(async () => svc) }) as unknown as EnvConfigType;
const fakeService = (): FakeAppService => ({
exists: vi.fn(async () => true),
openFile: vi.fn(async () => new File([new Uint8Array(1024)], 'Roboto.ttf')),
createDir: vi.fn(async () => undefined),
copyFile: vi.fn(async () => undefined),
deleteFile: vi.fn(async () => undefined),
});
beforeEach(() => {
mockPublishReplicaUpsert.mockClear();
useSettingsStore.setState({
settings: {} as SystemSettings,
setSettings: vi.fn(),
saveSettings: vi.fn().mockResolvedValue(undefined),
});
});
test('rehashes a legacy flat-path font into the per-bundle layout', async () => {
useCustomFontStore.setState({
fonts: [
{
id: 'legacy-1',
name: 'Roboto',
path: 'Roboto.ttf',
},
],
loading: false,
});
const svc = fakeService();
await migrateLegacyFonts(buildEnv(svc));
const after = useCustomFontStore.getState().fonts.find((f) => f.id === 'legacy-1')!;
expect(after.contentId).toBeDefined();
expect(after.bundleDir).toBe('fresh-bundle-1');
expect(after.path).toBe('fresh-bundle-1/Roboto.ttf');
expect(after.byteSize).toBe(1024);
expect(svc.copyFile).toHaveBeenCalledWith(
'Roboto.ttf',
'Fonts',
'fresh-bundle-1/Roboto.ttf',
'Fonts',
);
expect(svc.deleteFile).toHaveBeenCalledWith('Roboto.ttf', 'Fonts');
});
test('publishes each migrated font (replica upsert)', async () => {
useCustomFontStore.setState({
fonts: [{ id: 'legacy-2', name: 'Inter', path: 'Inter.ttf' }],
loading: false,
});
await migrateLegacyFonts(buildEnv(fakeService()));
expect(mockPublishReplicaUpsert).toHaveBeenCalledOnce();
expect(mockPublishReplicaUpsert.mock.calls[0]![0]).toBe('font');
});
test('skips fonts that already have a contentId (idempotent)', async () => {
useCustomFontStore.setState({
fonts: [
{
id: 'already-migrated',
name: 'Inter',
path: 'b/Inter.ttf',
contentId: 'pre-existing',
bundleDir: 'b',
},
],
loading: false,
});
const svc = fakeService();
await migrateLegacyFonts(buildEnv(svc));
expect(svc.copyFile).not.toHaveBeenCalled();
expect(svc.deleteFile).not.toHaveBeenCalled();
expect(mockPublishReplicaUpsert).not.toHaveBeenCalled();
});
test('skips fonts whose on-disk file is missing (re-flags via loadCustomFonts later)', async () => {
useCustomFontStore.setState({
fonts: [{ id: 'gone', name: 'Lost', path: 'Lost.ttf' }],
loading: false,
});
const svc = fakeService();
svc.exists.mockResolvedValueOnce(false);
await migrateLegacyFonts(buildEnv(svc));
const after = useCustomFontStore.getState().fonts.find((f) => f.id === 'gone')!;
expect(after.contentId).toBeUndefined();
expect(svc.copyFile).not.toHaveBeenCalled();
});
test('skips deleted fonts', async () => {
useCustomFontStore.setState({
fonts: [{ id: 'tombstoned', name: 'X', path: 'X.ttf', deletedAt: 100 }],
loading: false,
});
const svc = fakeService();
await migrateLegacyFonts(buildEnv(svc));
expect(svc.copyFile).not.toHaveBeenCalled();
});
test('per-font failure is isolated; other fonts still migrate', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
useCustomFontStore.setState({
fonts: [
{ id: 'a', name: 'A', path: 'A.ttf' },
{ id: 'b', name: 'B', path: 'B.ttf' },
],
loading: false,
});
const svc = fakeService();
svc.copyFile.mockRejectedValueOnce(new Error('disk full'));
await migrateLegacyFonts(buildEnv(svc));
const fonts = useCustomFontStore.getState().fonts;
const aFont = fonts.find((f) => f.id === 'a')!;
const bFont = fonts.find((f) => f.id === 'b')!;
expect(aFont.contentId).toBeUndefined();
expect(bFont.contentId).toBeDefined();
});
});
});
@@ -293,6 +293,22 @@ describe('createCustomFont', () => {
expect(font.variable).toBeUndefined();
});
it('should preserve replica sync fields (contentId, bundleDir, byteSize) from options', () => {
// Manual font import passes contentId / bundleDir / byteSize through
// addFont → createCustomFont. If createCustomFont drops them, the
// store's publishFontUpsert short-circuits on `!font.contentId` and
// the replica row never publishes — the binary upload also no-ops
// for the same reason. Lock these fields in.
const font = createCustomFont('/fonts/Roboto.ttf', {
contentId: 'content-hash-abc',
bundleDir: 'bundle-1',
byteSize: 4096,
});
expect(font.contentId).toBe('content-hash-abc');
expect(font.bundleDir).toBe('bundle-1');
expect(font.byteSize).toBe(4096);
});
it('should handle woff2 path', () => {
const font = createCustomFont('/fonts/OpenSans.woff2');
expect(font.name).toBe('OpenSans');
@@ -1,10 +1,15 @@
import { describe, it, expect, vi } from 'vitest';
import { afterEach, describe, it, expect, vi } from 'vitest';
// We need to import the singleton, but also be able to test a fresh instance.
// The module exports a singleton `eventDispatcher`. Since the class is not
// exported, we test through the singleton and reset between tests.
import { eventDispatcher } from '@/utils/event';
import {
eventDispatcher,
markSettled,
onSettled,
__resetSettledEventsForTests,
} from '@/utils/event';
describe('EventDispatcher', () => {
// -----------------------------------------------------------------------
@@ -199,3 +204,89 @@ describe('EventDispatcher', () => {
});
});
});
describe('settled events (markSettled / onSettled)', () => {
afterEach(() => {
__resetSettledEventsForTests();
});
it('fires listeners that subscribed BEFORE settling', async () => {
const fn = vi.fn();
onSettled('boot', fn);
expect(fn).not.toHaveBeenCalled();
await markSettled('boot', { ready: true });
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith({ ready: true });
});
it('replays synchronously for listeners that subscribe AFTER settling', async () => {
await markSettled('boot', { ready: true });
const fn = vi.fn();
onSettled('boot', fn);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith({ ready: true });
});
it('fires multiple listeners; each fires exactly once', async () => {
const a = vi.fn();
const b = vi.fn();
onSettled('boot', a);
onSettled('boot', b);
await markSettled('boot');
expect(a).toHaveBeenCalledOnce();
expect(b).toHaveBeenCalledOnce();
// Idempotent: a second markSettled is a no-op.
await markSettled('boot');
expect(a).toHaveBeenCalledOnce();
expect(b).toHaveBeenCalledOnce();
});
it('auto-unsubscribes after fire — repeated dispatches do NOT re-fire', async () => {
const fn = vi.fn();
onSettled('boot', fn);
await markSettled('boot');
expect(fn).toHaveBeenCalledOnce();
// The bare eventDispatcher would normally fire again on re-dispatch;
// settled-event listeners must not.
await eventDispatcher.dispatch('boot', { stale: true });
expect(fn).toHaveBeenCalledOnce();
});
it('returned unsubscribe cancels a pre-settle subscription', async () => {
const fn = vi.fn();
const unsub = onSettled('boot', fn);
unsub();
await markSettled('boot');
expect(fn).not.toHaveBeenCalled();
});
it('returned unsubscribe is a no-op for late (already-replayed) subscribers', async () => {
await markSettled('boot');
const fn = vi.fn();
const unsub = onSettled('boot', fn);
expect(fn).toHaveBeenCalledOnce();
expect(() => unsub()).not.toThrow();
});
it('preserves typed payload through onSettled<T>', async () => {
interface BootPayload {
version: number;
}
let captured: BootPayload | null = null;
onSettled<BootPayload>('typed-boot', (p) => {
captured = p;
});
await markSettled('typed-boot', { version: 7 });
expect(captured).toEqual({ version: 7 });
});
it('different event names are independent', async () => {
const a = vi.fn();
const b = vi.fn();
onSettled('a', a);
onSettled('b', b);
await markSettled('a');
expect(a).toHaveBeenCalledOnce();
expect(b).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { webDownload, type ProgressPayload } from '@/utils/transfer';
const buildResponse = (
body: Uint8Array,
contentLength: string | null,
ok = true,
status = 200,
): Response => {
const headers = new Headers();
if (contentLength !== null) headers.set('Content-Length', contentLength);
return new Response(body as unknown as BodyInit, {
status,
headers,
statusText: ok ? 'OK' : 'Err',
});
};
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useRealTimers();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('webDownload', () => {
test('reports progress with the byte total when Content-Length is present', async () => {
const bytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
globalThis.fetch = vi.fn(async () =>
buildResponse(bytes, String(bytes.length)),
) as unknown as typeof fetch;
const events: ProgressPayload[] = [];
const result = await webDownload('https://example.test/file', (p) => events.push(p));
expect(await result.blob.arrayBuffer()).toEqual(bytes.buffer);
expect(events.length).toBeGreaterThan(0);
expect(events.at(-1)!.total).toBe(8);
expect(events.at(-1)!.progress).toBe(8);
});
test('does NOT throw when Content-Length is missing — falls back to indeterminate progress (total=0)', async () => {
// R2 / S3 signed URLs frequently omit Content-Length from CORS-exposed
// headers. Failing the whole download to protect a progress bar makes
// replica binaries (fonts, dictionaries) impossible to fetch on web.
// The contract: still resolve, still feed bytes, just emit indeterminate
// progress events (total=0). UI callers already guard `total === 0`.
const bytes = new Uint8Array([10, 20, 30]);
globalThis.fetch = vi.fn(async () => buildResponse(bytes, null)) as unknown as typeof fetch;
const events: ProgressPayload[] = [];
const result = await webDownload('https://example.test/file', (p) => events.push(p));
expect(await result.blob.arrayBuffer()).toEqual(bytes.buffer);
expect(events.length).toBeGreaterThan(0);
expect(events.every((e) => e.total === 0)).toBe(true);
expect(events.at(-1)!.progress).toBe(3);
});
test('falls back to X-Content-Length when present', async () => {
const bytes = new Uint8Array([1, 2, 3]);
const headers = new Headers();
headers.set('X-Content-Length', '3');
globalThis.fetch = vi.fn(
async () => new Response(bytes as unknown as BodyInit, { status: 200, headers }),
) as unknown as typeof fetch;
const events: ProgressPayload[] = [];
await webDownload('https://example.test/file', (p) => events.push(p));
expect(events.at(-1)!.total).toBe(3);
});
test('rejects with Unauthorized for 401 / 403', async () => {
globalThis.fetch = vi.fn(
async () => new Response('', { status: 401 }),
) as unknown as typeof fetch;
await expect(webDownload('https://example.test/file')).rejects.toThrow('Unauthorized');
});
test('rejects with DownloadFailed for other non-OK statuses', async () => {
globalThis.fetch = vi.fn(
async () => new Response('', { status: 500 }),
) as unknown as typeof fetch;
await expect(webDownload('https://example.test/file')).rejects.toThrow();
});
test('completes successfully without a progress handler when Content-Length is missing', async () => {
const bytes = new Uint8Array([7, 8, 9]);
globalThis.fetch = vi.fn(async () => buildResponse(bytes, null)) as unknown as typeof fetch;
const result = await webDownload('https://example.test/file');
expect(await result.blob.arrayBuffer()).toEqual(bytes.buffer);
});
});
@@ -192,7 +192,7 @@ export const MigrateDataWindow = () => {
const srcPath = await join(currentDataDir, file.path);
const destPath = await join(newDataDir, file.path);
await appService.copyFile(srcPath, destPath, 'None');
await appService.copyFile(srcPath, 'None', destPath, 'None');
}
// Verify all files copied
+4 -4
View File
@@ -116,10 +116,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const { isSettingsDialogOpen, setSettingsDialogOpen } = useSettingsStore();
const { isTransferQueueOpen } = useTransferStore();
// Library page pulls dictionaries (custom mdict/stardict bundles synced
// across devices). Deferred 10s; module-scoped dedup means a later
// navigation to the reader won't re-pull the same kind.
useReplicaPull({ kinds: ['dictionary'] });
// Library page pulls user replicas (dictionaries, custom fonts).
// Deferred 10s; module-scoped dedup means a later navigation to the
// reader won't re-pull the same kind.
useReplicaPull({ kinds: ['dictionary', 'font'] });
const [showCatalogManager, setShowCatalogManager] = useState(
searchParams?.get('opds') === 'true',
);
+1 -1
View File
@@ -467,7 +467,7 @@ export default function BrowserPage() {
const probedFilename = await probeFilename(responseHeaders);
if (probedFilename) {
const newFilePath = await appService?.resolveFilePath(probedFilename, 'Cache');
await appService?.copyFile(dstFilePath, newFilePath, 'None');
await appService?.copyFile(dstFilePath, 'None', newFilePath, 'None');
await appService?.deleteFile(dstFilePath, 'None');
console.log('Renamed downloaded file to:', newFilePath);
dstFilePath = newFilePath;
@@ -74,11 +74,12 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' });
useScreenWakeLock(settings.screenWakeLock);
useTransferQueue(libraryLoaded, 5000);
// Reader needs custom dictionaries for word-lookup providers. Mounted
// here (not in the app-router page wrapper) so the web pages-router
// entry at `pages/reader/[ids].tsx` also gets the pull. Module-scoped
// dedup means navigating between library and reader doesn't re-pull.
useReplicaPull({ kinds: ['dictionary'] });
// Reader needs dictionaries for word-lookup and fonts for rendering.
// Mounted here (not in the app-router page wrapper) so the web pages-
// router entry at `pages/reader/[ids].tsx` also gets the pull.
// Module-scoped dedup means navigating between library and reader
// doesn't re-pull.
useReplicaPull({ kinds: ['dictionary', 'font'] });
useEffect(() => {
mountAdditionalFonts(document);
@@ -27,10 +27,11 @@ export const useBookCoverAutoSave = (bookKey: string) => {
const lastCoverFilename = 'last-book-cover.png';
const builtinImagesPath = await appService.resolveFilePath('', 'Images');
if (!savedCoverPath || savedCoverPath === builtinImagesPath) {
await appService.copyFile(coverPath, lastCoverFilename, 'Images');
await appService.copyFile(coverPath, 'None', lastCoverFilename, 'Images');
} else {
await appService.copyFile(
coverPath,
'None',
`${savedCoverPath}/${lastCoverFilename}`,
'None',
);
@@ -85,18 +85,24 @@ const StorageManager = () => {
loadStats();
}, [loadFiles, loadStats]);
// Group files by book_hash
// Group files by their natural row identity:
// - book_hash for book uploads
// - replica_kind:replica_id for replica binaries (each dictionary,
// font, etc. clusters under its own row)
// - 'no-group' as a final catch-all
const groupKeyFor = (file: FileRecord): string => {
if (file.book_hash) return file.book_hash;
if (file.replica_id) return `replica:${file.replica_kind ?? '?'}:${file.replica_id}`;
return 'no-group';
};
const groupedFiles = React.useMemo(() => {
const groups = new Map<string, FileRecord[]>();
files.forEach((file) => {
const bookHash = file.book_hash || 'no-book';
if (!groups.has(bookHash)) {
groups.set(bookHash, []);
}
groups.get(bookHash)!.push(file);
const key = groupKeyFor(file);
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(file);
});
return groups;
}, [files]);
@@ -109,9 +115,19 @@ const StorageManager = () => {
return getFileName(file.file_key).toLowerCase() === 'cover.png';
};
// Get main book file (first non-cover file)
const getMainBookFile = (bookFiles: FileRecord[]): FileRecord | null => {
return bookFiles.find((f) => !isCoverFile(f)) || bookFiles[0] || null;
// Pick the file whose name labels the group:
// - book groups: first non-cover file (existing behavior).
// - replica groups: the largest file. The primary binary
// (dictionary .mdx, font .ttf, etc.) dominates the bundle by
// size, so labelling by it surfaces the natural identifier
// rather than a tiny sidecar like dacihai.css.
const getMainBookFile = (groupFiles: FileRecord[]): FileRecord | null => {
if (groupFiles.length === 0) return null;
const isReplicaGroup = !groupFiles[0]!.book_hash && !!groupFiles[0]!.replica_id;
if (isReplicaGroup) {
return groupFiles.reduce((a, b) => (b.file_size > a.file_size ? b : a));
}
return groupFiles.find((f) => !isCoverFile(f)) || groupFiles[0] || null;
};
// Get all files for a book including covers
@@ -10,6 +10,7 @@ import { useCustomFontStore } from '@/store/customFontStore';
import { useFileSelector } from '@/hooks/useFileSelector';
import { saveViewSettings } from '@/helpers/settings';
import { CustomFont, mountCustomFont } from '@/styles/fonts';
import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload';
interface CustomFontsProps {
bookKey: string;
@@ -58,11 +59,15 @@ const CustomFonts: React.FC<CustomFontsProps> = ({ bookKey, onBack }) => {
style: fontInfo.style,
weight: fontInfo.weight,
variable: fontInfo.variable,
contentId: fontInfo.contentId,
bundleDir: fontInfo.bundleDir,
byteSize: fontInfo.byteSize,
});
console.log('Added custom font:', customFont);
if (customFont && !customFont.error) {
const loadedFont = await loadFont(envConfig, customFont.id);
mountCustomFont(document, loadedFont);
if (appService) void queueReplicaBinaryUpload('font', customFont, appService);
}
}
saveCustomFonts(envConfig);
+1 -1
View File
@@ -8,7 +8,7 @@ import { bootstrapReplicaAdapters } from '@/services/sync/replicaBootstrap';
import { initReplicaSync } from '@/services/sync/replicaSync';
import { createSettingsCursorStore } from '@/services/sync/replicaCursorStore';
import { startReplicaTransferIntegration } from '@/services/sync/replicaTransferIntegration';
import { enableReplicaAutoPersist } from '@/store/customDictionaryStore';
import { enableReplicaAutoPersist } from '@/services/sync/replicaPersist';
interface EnvContextType {
envConfig: EnvConfigType;
+100 -40
View File
@@ -1,18 +1,26 @@
import { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useCustomDictionaryStore, findDictionaryByContentId } from '@/store/customDictionaryStore';
import {
useCustomFontStore,
findFontByContentId,
migrateLegacyFonts,
} from '@/store/customFontStore';
import { transferManager } from '@/services/transferManager';
import { getReplicaSync } from '@/services/sync/replicaSync';
import { pullDictionariesAndApply } from '@/services/sync/replicaPullDictionaries';
import { getReplicaSync, subscribeReplicaSyncReady } from '@/services/sync/replicaSync';
import { dictionaryAdapter } from '@/services/sync/adapters/dictionary';
import { fontAdapter } from '@/services/sync/adapters/font';
import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import { replicaPullAndApply, type PullAndApplyDeps } from '@/services/sync/replicaPullAndApply';
import { getAccessToken } from '@/utils/access';
import { uniqueId } from '@/utils/misc';
import type { EnvConfigType } from '@/services/environment';
import type { AppService, BaseDir } from '@/types/system';
import type { AppService } from '@/types/system';
import type { ReplicaSyncManager } from '@/services/sync/replicaSyncManager';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { ReplicaTransferFile } from '@/store/transferStore';
import type { CustomFont } from '@/styles/fonts';
export type ReplicaKind = 'dictionary';
export type ReplicaKind = 'dictionary' | 'font';
export interface UseReplicaPullOpts {
/** Replica kinds this page wants pulled. */
@@ -34,7 +42,8 @@ const buildDictionaryPullDeps = (
manager: ReplicaSyncManager,
service: AppService,
envConfig: EnvConfigType,
) => ({
): PullAndApplyDeps<ImportedDictionary> => ({
adapter: dictionaryAdapter,
// Boot path uses since=null so we always re-fetch and apply locally,
// ignoring any previously-advanced cursor. Periodic sync (visibility /
// online) goes through manager.pull(kind) which keeps using the cursor.
@@ -48,29 +57,61 @@ const buildDictionaryPullDeps = (
// persisted dict that hadn't been hydrated by an Annotator/Settings
// mount. Library-page refreshes were the visible victim.
hydrateLocalStore: () => useCustomDictionaryStore.getState().loadCustomDictionaries(envConfig),
applyRemoteDictionary: (dict: ImportedDictionary) =>
useCustomDictionaryStore.getState().applyRemoteDictionary(dict),
softDeleteByContentId: (id: string) =>
useCustomDictionaryStore.getState().softDeleteByContentId(id),
applyRemote: (dict) => useCustomDictionaryStore.getState().applyRemoteDictionary(dict),
softDeleteByContentId: (id) => useCustomDictionaryStore.getState().softDeleteByContentId(id),
createBundleDir: async () => {
const id = uniqueId();
await service.createDir(id, 'Dictionaries', true);
return id;
},
queueReplicaDownload: (
contentId: string,
displayTitle: string,
files: ReplicaTransferFile[],
_bundleDir: string,
base: BaseDir,
) => transferManager.queueReplicaDownload('dictionary', contentId, displayTitle, files, base),
filesExist: async (bundleDir: string, filenames: string[]) => {
queueReplicaDownload: (contentId, displayTitle, files, _bundleDir, base) =>
transferManager.queueReplicaDownload('dictionary', contentId, displayTitle, files, base),
filesExist: async (bundleDir, filenames) => {
for (const filename of filenames) {
const exists = await service.exists(`${bundleDir}/${filename}`, 'Dictionaries');
if (!exists) return false;
}
return true;
},
queueLocalBinaryUpload: async (record) => {
await queueReplicaBinaryUpload('dictionary', record, service);
},
isAuthenticated: async () => !!(await getAccessToken()),
});
const buildFontPullDeps = (
manager: ReplicaSyncManager,
service: AppService,
envConfig: EnvConfigType,
): PullAndApplyDeps<CustomFont> => ({
adapter: fontAdapter,
pull: () => manager.pull('font', { since: null }),
findByContentId: (id: string) => findFontByContentId(id),
hydrateLocalStore: async () => {
await useCustomFontStore.getState().loadCustomFonts(envConfig);
// Rehash legacy flat-path fonts so the user doesn't have to
// re-import them by hand to get them onto other devices.
await migrateLegacyFonts(envConfig);
},
applyRemote: (font) => useCustomFontStore.getState().applyRemoteFont(font),
softDeleteByContentId: (id) => useCustomFontStore.getState().softDeleteByContentId(id),
createBundleDir: async () => {
const id = uniqueId();
await service.createDir(id, 'Fonts', true);
return id;
},
queueReplicaDownload: (contentId, displayTitle, files, _bundleDir, base) =>
transferManager.queueReplicaDownload('font', contentId, displayTitle, files, base),
filesExist: async (bundleDir, filenames) => {
for (const filename of filenames) {
const exists = await service.exists(`${bundleDir}/${filename}`, 'Fonts');
if (!exists) return false;
}
return true;
},
queueLocalBinaryUpload: async (record) => {
await queueReplicaBinaryUpload('font', record, service);
},
isAuthenticated: async () => !!(await getAccessToken()),
});
@@ -82,11 +123,14 @@ const runPullForKind = async (
const ctx = getReplicaSync();
if (!ctx) return;
if (kind === 'dictionary') {
const deps = buildDictionaryPullDeps(ctx.manager, service, envConfig);
await pullDictionariesAndApply(deps);
await replicaPullAndApply(buildDictionaryPullDeps(ctx.manager, service, envConfig));
return;
}
// Future: dispatch to other per-kind orchestrators here.
if (kind === 'font') {
await replicaPullAndApply(buildFontPullDeps(ctx.manager, service, envConfig));
return;
}
// Future: dispatch to other per-kind dep builders here.
};
/**
@@ -111,28 +155,44 @@ export const useReplicaPull = ({
useEffect(() => {
if (!appService) return;
const ctx = getReplicaSync();
if (!ctx) return;
const pendingKinds = kinds.filter((k) => !pulledKinds.has(k));
if (pendingKinds.length === 0) return;
let timer: ReturnType<typeof setTimeout> | null = null;
let unsubscribe: (() => void) | null = null;
const timer = setTimeout(() => {
for (const kind of pendingKinds) {
if (pulledKinds.has(kind)) continue;
// Claim the slot up front so a concurrently-scheduled mount
// (e.g., library + reader mounting back-to-back) doesn't double-
// pull. On failure we release the slot so a subsequent navigation
// can retry.
pulledKinds.add(kind);
void runPullForKind(kind, appService, envConfig).catch((err) => {
console.warn(`replica ${kind} pull failed`, err);
pulledKinds.delete(kind);
});
}
}, delayMs);
const schedule = () => {
if (timer) return;
const pendingKinds = kinds.filter((k) => !pulledKinds.has(k));
if (pendingKinds.length === 0) return;
timer = setTimeout(() => {
for (const kind of pendingKinds) {
if (pulledKinds.has(kind)) continue;
// Claim the slot up front so a concurrently-scheduled mount
// (e.g., library + reader mounting back-to-back) doesn't
// double-pull. On failure we release the slot so a
// subsequent navigation can retry.
pulledKinds.add(kind);
void runPullForKind(kind, appService, envConfig).catch((err) => {
console.warn(`replica ${kind} pull failed`, err);
pulledKinds.delete(kind);
});
}
}, delayMs);
};
return () => clearTimeout(timer);
if (getReplicaSync()) {
schedule();
} else {
// Hard-refresh race: appService resolved before
// EnvContext.initReplicaSync finished (loadSettings is async,
// setAppService runs first). Wait for the ready signal so the
// pull still fires once the singleton lands.
unsubscribe = subscribeReplicaSyncReady(schedule);
}
return () => {
if (timer) clearTimeout(timer);
if (unsubscribe) unsubscribe();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kindsKey, appService, envConfig, delayMs]);
};
@@ -32,6 +32,18 @@ const dictionaryFieldsSchema = z
})
.catchall(fieldEnvelopeWithCipher);
const fontFieldsSchema = z
.object({
name: fieldEnvelopeSchema.optional(),
family: fieldEnvelopeSchema.optional(),
style: fieldEnvelopeSchema.optional(),
weight: fieldEnvelopeSchema.optional(),
variable: fieldEnvelopeSchema.optional(),
byteSize: fieldEnvelopeSchema.optional(),
downloadedAt: fieldEnvelopeSchema.optional(),
})
.catchall(fieldEnvelopeWithCipher);
interface KindSpec {
minSchemaVersion: number;
maxSchemaVersion: number;
@@ -48,6 +60,13 @@ export const KIND_ALLOWLIST: Record<string, KindSpec> = {
fields: dictionaryFieldsSchema,
binary: true,
},
font: {
minSchemaVersion: 1,
maxSchemaVersion: 1,
maxRowsPerUser: 500,
fields: fontFieldsSchema,
binary: true,
},
};
export const isAllowedKind = (kind: string): boolean => Object.hasOwn(KIND_ALLOWLIST, kind);
+6
View File
@@ -86,6 +86,8 @@ export const uploadReplicaFile = async (
file: File,
fileFullPath: string,
cfp: string,
replicaKind: string,
replicaId: string,
onProgress?: ProgressHandler,
) => {
try {
@@ -97,6 +99,8 @@ export const uploadReplicaFile = async (
body: JSON.stringify({
fileName: cfp,
fileSize: file.size,
replicaKind,
replicaId,
temp: false,
}),
});
@@ -261,6 +265,8 @@ export interface FileRecord {
file_key: string;
file_size: number;
book_hash: string | null;
replica_kind: string | null;
replica_id: string | null;
created_at: string;
updated_at: string | null;
}
+30 -20
View File
@@ -7,6 +7,8 @@ interface FileRecord {
file_key: string;
file_size: number;
book_hash: string | null;
replica_kind: string | null;
replica_id: string | null;
created_at: string;
updated_at: string | null;
}
@@ -51,7 +53,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
let query = supabase
.from('files')
.select('file_key, file_size, book_hash, created_at, updated_at', { count: 'exact' })
.select('file_key, file_size, book_hash, replica_kind, replica_id, created_at, updated_at', {
count: 'exact',
})
.eq('user_id', user.id)
.is('deleted_at', null);
@@ -81,31 +85,37 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const total = count || 0;
const totalPages = Math.ceil(total / pageSize);
// Get all book_hashes from the paginated results
// Pull every file that shares a group with the paginated results so
// groups (book or replica) appear complete in the UI — covers, mdds,
// etc. that wouldn't match a search filter still ride along.
// IMPORTANT: We don't apply the search filter here.
const bookHashes = Array.from(
new Set((files || []).map((f) => f.book_hash).filter((hash): hash is string => !!hash)),
);
// Fetch all files with the same book_hashes to ensure complete book groups
// IMPORTANT: We don't apply the search filter here. This ensures that ALL files
// for matched books are included (e.g., cover.png files), even if they don't
// match the search term. This is crucial for proper book grouping and selection.
const replicaIds = Array.from(
new Set((files || []).map((f) => f.replica_id).filter((id): id is string => !!id)),
);
let allRelatedFiles = files || [];
if (bookHashes.length > 0) {
const relatedQuery = supabase
.from('files')
.select('file_key, file_size, book_hash, created_at, updated_at')
.eq('user_id', user.id)
.is('deleted_at', null)
.in('book_hash', bookHashes);
if (bookHashes.length > 0 || replicaIds.length > 0) {
const baseQuery = () =>
supabase
.from('files')
.select(
'file_key, file_size, book_hash, replica_kind, replica_id, created_at, updated_at',
)
.eq('user_id', user.id)
.is('deleted_at', null);
const { data: relatedFiles, error: relatedError } = await relatedQuery;
if (!relatedError && relatedFiles) {
const fileMap = new Map(allRelatedFiles.map((f) => [f.file_key, f]));
relatedFiles.forEach((f) => fileMap.set(f.file_key, f));
allRelatedFiles = Array.from(fileMap.values());
const fileMap = new Map(allRelatedFiles.map((f) => [f.file_key, f]));
if (bookHashes.length > 0) {
const { data, error } = await baseQuery().in('book_hash', bookHashes);
if (!error && data) data.forEach((f) => fileMap.set(f.file_key, f));
}
if (replicaIds.length > 0) {
const { data, error } = await baseQuery().in('replica_id', replicaIds);
if (!error && data) data.forEach((f) => fileMap.set(f.file_key, f));
}
allRelatedFiles = Array.from(fileMap.values());
}
const response: ListFilesResponse = {
@@ -21,7 +21,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(403).json({ error: 'Not authenticated' });
}
const { fileName, fileSize, bookHash, temp = false } = req.body;
const { fileName, fileSize, bookHash, replicaKind, replicaId, temp = false } = req.body;
if (temp) {
try {
const datetime = new Date();
@@ -76,7 +76,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
.insert([
{
user_id: user.id,
book_hash: bookHash,
book_hash: bookHash ?? null,
replica_kind: replicaKind ?? null,
replica_id: replicaId ?? null,
file_key: fileKey,
file_size: fileSize,
},
+7 -2
View File
@@ -121,8 +121,13 @@ export abstract class BaseAppService implements AppService {
return await this.fs.openFile(path, base);
}
async copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise<void> {
return await this.fs.copyFile(srcPath, dstPath, base);
async copyFile(
srcPath: string,
srcBase: BaseDir,
dstPath: string,
dstBase: BaseDir,
): Promise<void> {
return await this.fs.copyFile(srcPath, srcBase, dstPath, dstBase);
}
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
+4 -4
View File
@@ -384,13 +384,13 @@ export async function importBook(
if (/\.txt$/i.test(filename)) {
await fs.writeFile(bookFilename, 'Books', fileobj);
} else if (typeof file === 'string' && isContentURI(file)) {
await fs.copyFile(file, bookFilename, 'Books');
await fs.copyFile(file, 'None', bookFilename, 'Books');
} else if (typeof file === 'string' && !isValidURL(file)) {
try {
// try to copy the file directly first in case of large files to avoid memory issues
// on desktop when reading recursively from selected directory the direct copy will fail
// due to permission issues, then fallback to read and write files
await fs.copyFile(file, bookFilename, 'Books');
await fs.copyFile(file, 'None', bookFilename, 'Books');
} catch {
await fs.writeFile(bookFilename, 'Books', await fileobj.arrayBuffer());
}
@@ -649,7 +649,7 @@ export async function exportBook(
fs: FileSystem,
book: Book,
resolveFilePath: (path: string, base: BaseDir) => Promise<string>,
copyFile: (srcPath: string, dstPath: string, base: BaseDir) => Promise<void>,
copyFile: (srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir) => Promise<void>,
saveFile: (
filename: string,
content: ArrayBuffer,
@@ -662,7 +662,7 @@ export async function exportBook(
let filePath = await resolveFilePath(getLocalBookFilename(book), 'Books');
const mimeType = file.type || 'application/octet-stream';
if (getFilename(filePath) !== filename) {
await copyFile(filePath, filename, 'Temp');
await copyFile(filePath, 'None', filename, 'Temp');
filePath = await resolveFilePath(filename, 'Temp');
}
return await saveFile(filename, content, { filePath, mimeType });
@@ -95,7 +95,7 @@ export async function uploadReplicaFileToCloud(
console.log('Uploading replica file:', opts.lfp, 'to', cfp);
const file = await fs.openFile(opts.lfp, opts.base, opts.filename);
const localFullpath = await resolveFilePath(opts.lfp, opts.base);
await uploadReplicaFile(file, localFullpath, cfp, opts.onProgress);
await uploadReplicaFile(file, localFullpath, cfp, opts.kind, opts.replicaId, opts.onProgress);
const f = file as ClosableFile;
if (f && f.close) {
await f.close();
+51 -9
View File
@@ -1,34 +1,76 @@
import { FileSystem } from '@/types/system';
import { getFilename } from '@/utils/path';
import { md5, partialMD5 } from '@/utils/md5';
import { uniqueId } from '@/utils/misc';
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
import { parseFontInfo } from '@/utils/font';
/**
* Build the cross-device content id for a font:
* `md5(partialMD5 ‖ byteSize ‖ filename)`. Same recipe shape as
* dictionary.computeReplicaId keeps the kinds aligned.
*/
export const computeFontContentId = (
partialMd5: string,
byteSize: number,
filename: string,
): string => md5(`${partialMd5}|${byteSize}|${filename}`);
/**
* Import a font into the user's `Fonts` base under a per-font bundle dir
* (`<bundleDir>/<filename>`). The bundle dir is a fresh `uniqueId()`
* matches the dictionary import pattern. Returns a CustomFontInfo with
* the relative path, contentId, bundleDir, and byteSize populated so
* the store can publish the replica row immediately.
*/
export async function importFont(
fs: FileSystem,
file?: string | File,
): Promise<CustomFontInfo | null> {
let fontPath: string;
let fontFile: File;
const bundleDir = uniqueId();
let filename: string;
let bytes: ArrayBuffer;
if (typeof file === 'string') {
const filePath = file;
const fileobj = await fs.openFile(filePath, 'None');
fontPath = fileobj.name || getFilename(filePath);
await fs.copyFile(filePath, fontPath, 'Fonts');
fontFile = await fs.openFile(fontPath, 'Fonts');
filename = fileobj.name || getFilename(filePath);
bytes = await fileobj.arrayBuffer();
} else if (file) {
fontPath = getFilename(file.name);
await fs.writeFile(fontPath, 'Fonts', file);
fontFile = file;
filename = getFilename(file.name);
bytes = await file.arrayBuffer();
} else {
return null;
}
const fontPath = `${bundleDir}/${filename}`;
await fs.createDir(bundleDir, 'Fonts', true);
await fs.writeFile(fontPath, 'Fonts', bytes);
const fontFile = await fs.openFile(fontPath, 'Fonts');
const partialMd5 = await partialMD5(fontFile);
const byteSize = bytes.byteLength;
const contentId = computeFontContentId(partialMd5, byteSize, filename);
return {
path: fontPath,
...parseFontInfo(await fontFile.arrayBuffer(), fontPath),
bundleDir,
contentId,
byteSize,
...parseFontInfo(bytes, filename),
};
}
export async function deleteFont(fs: FileSystem, font: CustomFont): Promise<void> {
await fs.removeFile(font.path, 'Fonts');
// Also remove the per-font bundle dir if it's now empty. Legacy fonts
// without bundleDir live at the flat `Fonts/<filename>` path; nothing
// extra to clean up there.
if (font.bundleDir) {
try {
await fs.removeDir(font.bundleDir, 'Fonts', true);
} catch (err) {
console.warn('Failed to remove font bundleDir', font.bundleDir, err);
}
}
}
@@ -11,7 +11,7 @@ export async function importImage(
const filePath = file;
const fileobj = await fs.openFile(filePath, 'None');
imagePath = fileobj.name || getFilename(filePath);
await fs.copyFile(filePath, imagePath, 'Images');
await fs.copyFile(filePath, 'None', imagePath, 'Images');
} else if (file) {
imagePath = getFilename(file.name);
await fs.writeFile(imagePath, 'Images', file);
@@ -259,16 +259,16 @@ export const nativeFileSystem: FileSystem = {
}
}
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
async copyFile(srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir) {
try {
if (!(await this.exists(getDirPath(dstPath), base))) {
await this.createDir(getDirPath(dstPath), base, true);
if (!(await this.exists(getDirPath(dstPath), dstBase))) {
await this.createDir(getDirPath(dstPath), dstBase, true);
}
} catch (error) {
console.log('Failed to create directory for copying file:', error);
}
if (isContentURI(srcPath)) {
const prefix = await this.getPrefix(base);
const prefix = await this.getPrefix(dstBase);
if (!prefix) {
throw new Error('Invalid base directory');
}
@@ -281,8 +281,12 @@ export const nativeFileSystem: FileSystem = {
throw new Error('Failed to copy file');
}
} else {
const { fp, baseDir } = this.resolvePath(dstPath, base);
await copyFile(srcPath, fp, baseDir ? { toPathBaseDir: baseDir } : undefined);
const { fp: srcFp, baseDir: srcBaseDir } = this.resolvePath(srcPath, srcBase);
const { fp: dstFp, baseDir: dstBaseDir } = this.resolvePath(dstPath, dstBase);
const opts: { fromPathBaseDir?: number; toPathBaseDir?: number } = {};
if (srcBaseDir) opts.fromPathBaseDir = srcBaseDir;
if (dstBaseDir) opts.toPathBaseDir = dstBaseDir;
await copyFile(srcFp, dstFp, Object.keys(opts).length > 0 ? opts : undefined);
}
},
async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') {
@@ -236,10 +236,16 @@ export const nodeFileSystem: FileSystem = {
return new File([buffer], fileName);
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise<void> {
const fullDst = await toAbsolute(this.resolvePath(dstPath, base));
async copyFile(
srcPath: string,
srcBase: BaseDir,
dstPath: string,
dstBase: BaseDir,
): Promise<void> {
const fullSrc = await toAbsolute(this.resolvePath(srcPath, srcBase));
const fullDst = await toAbsolute(this.resolvePath(dstPath, dstBase));
await fsp.mkdir(nodePath.dirname(fullDst), { recursive: true });
await fsp.copyFile(srcPath, fullDst);
await fsp.copyFile(fullSrc, fullDst);
},
async readFile(
@@ -79,7 +79,7 @@ async function downloadAndImport(
const probedFilename = await probeFilename(responseHeaders);
if (probedFilename) {
const newFilePath = await appService.resolveFilePath(probedFilename, 'Cache');
await appService.copyFile(dstFilePath, newFilePath, 'None');
await appService.copyFile(dstFilePath, 'None', newFilePath, 'None');
await appService.deleteFile(dstFilePath, 'None');
dstFilePath = newFilePath;
}
@@ -1,6 +1,8 @@
import { md5 } from '@/utils/md5';
import { buildLocalDictFromRow } from '@/services/sync/replicaDictionaryApply';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
import type { ReplicaRow } from '@/types/replica';
export const DICTIONARY_KIND = 'dictionary';
export const DICTIONARY_SCHEMA_VERSION = 1;
@@ -108,6 +110,10 @@ export const dictionaryAdapter: ReplicaAdapter<ImportedDictionary> = {
return d.id;
},
unpackRow(row: ReplicaRow, bundleDir: string): ImportedDictionary | null {
return buildLocalDictFromRow(row, bundleDir);
},
binary: {
localBaseDir: 'Dictionaries',
enumerateFiles: enumerateDictionaryFiles,
@@ -0,0 +1,129 @@
import { computeFontContentId } from '@/services/fontService';
import type { CustomFont } from '@/styles/fonts';
import type { ReplicaAdapter } from '@/services/sync/replicaRegistry';
import type { FieldEnvelope, FieldsObject, ReplicaRow } from '@/types/replica';
export const FONT_KIND = 'font';
export const FONT_SCHEMA_VERSION = 1;
const unwrap = (env: FieldEnvelope | undefined): unknown =>
env && typeof env === 'object' && 'v' in env ? (env as FieldEnvelope).v : undefined;
interface UnwrappedFontFields {
name?: string;
family?: string;
style?: string;
weight?: number;
variable?: boolean;
byteSize?: number;
downloadedAt?: number;
}
const unwrapFontFields = (fields: FieldsObject): UnwrappedFontFields => {
const name = unwrap(fields['name']);
const family = unwrap(fields['family']);
const style = unwrap(fields['style']);
const weight = unwrap(fields['weight']);
const variable = unwrap(fields['variable']);
const byteSize = unwrap(fields['byteSize']);
const downloadedAt = unwrap(fields['downloadedAt']);
return {
name: typeof name === 'string' ? name : undefined,
family: typeof family === 'string' ? family : undefined,
style: typeof style === 'string' ? style : undefined,
weight: typeof weight === 'number' ? weight : undefined,
variable: variable === true ? true : undefined,
byteSize: typeof byteSize === 'number' ? byteSize : undefined,
downloadedAt: typeof downloadedAt === 'number' ? downloadedAt : undefined,
};
};
const filenameFromManifest = (row: ReplicaRow): string | null => {
// Font kind is single-file; the manifest carries exactly one entry.
const f = row.manifest_jsonb?.files[0];
return f?.filename ?? null;
};
export { computeFontContentId };
export const fontAdapter: ReplicaAdapter<CustomFont> = {
kind: FONT_KIND,
schemaVersion: FONT_SCHEMA_VERSION,
pack(font: CustomFont): Record<string, unknown> {
const fields: Record<string, unknown> = {
name: font.name,
downloadedAt: font.downloadedAt ?? Date.now(),
};
if (font.family !== undefined) fields['family'] = font.family;
if (font.style !== undefined) fields['style'] = font.style;
if (font.weight !== undefined) fields['weight'] = font.weight;
if (font.variable !== undefined) fields['variable'] = font.variable;
if (font.byteSize !== undefined) fields['byteSize'] = font.byteSize;
return fields;
},
unpack(fields: Record<string, unknown>): CustomFont {
return {
id: '',
name: String(fields['name'] ?? ''),
path: '',
family: fields['family'] !== undefined ? String(fields['family']) : undefined,
style: fields['style'] !== undefined ? String(fields['style']) : undefined,
weight: fields['weight'] !== undefined ? Number(fields['weight']) : undefined,
variable: fields['variable'] === true ? true : undefined,
byteSize: fields['byteSize'] !== undefined ? Number(fields['byteSize']) : undefined,
downloadedAt:
fields['downloadedAt'] !== undefined ? Number(fields['downloadedAt']) : undefined,
};
},
async computeId(f: CustomFont): Promise<string> {
return f.contentId ?? f.id;
},
unpackRow(row: ReplicaRow, bundleDir: string): CustomFont | null {
const fields = unwrapFontFields(row.fields_jsonb);
if (!fields.name) return null;
const filename = filenameFromManifest(row);
if (!filename) {
// No manifest yet — placeholder with empty path; the manifest
// commit on the publishing device will fill this in on the next
// pull. Returning null skips the row for now (orchestrator
// tolerates re-pulling the same row later).
return null;
}
const font: CustomFont = {
id: bundleDir,
contentId: row.replica_id,
name: fields.name,
bundleDir,
path: `${bundleDir}/${filename}`,
unavailable: true,
};
if (fields.family !== undefined) font.family = fields.family;
if (fields.style !== undefined) font.style = fields.style;
if (fields.weight !== undefined) font.weight = fields.weight;
if (fields.variable !== undefined) font.variable = fields.variable;
if (fields.byteSize !== undefined) font.byteSize = fields.byteSize;
if (fields.downloadedAt !== undefined) font.downloadedAt = fields.downloadedAt;
if (row.reincarnation) font.reincarnation = row.reincarnation;
return font;
},
binary: {
localBaseDir: 'Fonts',
enumerateFiles: (font: CustomFont) => {
// Fonts are single-file. The font's `path` is the lfp, relative
// to the Fonts base dir.
const filename = font.path.split('/').pop() ?? font.path;
return [
{
logical: filename,
lfp: font.path,
byteSize: font.byteSize ?? 0,
},
];
},
},
};
@@ -1,17 +1,21 @@
import { transferManager } from '@/services/transferManager';
import { enumerateDictionaryFiles } from './adapters/dictionary';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { AppService } from '@/types/system';
import { getReplicaAdapter } from './replicaRegistry';
import type { AppService, BaseDir } from '@/types/system';
import type { ReplicaTransferFile } from '@/store/transferStore';
import type { ClosableFile } from '@/utils/file';
/**
* Resolve the on-disk byteSize for one bundle file. Mirrors the
* bookService.getBookFileSize pattern: openFile + .size + close.
* On Tauri, openFile streams metadata; .size doesn't read the body.
* Open the file just to read its `.size` (Tauri streams metadata; the
* body isn't read). Used as a fallback when the adapter's
* enumerateFiles doesn't carry a byteSize e.g. the dictionary
* adapter, which doesn't track per-file sizes on its records.
*/
const resolveByteSize = async (appService: AppService, lfp: string): Promise<number> => {
const file = await appService.openFile(lfp, 'Dictionaries');
const resolveByteSize = async (
appService: AppService,
lfp: string,
base: BaseDir,
): Promise<number> => {
const file = await appService.openFile(lfp, base);
const size = file.size;
const closable = file as ClosableFile;
if (closable && closable.close) {
@@ -20,42 +24,64 @@ const resolveByteSize = async (appService: AppService, lfp: string): Promise<num
return size;
};
interface ReplicaBinaryRecord {
contentId?: string;
name: string;
reincarnation?: string;
}
/**
* Queue a dictionary's binary files for upload via TransferManager.
* Reads each file's byteSize once up-front so progress reporting is
* accurate, then dispatches to the existing replica-transfer pipeline.
* Queue a replica's binary files for upload via TransferManager.
* Generic across kinds uses the adapter registry to dispatch:
* - adapter.binary.enumerateFiles to list the per-record files
* - adapter.binary.localBaseDir for the upload base
*
* Resolves missing byteSize entries via openFile so progress reporting
* is accurate. The TransferManager fires `replica-transfer-complete`
* on success; replicaTransferIntegration converts that into a
* publishReplicaManifest call (binaries first, manifest last).
*
* No-op when:
* - the dictionary lacks contentId (legacy bundle, needs rehash)
* - the record lacks contentId (legacy / unsynced)
* - the kind isn't registered or has no binary capability
* - TransferManager isn't initialized yet (pre-library mount)
*
* Caller is responsible for ordering this AFTER publishDictionaryUpsert
* Caller is responsible for ordering this AFTER publishReplicaUpsert
* so the metadata row exists before the manifest commit fires.
*/
export const queueDictionaryBinaryUpload = async (
dict: ImportedDictionary,
export const queueReplicaBinaryUpload = async <T extends ReplicaBinaryRecord>(
kind: string,
record: T,
appService: AppService,
): Promise<string | null> => {
if (!dict.contentId) return null;
if (!record.contentId) return null;
if (!transferManager.isReady()) return null;
const enumerated = enumerateDictionaryFiles(dict);
const adapter = getReplicaAdapter<T>(kind);
if (!adapter?.binary) return null;
const base = adapter.binary.localBaseDir;
const enumerated = adapter.binary.enumerateFiles(record);
if (enumerated.length === 0) return null;
const files: ReplicaTransferFile[] = await Promise.all(
enumerated.map(async (f) => ({
logical: f.logical,
lfp: f.lfp,
byteSize: await resolveByteSize(appService, f.lfp),
byteSize: f.byteSize > 0 ? f.byteSize : await resolveByteSize(appService, f.lfp, base),
})),
);
return transferManager.queueReplicaUpload(
'dictionary',
dict.contentId,
dict.name,
files,
'Dictionaries',
{ reincarnation: dict.reincarnation },
);
return transferManager.queueReplicaUpload(kind, record.contentId, record.name, files, base, {
reincarnation: record.reincarnation,
});
};
/**
* Backwards-compatible alias for the dictionary-specific helper that
* call sites used before the kind-agnostic refactor.
*/
export const queueDictionaryBinaryUpload = <T extends ReplicaBinaryRecord>(
dict: T,
appService: AppService,
): Promise<string | null> => queueReplicaBinaryUpload('dictionary', dict, appService);
@@ -1,9 +1,15 @@
import { dictionaryAdapter } from './adapters/dictionary';
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { useCustomFontStore } from '@/store/customFontStore';
import { dictionaryAdapter, DICTIONARY_KIND } from './adapters/dictionary';
import { fontAdapter, FONT_KIND } from './adapters/font';
import { getReplicaPersistEnv } from './replicaPersist';
import { getReplicaAdapter, registerReplicaAdapter } from './replicaRegistry';
import { registerReplicaDownloadHandler } from './replicaTransferIntegration';
import type { ReplicaAdapter } from './replicaRegistry';
const KNOWN_ADAPTERS: ReplicaAdapter<unknown>[] = [
dictionaryAdapter as unknown as ReplicaAdapter<unknown>,
fontAdapter as unknown as ReplicaAdapter<unknown>,
];
let didBootstrap = false;
@@ -14,6 +20,27 @@ export const bootstrapReplicaAdapters = (): void => {
if (getReplicaAdapter(adapter.kind)) continue;
registerReplicaAdapter(adapter);
}
// Per-kind download-completion handlers — fired by
// replicaTransferIntegration once binaries are on disk. Each store
// exposes a markAvailable* method that clears the placeholder
// `unavailable` flag set by the pull orchestrator.
registerReplicaDownloadHandler(DICTIONARY_KIND, (replicaId) => {
useCustomDictionaryStore.getState().markAvailableByContentId(replicaId);
});
// Fonts need more than the unavailable flag cleared: the binary must
// be loaded into a blob URL and the @font-face rule injected, the
// same plumbing manual import does in CustomFonts.tsx. Without this
// the auto-downloaded font appears in the UI but renders in a
// fallback face. Falls back to flag-only when persist env hasn't
// landed yet (extremely early boot).
registerReplicaDownloadHandler(FONT_KIND, (replicaId) => {
const env = getReplicaPersistEnv();
if (!env) {
useCustomFontStore.getState().markAvailableByContentId(replicaId);
return;
}
void useCustomFontStore.getState().activateFontByContentId(env, replicaId);
});
didBootstrap = true;
};
@@ -0,0 +1,21 @@
import type { EnvConfigType } from '@/services/environment';
/**
* Replica-side mutators (applyRemote*, softDelete*, markAvailable*)
* fire from the boot-time pull / download-complete handlers, NOT the
* settings UI. UI mutators couple their state writes with an explicit
* saveCustomX(envConfig) call; the replica path has no such pairing,
* so without auto-persist the next loadCustomX would read stale
* settings and wipe the in-memory rows.
*
* EnvProvider registers envConfig once at boot; every replica-aware
* store reads it via getReplicaPersistEnv() inside its replica-side
* mutators and fire-and-forget saves through it.
*/
let replicaPersistEnv: EnvConfigType | null = null;
export const enableReplicaAutoPersist = (envConfig: EnvConfigType | null): void => {
replicaPersistEnv = envConfig;
};
export const getReplicaPersistEnv = (): EnvConfigType | null => replicaPersistEnv;
@@ -1,33 +1,34 @@
import { setField, removeReplica, hlcMax } from '@/libs/crdt';
import { getUserID } from '@/utils/access';
import { getReplicaAdapter } from './replicaRegistry';
import { getReplicaSync } from './replicaSync';
import {
DICTIONARY_KIND,
DICTIONARY_SCHEMA_VERSION,
dictionaryAdapter,
} from './adapters/dictionary';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { FieldsObject, Hlc, ReplicaRow } from '@/types/replica';
/**
* Build + push a CRDT row for an upsert of a single dictionary. Each
* field gets a fresh HLC stamp; updated_at_ts is the max of all field
* stamps. The row is queued via replicaSyncManager.markDirty (5s
* debounced push, immediate flush on visibilitychange / online).
* Build + push a CRDT row for an upsert of a single replica record.
* Each field gets a fresh HLC stamp; updated_at_ts is the max of all
* field stamps. The row is queued via replicaSyncManager.markDirty
* (5s debounced push, immediate flush on visibilitychange / online).
*
* No-op when:
* - replica sync is not initialized (e.g., user signed out)
* - the dictionary lacks contentId (legacy bundle, needs rehash before sync)
* - the kind has no registered adapter
* - the user is not authenticated
*/
export const publishDictionaryUpsert = async (dict: ImportedDictionary): Promise<void> => {
export const publishReplicaUpsert = async <T>(
kind: string,
record: T,
contentId: string,
reincarnation?: string,
): Promise<void> => {
const ctx = getReplicaSync();
if (!ctx) return;
if (!dict.contentId) return;
const adapter = getReplicaAdapter<T>(kind);
if (!adapter) return;
const userId = await getUserID();
if (!userId) return;
const packed = dictionaryAdapter.pack(dict);
const packed = adapter.pack(record);
let fields: FieldsObject = {};
let maxFieldHlc: Hlc | null = null;
for (const [key, value] of Object.entries(packed)) {
@@ -40,77 +41,84 @@ export const publishDictionaryUpsert = async (dict: ImportedDictionary): Promise
const row: ReplicaRow = {
user_id: userId,
kind: DICTIONARY_KIND,
replica_id: dict.contentId,
kind,
replica_id: contentId,
fields_jsonb: fields,
manifest_jsonb: null,
deleted_at_ts: null,
reincarnation: dict.reincarnation ?? null,
reincarnation: reincarnation ?? null,
updated_at_ts: updatedAt,
schema_version: DICTIONARY_SCHEMA_VERSION,
schema_version: adapter.schemaVersion,
};
ctx.manager.markDirty(row);
};
/**
* Tombstone a dictionary row by contentId. The row carries no fields
* just the deleted_at_ts HLC. Per remove-wins semantics, a later field
* write does NOT revive this row; only an explicit reincarnation token
* does.
* Tombstone a replica row by contentId. The row carries no fields
* just the deleted_at_ts HLC. Per remove-wins semantics, a later
* field write does NOT revive this row; only an explicit
* reincarnation token does.
*
* No-op when replica sync isn't initialized or the user isn't authenticated.
* No-op when replica sync isn't initialized or the user isn't
* authenticated.
*/
export const publishDictionaryDelete = async (contentId: string): Promise<void> => {
export const publishReplicaDelete = async (kind: string, contentId: string): Promise<void> => {
const ctx = getReplicaSync();
if (!ctx) return;
const adapter = getReplicaAdapter(kind);
if (!adapter) return;
const userId = await getUserID();
if (!userId) return;
const tombstoneHlc = ctx.hlc.next();
const baseRow: ReplicaRow = {
user_id: userId,
kind: DICTIONARY_KIND,
kind,
replica_id: contentId,
fields_jsonb: {},
manifest_jsonb: null,
deleted_at_ts: null,
reincarnation: null,
updated_at_ts: tombstoneHlc,
schema_version: DICTIONARY_SCHEMA_VERSION,
schema_version: adapter.schemaVersion,
};
ctx.manager.markDirty(removeReplica(baseRow, tombstoneHlc));
};
/**
* Publish a manifest for an existing dictionary row. Called once binary
* Publish a manifest for an existing replica row. Called once binary
* uploads complete (transferManager fires `replica-transfer-complete`).
* The fields_jsonb is empty server-side per-field LWW preserves the
* existing fields; only manifest_jsonb is updated. updated_at_ts = fresh
* HLC so the manifest wins over any prior null value on the row.
* existing fields; only manifest_jsonb is updated. updated_at_ts =
* fresh HLC so the manifest wins over any prior null value on the
* row.
*
* No-ops when sync isn't initialized or the user isn't authenticated.
*/
export const publishDictionaryManifest = async (
export const publishReplicaManifest = async (
kind: string,
contentId: string,
files: { filename: string; byteSize: number; partialMd5: string }[],
reincarnation?: string,
): Promise<void> => {
const ctx = getReplicaSync();
if (!ctx) return;
const adapter = getReplicaAdapter(kind);
if (!adapter) return;
const userId = await getUserID();
if (!userId) return;
const updatedAt = ctx.hlc.next();
const row: ReplicaRow = {
user_id: userId,
kind: DICTIONARY_KIND,
kind,
replica_id: contentId,
fields_jsonb: {},
manifest_jsonb: { files, schemaVersion: 1 },
deleted_at_ts: null,
reincarnation: reincarnation ?? null,
updated_at_ts: updatedAt,
schema_version: DICTIONARY_SCHEMA_VERSION,
schema_version: adapter.schemaVersion,
};
ctx.manager.markDirty(row);
};
@@ -0,0 +1,185 @@
import { isReplicaRowAlive } from '@/libs/replicaInterpret';
import type { ReplicaRow } from '@/types/replica';
import type { ReplicaTransferFile } from '@/store/transferStore';
import type { BaseDir } from '@/types/system';
import type { ReplicaAdapter } from './replicaRegistry';
export interface ReplicaLocalRecord {
/**
* Per-record on-disk directory under the kind's base. Required for
* sync-era records; legacy entries (created before replica sync) may
* have it unset and the orchestrator treats them as non-syncable.
*/
bundleDir?: string;
name: string;
deletedAt?: number;
}
export interface PullAndApplyDeps<T extends ReplicaLocalRecord> {
/** Replica adapter for this kind. Provides unpackRow + binary base dir. */
adapter: ReplicaAdapter<T>;
/** Pulls rows for this kind. Boot caller passes since=null for full sync. */
pull(): Promise<ReplicaRow[]>;
/** Looks up an existing local record by its cross-device contentId. */
findByContentId(contentId: string): T | undefined;
/** Adds a remote-sourced record to the local store WITHOUT republishing. */
applyRemote(record: T): void;
/**
* Tombstones the local entry whose contentId matches. Implementer
* looks up by contentId and removes it from the local store, but
* skips re-publishing the tombstone the row is already tombstoned
* server-side; we just observed that fact.
*/
softDeleteByContentId(contentId: string): void;
/**
* Mints a fresh local bundleDir, creates the directory on disk under
* the kind's base dir, returns the directory name (relative).
*/
createBundleDir(): Promise<string>;
/**
* Hands the manifest's binary files off to TransferManager for
* download. Returns the transfer id (or null if the queue isn't
* ready). Caller arguments mirror transferManager.queueReplicaDownload.
*/
queueReplicaDownload(
contentId: string,
displayTitle: string,
files: ReplicaTransferFile[],
bundleDir: string,
base: BaseDir,
): string | null;
/**
* Returns true iff EVERY filename exists on disk under
* `<bundleDir>/<filename>` in the kind's base dir. Lets the
* orchestrator skip the download queue when the binaries from a
* previous session are still around.
*/
filesExist(bundleDir: string, filenames: string[]): Promise<boolean>;
/**
* Hydrates the local store from disk before the orchestrator queries
* findByContentId. Without this, applyRemote's auto-persist round-
* trip overwrites persisted entries that hadn't yet been pulled into
* the in-memory store by a feature mount.
*/
hydrateLocalStore?(): Promise<void>;
/**
* Reconciliation hook for "server has the row but no manifest, and
* we're the device with the local binaries". The orchestrator
* invokes this when applyRow finds an alive row with empty
* `manifest_jsonb` AND a matching local record. Implementation
* should fan out to the binary-upload pipeline (typically
* `queueReplicaBinaryUpload(kind, record, appService)`), which in
* turn fires `replica-transfer-complete` and commits the manifest.
* Without this, transient upload failures or "TM not ready at
* import time" leave the server row stuck with manifest_jsonb=null
* forever a refresh wouldn't recover it.
*/
queueLocalBinaryUpload?(record: T): Promise<void>;
/**
* Optional auth precheck. When provided and resolves to false, the
* orchestrator skips the entire pull (no network call, no warnings).
*/
isAuthenticated?(): Promise<boolean>;
}
const MANIFEST_FILE_TO_TRANSFER = (
filename: string,
byteSize: number,
bundleDir: string,
): ReplicaTransferFile => ({
logical: filename,
lfp: `${bundleDir}/${filename}`,
byteSize,
});
const applyRow = async <T extends ReplicaLocalRecord>(
row: ReplicaRow,
deps: PullAndApplyDeps<T>,
): Promise<void> => {
const local = deps.findByContentId(row.replica_id);
const alive = isReplicaRowAlive(row);
if (!alive) {
if (local && !local.deletedAt) {
deps.softDeleteByContentId(row.replica_id);
}
return;
}
// Decide bundleDir + display name. If a local entry already maps this
// contentId, reuse its bundleDir so we don't orphan the previously
// downloaded binaries; otherwise mint a fresh dir and apply the remote
// record to the local store. Legacy local records (pre-replica-sync)
// may carry no bundleDir — skip them; they aren't sync-eligible.
let bundleDir: string;
let displayName: string;
if (local) {
if (!local.bundleDir) return;
bundleDir = local.bundleDir;
displayName = deps.adapter.getDisplayName?.(local) ?? local.name;
} else {
bundleDir = await deps.createBundleDir();
const record = deps.adapter.unpackRow(row, bundleDir);
if (!record) return;
deps.applyRemote(record);
displayName = deps.adapter.getDisplayName?.(record) ?? record.name;
}
if (!row.manifest_jsonb || row.manifest_jsonb.files.length === 0) {
// Server row has no manifest yet — typically the device that
// wrote the metadata never finished the binary upload (TM wasn't
// ready, transient failure, app close mid-upload). If we're the
// device with the local copy, push the binaries now so the
// manifest commits via replica-transfer-complete.
if (local && deps.queueLocalBinaryUpload) {
await deps.queueLocalBinaryUpload(local);
}
return;
}
if (!deps.adapter.binary) return;
// Skip the download queue if every manifest file is already on disk
// under the resolved bundle dir. Refresh-the-page is a no-op rather
// than a re-download; partial-download recovery still queues because
// some files would be missing.
const filenames = row.manifest_jsonb.files.map((f) => f.filename);
const allPresent = await deps.filesExist(bundleDir, filenames);
if (allPresent) return;
const files = row.manifest_jsonb.files.map((f) =>
MANIFEST_FILE_TO_TRANSFER(f.filename, f.byteSize, bundleDir),
);
deps.queueReplicaDownload(
row.replica_id,
displayName,
files,
bundleDir,
deps.adapter.binary.localBaseDir,
);
};
/**
* Generic pull-side dispatcher for any replica kind. Walks rows since
* the last cursor advance and applies each via applyRow. Errors per
* row are isolated one bad row never blocks the others.
*
* The dictionary adapter and (future) font / texture adapters share
* this orchestrator; per-kind translation lives entirely in the
* adapter's unpackRow + binary capability.
*/
export const replicaPullAndApply = async <T extends ReplicaLocalRecord>(
deps: PullAndApplyDeps<T>,
): Promise<void> => {
if (deps.isAuthenticated && !(await deps.isAuthenticated())) return;
if (deps.hydrateLocalStore) {
await deps.hydrateLocalStore();
}
const rows = await deps.pull();
for (const row of rows) {
try {
await applyRow(row, deps);
} catch (err) {
console.warn('replica pull row apply failed', { replicaId: row.replica_id, err });
}
}
};
@@ -1,145 +0,0 @@
import { isReplicaRowAlive } from '@/libs/replicaInterpret';
import type { ReplicaRow } from '@/types/replica';
import type { ImportedDictionary } from '@/services/dictionaries/types';
import type { ReplicaTransferFile } from '@/store/transferStore';
import type { BaseDir } from '@/types/system';
import { buildLocalDictFromRow } from './replicaDictionaryApply';
export interface PullDictionariesDeps {
/** Pulls dictionary rows since the last cursor advance. */
pull(): Promise<ReplicaRow[]>;
/** Looks up an existing local dict by its cross-device contentId. */
findByContentId(contentId: string): ImportedDictionary | undefined;
/** Adds a remote-sourced dict to the local store WITHOUT republishing. */
applyRemoteDictionary(dict: ImportedDictionary): void;
/**
* Tombstones the local entry whose contentId matches. Implementer
* looks up by contentId, calls removeDictionary on the local store,
* but skips publishDictionaryDelete (the row is already tombstoned
* server-side; we just observed that fact).
*/
softDeleteByContentId(contentId: string): void;
/**
* Mints a fresh local bundleDir, creates the directory on disk under
* the 'Dictionaries' base dir, returns the directory name (relative).
*/
createBundleDir(): Promise<string>;
/**
* Hands the manifest's binary files off to TransferManager for
* download. Returns the transfer id (or null if the queue isn't
* ready). Caller arguments mirror transferManager.queueReplicaDownload.
*/
queueReplicaDownload(
contentId: string,
displayTitle: string,
files: ReplicaTransferFile[],
bundleDir: string,
base: BaseDir,
): string | null;
/**
* Returns true iff EVERY filename exists on disk under
* `<bundleDir>/<filename>` in the kind's base dir. Lets the
* orchestrator skip the download queue when the binaries from a
* previous session are still around refreshing the page or pulling
* a row whose contentId already maps to a populated bundle is a
* no-op rather than a re-download.
*/
filesExist(bundleDir: string, filenames: string[]): Promise<boolean>;
/**
* Hydrates the persistence-aware local store from disk before the
* orchestrator queries findByContentId. Without this, applyRow's
* subsequent applyRemoteDictionary + auto-persist round-trip
* overwrites settings.customDictionaries with only the rows we just
* applied wiping persisted entries that hadn't yet been pulled
* into the zustand store by a feature mount.
*/
hydrateLocalStore?(): Promise<void>;
/**
* Optional auth precheck. When provided and resolves to false, the
* orchestrator skips the entire pull (no network call, no warnings).
* Lets the boot site avoid spamming "Not authenticated" errors when
* the user is signed out but a prior session left a deviceId behind.
*/
isAuthenticated?(): Promise<boolean>;
}
const MANIFEST_FILE_TO_TRANSFER = (
filename: string,
byteSize: number,
bundleDir: string,
): ReplicaTransferFile => ({
logical: filename,
// Local file path under the 'Dictionaries' base — TransferManager
// resolves it via appService for the actual download IO.
lfp: `${bundleDir}/${filename}`,
byteSize,
});
const applyRow = async (row: ReplicaRow, deps: PullDictionariesDeps): Promise<void> => {
const local = deps.findByContentId(row.replica_id);
const alive = isReplicaRowAlive(row);
if (!alive) {
if (local && !local.deletedAt) {
deps.softDeleteByContentId(row.replica_id);
}
return;
}
// Decide bundleDir + display name. If a local entry already maps this
// contentId, reuse its bundleDir so we don't orphan the previously
// downloaded binaries; otherwise mint a fresh dir and apply the remote
// dict to the local store.
let bundleDir: string;
let displayName: string;
if (local) {
bundleDir = local.bundleDir;
displayName = local.name;
} else {
bundleDir = await deps.createBundleDir();
const dict = buildLocalDictFromRow(row, bundleDir);
if (!dict) return;
deps.applyRemoteDictionary(dict);
displayName = dict.name;
}
if (!row.manifest_jsonb || row.manifest_jsonb.files.length === 0) return;
// Skip the download queue if every manifest file is already on disk
// under the resolved bundle dir. Refresh-the-page is a no-op rather
// than a re-download; partial-download recovery still queues because
// some files would be missing.
const filenames = row.manifest_jsonb.files.map((f) => f.filename);
const allPresent = await deps.filesExist(bundleDir, filenames);
if (allPresent) return;
const files = row.manifest_jsonb.files.map((f) =>
MANIFEST_FILE_TO_TRANSFER(f.filename, f.byteSize, bundleDir),
);
deps.queueReplicaDownload(row.replica_id, displayName, files, bundleDir, 'Dictionaries');
};
/**
* Pull-side dispatcher: walks rows since the last cursor advance and
* applies each via applyRow. Errors per row are isolated one bad
* row never blocks the others.
*/
export const pullDictionariesAndApply = async (deps: PullDictionariesDeps): Promise<void> => {
if (deps.isAuthenticated && !(await deps.isAuthenticated())) return;
// Hydrate the in-memory dict store from disk BEFORE the apply loop.
// applyRemoteDictionary auto-persists the in-memory list back to
// settings, so if the in-memory list isn't already populated, the
// first save would clobber every persisted dict that we hadn't
// re-read into memory.
if (deps.hydrateLocalStore) {
await deps.hydrateLocalStore();
}
const rows = await deps.pull();
for (const row of rows) {
try {
await applyRow(row, deps);
} catch (err) {
console.warn('replica pull row apply failed', { replicaId: row.replica_id, err });
}
}
};
@@ -1,3 +1,4 @@
import type { ReplicaRow } from '@/types/replica';
import type { BaseDir, FileSystem } from '@/types/system';
export interface BinaryCapability<T> {
@@ -16,6 +17,20 @@ export interface ReplicaAdapter<T = unknown> {
pack(replica: T): Record<string, unknown>;
unpack(fields: Record<string, unknown>): T;
computeId(input: T): Promise<string>;
/**
* Build a local placeholder record from a pulled replica row. Used by
* the generic pull orchestrator to dispatch row record translation
* without knowing the kind. Returns null when the row's fields are
* malformed (missing required fields). The bundleDir is the freshly-
* created (or reused) on-disk directory the binaries will land in.
*/
unpackRow(row: ReplicaRow, bundleDir: string): T | null;
/**
* Display label for the orchestrator's transfer-queue title. Defaults
* to the record's `name` field when unset; adapters override only if
* they want a different surface label.
*/
getDisplayName?(record: T): string;
binary?: BinaryCapability<T>;
lifecycle?: LifecycleHooks<T>;
}
@@ -1,8 +1,11 @@
import { HlcGenerator } from '@/libs/crdt';
import { LocalStorageHlcStore, type HlcSnapshotStore } from '@/libs/hlcStore';
import { ReplicaSyncClient } from '@/libs/replicaSyncClient';
import { markSettled, onSettled } from '@/utils/event';
import { ReplicaSyncManager, type CursorStore } from './replicaSyncManager';
const REPLICA_SYNC_READY_EVENT = 'replica-sync-ready';
export interface ReplicaSyncInitOpts {
deviceId: string;
cursorStore: CursorStore;
@@ -18,6 +21,25 @@ export interface ReplicaSyncContext {
let instance: ReplicaSyncContext | null = null;
/**
* Subscribe to be notified when `initReplicaSync` completes. Used by
* useReplicaPull to recover from the boot race where appService
* resolves first and triggers the hook's effect, but
* `await service.loadSettings()` hasn't yet returned, so
* `initReplicaSync` is still pending. The hook reads the singleton
* synchronously and would early-return with no recovery path; the
* subscription gives it a way to retry once the singleton lands.
*
* Listener fires once the singleton is created. If the singleton
* already exists when subscribe is called, the listener is invoked
* synchronously (replay) so callers don't need to deal with the race.
*
* Returns an unsubscribe function. Idempotent; safe to call from
* effect cleanups. Backed by `onSettled('replica-sync-ready')`.
*/
export const subscribeReplicaSyncReady = (listener: () => void): (() => void) =>
onSettled(REPLICA_SYNC_READY_EVENT, () => listener());
const wrapHlcWithPersistence = (hlc: HlcGenerator, hlcStore: HlcSnapshotStore): HlcGenerator => {
const originalNext = hlc.next.bind(hlc);
const originalObserve = hlc.observe.bind(hlc);
@@ -52,6 +74,7 @@ export const initReplicaSync = (opts: ReplicaSyncInitOpts): ReplicaSyncContext =
});
instance = { manager, hlc, deviceId: opts.deviceId };
void markSettled(REPLICA_SYNC_READY_EVENT);
return instance;
};
@@ -1,7 +1,7 @@
import { eventDispatcher } from '@/utils/event';
import { partialMD5 } from '@/utils/md5';
import { useCustomDictionaryStore } from '@/store/customDictionaryStore';
import { publishDictionaryManifest } from './replicaPublish';
import { getReplicaAdapter } from './replicaRegistry';
import { publishReplicaManifest } from './replicaPublish';
import type { AppService } from '@/types/system';
import type { ClosableFile } from '@/utils/file';
import type { ReplicaTransferFile } from '@/store/transferStore';
@@ -15,6 +15,26 @@ interface ReplicaTransferCompleteDetail {
filenames?: string[];
}
type DownloadHandler = (replicaId: string) => void;
const downloadHandlers = new Map<string, DownloadHandler>();
/**
* Per-kind download-completion handler. Called when binary downloads
* for a replica row finish landing on disk. Each store that participates
* in replica sync registers one; the dictionary store clears its
* `unavailable` flag, the font store will activate its FontFace, etc.
*
* Calling twice with the same kind throws defensive against doubly-
* imported store modules during dev hot-reload.
*/
export const registerReplicaDownloadHandler = (kind: string, handler: DownloadHandler): void => {
if (downloadHandlers.has(kind)) {
throw new Error(`Replica download handler for kind="${kind}" is already registered`);
}
downloadHandlers.set(kind, handler);
};
let started = false;
let appServiceRef: AppService | null = null;
let listener: ((event: CustomEvent) => Promise<void>) | null = null;
@@ -22,29 +42,39 @@ let listener: ((event: CustomEvent) => Promise<void>) | null = null;
const handleReplicaUpload = async (detail: ReplicaTransferCompleteDetail): Promise<void> => {
if (!detail.files || detail.files.length === 0) return;
if (!appServiceRef) return;
const adapter = getReplicaAdapter(detail.kind);
if (!adapter?.binary) return;
const base = adapter.binary.localBaseDir;
try {
const manifestFiles = await Promise.all(
detail.files.map(async (f) => {
const file = await appServiceRef!.openFile(f.lfp, 'Dictionaries');
const file = await appServiceRef!.openFile(f.lfp, base);
const partialMd5 = await partialMD5(file);
const closable = file as ClosableFile;
if (closable && closable.close) await closable.close();
return { filename: f.logical, byteSize: f.byteSize, partialMd5 };
}),
);
await publishDictionaryManifest(detail.replicaId, manifestFiles, detail.reincarnation);
await publishReplicaManifest(
detail.kind,
detail.replicaId,
manifestFiles,
detail.reincarnation,
);
} catch (err) {
console.warn('replica-transfer-complete upload handler failed', err);
}
};
const handleReplicaDownload = (detail: ReplicaTransferCompleteDetail): void => {
// The pull orchestrator created the local dict with unavailable=true
// as a placeholder. Now that the binaries are on disk, clear the flag
// so the provider registry surfaces the dict for lookups.
// Per-kind handler clears the `unavailable` placeholder flag now that
// binaries are on disk. Stores register at boot via
// registerReplicaDownloadHandler.
const handler = downloadHandlers.get(detail.kind);
if (!handler) return;
try {
useCustomDictionaryStore.getState().markAvailableByContentId(detail.replicaId);
handler(detail.replicaId);
} catch (err) {
console.warn('replica-transfer-complete download handler failed', err);
}
@@ -53,7 +83,7 @@ const handleReplicaDownload = (detail: ReplicaTransferCompleteDetail): void => {
const handleReplicaTransferComplete = async (event: CustomEvent): Promise<void> => {
const detail = event.detail as ReplicaTransferCompleteDetail | undefined;
if (!detail) return;
if (detail.kind !== 'dictionary') return;
if (!getReplicaAdapter(detail.kind)) return;
if (detail.type === 'upload') {
await handleReplicaUpload(detail);
@@ -70,7 +100,7 @@ const handleReplicaTransferComplete = async (event: CustomEvent): Promise<void>
* after appService boots; idempotent.
*
* Callers that subsequently sign in / out shouldn't re-call this the
* listener doesn't need to know auth state, and publishDictionaryManifest
* listener doesn't need to know auth state, and publishReplicaManifest
* already gates on the user being authenticated.
*/
export const startReplicaTransferIntegration = (appService: AppService): void => {
@@ -88,4 +118,5 @@ export const __resetReplicaTransferIntegrationForTests = (): void => {
}
started = false;
appServiceRef = null;
downloadHandlers.clear();
};
@@ -7,12 +7,11 @@ export interface TransferMessages {
}
/**
* Build per-kind toast copy for a TransferItem. Books and replica kinds
* (currently just `dictionary`; fonts / textures / OPDS catalogs come
* later) get distinct strings so the toast doesn't say "Book uploaded"
* when a dictionary just synced.
*
* Future replica kinds slot into the switch on transfer.replicaKind.
* Build per-kind toast copy for a TransferItem. Books keep their own
* copy ("Book uploaded"); everything else replica kinds (dictionary,
* font, textures, OPDS catalogs, ) and any future transfer kind
* falls through to the generic "File uploaded" string so we don't have
* to add a per-kind branch each time.
*/
export const getTransferMessages = (
transfer: TransferItem,
@@ -20,31 +19,31 @@ export const getTransferMessages = (
): TransferMessages => {
const title = transfer.bookTitle;
if (transfer.kind === 'replica' && transfer.replicaKind === 'dictionary') {
if (transfer.kind === 'book') {
return {
success: {
upload: _('Dictionary uploaded: {{title}}', { title }),
download: _('Dictionary downloaded: {{title}}', { title }),
delete: _('Deleted cloud copy of the dictionary: {{title}}', { title }),
upload: _('Book uploaded: {{title}}', { title }),
download: _('Book downloaded: {{title}}', { title }),
delete: _('Deleted cloud backup of the book: {{title}}', { title }),
},
failure: {
upload: _('Failed to upload dictionary: {{title}}', { title }),
download: _('Failed to download dictionary: {{title}}', { title }),
delete: _('Failed to delete cloud copy of the dictionary: {{title}}', { title }),
upload: _('Failed to upload book: {{title}}', { title }),
download: _('Failed to download book: {{title}}', { title }),
delete: _('Failed to delete cloud backup of the book: {{title}}', { title }),
},
};
}
return {
success: {
upload: _('Book uploaded: {{title}}', { title }),
download: _('Book downloaded: {{title}}', { title }),
delete: _('Deleted cloud backup of the book: {{title}}', { title }),
upload: _('File uploaded: {{title}}', { title }),
download: _('File downloaded: {{title}}', { title }),
delete: _('Deleted cloud copy of the file: {{title}}', { title }),
},
failure: {
upload: _('Failed to upload book: {{title}}', { title }),
download: _('Failed to download book: {{title}}', { title }),
delete: _('Failed to delete cloud backup of the book: {{title}}', { title }),
upload: _('Failed to upload file: {{title}}', { title }),
download: _('Failed to download file: {{title}}', { title }),
delete: _('Failed to delete cloud copy of the file: {{title}}', { title }),
},
};
};
@@ -88,22 +88,23 @@ const indexedDBFileSystem: FileSystem = {
return new File([content], filename || path);
}
},
async copyFile(srcPath: string, dstPath: string, base: BaseDir) {
const { fp } = this.resolvePath(dstPath, base);
async copyFile(srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir) {
const { fp: srcFp } = this.resolvePath(srcPath, srcBase);
const { fp: dstFp } = this.resolvePath(dstPath, dstBase);
const db = await openIndexedDB();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction('files', 'readwrite');
const store = transaction.objectStore('files');
const getRequest = store.get(srcPath);
const getRequest = store.get(srcFp);
getRequest.onsuccess = () => {
const data = getRequest.result;
if (data) {
store.put({ path: fp, content: data.content });
store.put({ path: dstFp, content: data.content });
resolve();
} else {
reject(new Error(`File not found: ${srcPath}`));
reject(new Error(`File not found: ${srcFp}`));
}
};
@@ -7,7 +7,17 @@ import type {
} from '@/services/dictionaries/types';
import { BUILTIN_PROVIDER_IDS, BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types';
import { useSettingsStore } from './settingsStore';
import { publishDictionaryDelete, publishDictionaryUpsert } from '@/services/sync/replicaPublish';
import { publishReplicaDelete, publishReplicaUpsert } from '@/services/sync/replicaPublish';
import { DICTIONARY_KIND } from '@/services/sync/adapters/dictionary';
const publishDictUpsert = (dict: ImportedDictionary): void => {
if (!dict.contentId) return;
void publishReplicaUpsert(DICTIONARY_KIND, dict, dict.contentId, dict.reincarnation);
};
const publishDictDelete = (contentId: string): void => {
void publishReplicaDelete(DICTIONARY_KIND, contentId);
};
/**
* Built-in web-search ids are seeded into `providerOrder` but disabled by
@@ -120,15 +130,12 @@ function toSettingsDict(dict: ImportedDictionary): ImportedDictionary {
// Replica-side mutators (applyRemoteDictionary, softDeleteByContentId,
// markAvailableByContentId) fire from boot-time pull / download-complete
// handlers, NOT the settings UI. The UI couples its state mutations with
// an explicit saveCustomDictionaries(envConfig) call; the replica path
// has no such pairing, so without this auto-persist the next
// loadCustomDictionaries (run on Annotator / settings panel mount) reads
// stale settings.customDictionaries and wipes the in-memory rows.
let replicaPersistEnv: EnvConfigType | null = null;
export const enableReplicaAutoPersist = (envConfig: EnvConfigType | null): void => {
replicaPersistEnv = envConfig;
};
// handlers, NOT the settings UI. The shared `replicaPersist` registry
// holds the envConfig (registered once by EnvProvider); each mutator
// fire-and-forget saves through it so the next loadCustomDictionaries
// reads up-to-date settings.customDictionaries instead of wiping the
// in-memory rows.
import { getReplicaPersistEnv } from '@/services/sync/replicaPersist';
/**
* Look up a dict by its cross-device contentId, falling back to the
@@ -177,7 +184,7 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
settings: { ...state.settings, providerOrder: order, providerEnabled: enabled },
};
});
void publishDictionaryUpsert(dict);
publishDictUpsert(dict);
},
applyRemoteDictionary: (dict) => {
@@ -201,7 +208,8 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
settings: { ...state.settings, providerOrder: order, providerEnabled: enabled },
};
});
if (replicaPersistEnv) void get().saveCustomDictionaries(replicaPersistEnv);
const env = getReplicaPersistEnv();
if (env) void get().saveCustomDictionaries(env);
},
findByContentId: (contentId) =>
@@ -213,7 +221,8 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
d.contentId === contentId ? { ...d, unavailable: undefined } : d,
),
}));
if (replicaPersistEnv) void get().saveCustomDictionaries(replicaPersistEnv);
const env = getReplicaPersistEnv();
if (env) void get().saveCustomDictionaries(env);
},
softDeleteByContentId: (contentId) => {
@@ -231,7 +240,8 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
),
},
}));
if (replicaPersistEnv) void get().saveCustomDictionaries(replicaPersistEnv);
const env = getReplicaPersistEnv();
if (env) void get().saveCustomDictionaries(env);
},
updateDictionary: (id, patch) => {
@@ -249,7 +259,7 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
const dictionaries = state.dictionaries.map((d, i) => (i === idx ? updated! : d));
return { dictionaries };
});
if (updated) void publishDictionaryUpsert(updated);
if (updated) publishDictUpsert(updated);
},
replaceDictionaries: (oldIds, newDict) => {
@@ -315,9 +325,9 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
const isContentSurvivingSwap =
Boolean(newDict.contentId) && oldContentIds.includes(newDict.contentId!);
if (!isContentSurvivingSwap) {
for (const contentId of oldContentIds) void publishDictionaryDelete(contentId);
for (const contentId of oldContentIds) publishDictDelete(contentId);
}
void publishDictionaryUpsert(newDict);
publishDictUpsert(newDict);
},
removeDictionary: (id) => {
@@ -335,7 +345,7 @@ export const useCustomDictionaryStore = create<DictionaryStoreState>((set, get)
),
},
}));
if (dict.contentId) void publishDictionaryDelete(dict.contentId);
if (dict.contentId) publishDictDelete(dict.contentId);
return true;
},
+209 -2
View File
@@ -1,7 +1,29 @@
import { create } from 'zustand';
import { EnvConfigType } from '@/services/environment';
import { CustomFont, createCustomFont, getFontFormat, getMimeType } from '@/styles/fonts';
import {
CustomFont,
createCustomFont,
getFontFormat,
getMimeType,
mountCustomFont,
} from '@/styles/fonts';
import { useSettingsStore } from './settingsStore';
import { getReplicaPersistEnv } from '@/services/sync/replicaPersist';
import { publishReplicaDelete, publishReplicaUpsert } from '@/services/sync/replicaPublish';
import { FONT_KIND } from '@/services/sync/adapters/font';
import { computeFontContentId } from '@/services/fontService';
import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload';
import { partialMD5 } from '@/utils/md5';
import { uniqueId } from '@/utils/misc';
const publishFontUpsert = (font: CustomFont): void => {
if (!font.contentId) return;
void publishReplicaUpsert(FONT_KIND, font, font.contentId, font.reincarnation);
};
const publishFontDelete = (contentId: string): void => {
void publishReplicaDelete(FONT_KIND, contentId);
};
interface FontStoreState {
fonts: CustomFont[];
@@ -16,6 +38,28 @@ interface FontStoreState {
getAvailableFonts: () => CustomFont[];
clearAllFonts: () => void;
/** Look up a local font by its cross-device contentId. */
findByContentId: (contentId: string) => CustomFont | undefined;
/**
* Add a remote-sourced font from a replica pull WITHOUT republishing.
* The placeholder lands with `unavailable: true`; the binary download
* handler clears the flag on completion.
*/
applyRemoteFont: (font: CustomFont) => void;
/** Soft-delete by contentId, skipping the publish call. */
softDeleteByContentId: (contentId: string) => void;
/** Clear the placeholder unavailable flag once binaries land on disk. */
markAvailableByContentId: (contentId: string) => void;
/**
* Full activation path for a remote-pulled font once its binary has
* landed on disk: clear the `unavailable` flag, load the file into a
* blob URL, and inject the `@font-face` rule so the family is
* actually rendered. Manual imports get this for free in
* `CustomFonts.tsx`; auto-download needs the same plumbing or the
* font appears in the UI but renders in a fallback face.
*/
activateFontByContentId: (envConfig: EnvConfigType, contentId: string) => Promise<void>;
loadFont: (envConfig: EnvConfigType, fontId: string) => Promise<CustomFont>;
loadFonts: (envConfig: EnvConfigType, fontIds: string[]) => Promise<CustomFont[]>;
loadAllFonts: (envConfig: EnvConfigType) => Promise<CustomFont[]>;
@@ -57,7 +101,9 @@ export const useCustomFontStore = create<FontStoreState>((set, get) => ({
set((state) => ({
fonts: [...state.fonts],
}));
return existingFont;
const refreshed = get().getFont(font.id) ?? existingFont;
publishFontUpsert(refreshed);
return refreshed;
}
const newFont = {
@@ -69,6 +115,7 @@ export const useCustomFontStore = create<FontStoreState>((set, get) => ({
fonts: [...state.fonts, newFont],
}));
publishFontUpsert(newFont);
return newFont;
},
@@ -89,6 +136,7 @@ export const useCustomFontStore = create<FontStoreState>((set, get) => ({
set((state) => ({
fonts: [...state.fonts],
}));
if (font.contentId) publishFontDelete(font.contentId);
return result;
},
@@ -107,6 +155,61 @@ export const useCustomFontStore = create<FontStoreState>((set, get) => ({
return true;
},
findByContentId: (contentId) =>
contentId ? get().fonts.find((f) => f.contentId === contentId) : undefined,
applyRemoteFont: (font) => {
set((state) => {
const existingIdx = state.fonts.findIndex((f) => f.id === font.id);
const fonts =
existingIdx >= 0
? state.fonts.map((f, i) => (i === existingIdx ? { ...font, deletedAt: undefined } : f))
: [...state.fonts, font];
return { fonts };
});
const env = getReplicaPersistEnv();
if (env) void get().saveCustomFonts(env);
},
softDeleteByContentId: (contentId) => {
const target = get().fonts.find((f) => f.contentId === contentId && !f.deletedAt);
if (!target) return;
set((state) => ({
fonts: state.fonts.map((f) =>
f.id === target.id ? { ...f, deletedAt: Date.now(), blobUrl: undefined, loaded: false } : f,
),
}));
if (target.blobUrl) URL.revokeObjectURL(target.blobUrl);
const env = getReplicaPersistEnv();
if (env) void get().saveCustomFonts(env);
},
markAvailableByContentId: (contentId) => {
set((state) => ({
fonts: state.fonts.map((f) =>
f.contentId === contentId ? { ...f, unavailable: undefined } : f,
),
}));
const env = getReplicaPersistEnv();
if (env) void get().saveCustomFonts(env);
},
activateFontByContentId: async (envConfig, contentId) => {
get().markAvailableByContentId(contentId);
const target = get().fonts.find((f) => f.contentId === contentId && !f.deletedAt);
if (!target) return;
try {
const loaded = await get().loadFont(envConfig, target.id);
if (typeof document !== 'undefined') {
mountCustomFont(document, loaded);
}
const env = getReplicaPersistEnv();
if (env) await get().saveCustomFonts(env);
} catch (err) {
console.warn('activateFontByContentId failed', contentId, err);
}
},
getFont: (id) => {
return get().fonts.find((font) => font.id === id);
},
@@ -269,6 +372,15 @@ export const useCustomFontStore = create<FontStoreState>((set, get) => ({
});
set({ fonts });
await get().loadAllFonts(envConfig);
// Mount @font-face on the main document so settings UI / library
// chrome can render in the actual face. The Reader mounts again
// into book documents on top of this; mountCustomFont keys by
// font id so the second call is an idempotent update.
if (typeof document !== 'undefined') {
for (const font of get().getLoadedFonts()) {
mountCustomFont(document, font);
}
}
}
} catch (error) {
console.error('Failed to load custom fonts settings:', error);
@@ -289,6 +401,101 @@ export const useCustomFontStore = create<FontStoreState>((set, get) => ({
},
}));
/**
* Look up a font by its cross-device contentId, falling back to the
* persisted `settings.customFonts` when the in-memory store is empty.
* The pull-side orchestrator runs at app boot earlier than the font
* panel mount, so loadCustomFonts hasn't hydrated the zustand store
* yet. Without the fallback every refresh would mint a fresh bundleDir
* per row and re-download.
*/
export const findFontByContentId = (contentId: string): CustomFont | undefined => {
if (!contentId) return undefined;
const inMemory = useCustomFontStore.getState().findByContentId(contentId);
if (inMemory) return inMemory;
const persisted = useSettingsStore.getState().settings?.customFonts ?? [];
return persisted.find((f) => f.contentId === contentId && !f.deletedAt);
};
/**
* One-time migration: rehash legacy flat-path fonts (imported before
* replica sync shipped) into the per-bundle layout so they sync
* across devices without forcing the user to re-import.
*
* For each live font with no `contentId`:
* 1. Read bytes from `Fonts/<filename>`.
* 2. Compute partialMD5 + size contentId.
* 3. Mint `bundleDir = uniqueId()`; copy the file to
* `Fonts/<bundleDir>/<filename>` and remove the flat-path one.
* 4. Patch the in-memory record (contentId, bundleDir, byteSize,
* path), persist via saveCustomFonts, then publish through
* publishReplicaUpsert.
*
* Idempotent: a font that already carries `contentId` is skipped.
* If the file is missing on disk, the migration leaves the record
* untouched (loadCustomFonts will mark it `unavailable`). Per-font
* failures are logged and don't block the rest.
*/
export const migrateLegacyFonts = async (envConfig: EnvConfigType): Promise<void> => {
const candidates = useCustomFontStore
.getState()
.fonts.filter((f) => !f.contentId && !f.bundleDir && !f.deletedAt && !f.path.includes('/'));
if (candidates.length === 0) return;
const appService = await envConfig.getAppService();
const migrated: CustomFont[] = [];
for (const legacy of candidates) {
try {
const exists = await appService.exists(legacy.path, 'Fonts');
if (!exists) continue;
const file = await appService.openFile(legacy.path, 'Fonts');
const bytes = await file.arrayBuffer();
const partialMd5 = await partialMD5(file);
const byteSize = bytes.byteLength;
const filename = legacy.path;
const contentId = computeFontContentId(partialMd5, byteSize, filename);
const bundleDir = uniqueId();
const newPath = `${bundleDir}/${filename}`;
await appService.createDir(bundleDir, 'Fonts', true);
await appService.copyFile(legacy.path, 'Fonts', newPath, 'Fonts');
await appService.deleteFile(legacy.path, 'Fonts');
const next: CustomFont = {
...legacy,
contentId,
bundleDir,
byteSize,
path: newPath,
// Force a re-load on next render so the blob URL points at the
// new on-disk path. Loaders observe `loaded=false` + missing
// blobUrl and refresh.
blobUrl: undefined,
loaded: false,
error: undefined,
};
useCustomFontStore.getState().updateFont(legacy.id, next);
migrated.push(next);
} catch (err) {
console.warn('migrateLegacyFonts: failed for', legacy.path, err);
}
}
if (migrated.length === 0) return;
try {
await useCustomFontStore.getState().saveCustomFonts(envConfig);
} catch (err) {
console.warn('migrateLegacyFonts: save failed', err);
}
for (const font of migrated) {
publishFontUpsert(font);
void queueReplicaBinaryUpload('font', font, appService);
}
};
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
const store = useCustomFontStore.getState();
+33 -4
View File
@@ -137,6 +137,33 @@ export interface CustomFont {
weight?: number;
variable?: boolean;
/**
* Cross-device content hash. Set on imports new enough to participate
* in replica sync (`partialMD5 + byteSize + filename`). Legacy fonts
* (created before replica sync) leave this undefined and never publish
* re-import to enable cloud sync.
*/
contentId?: string;
/**
* Per-font directory name relative to the `Fonts` base. New imports
* land at `<bundleDir>/<filename>`; legacy imports keep their flat
* `<filename>` path with bundleDir undefined.
*/
bundleDir?: string;
/** File size in bytes — used by the replica manifest, optional for legacy. */
byteSize?: number;
/**
* On a remote-pulled placeholder, set to true until the binary download
* lands. The transfer-complete handler clears it via the font store's
* markAvailable hook.
*/
unavailable?: boolean;
/**
* Reincarnation token opaque value that revives a tombstoned remote
* row. Mirrors the dictionary mechanism.
*/
reincarnation?: string;
downloadedAt?: number;
deletedAt?: number;
@@ -216,13 +243,15 @@ export function createCustomFont(
options?: Partial<Omit<CustomFont, 'id' | 'path'>>,
): CustomFont {
const name = options?.name || getFontName(path);
// Spread options first so replica-sync fields (contentId, bundleDir,
// byteSize) flow through from the import path. The earlier hand-
// picked field list silently dropped them, leaving font.contentId
// undefined → publishFontUpsert short-circuited on `!contentId` →
// newly imported fonts never published their replica row.
return {
...options,
id: getFontId(name),
name,
family: options?.family,
style: options?.style,
weight: options?.weight,
variable: options?.variable,
path,
};
}
+2 -2
View File
@@ -56,7 +56,7 @@ export interface FileSystem {
getBlobURL(path: string, base: BaseDir): Promise<string>;
getImageURL(path: string): Promise<string>;
openFile(path: string, base: BaseDir, filename?: string): Promise<File>;
copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise<void>;
copyFile(srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir): Promise<void>;
readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise<string | ArrayBuffer>;
writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File): Promise<void>;
removeFile(path: string, base: BaseDir): Promise<void>;
@@ -102,7 +102,7 @@ export interface AppService {
init(): Promise<void>;
openFile(path: string, base: BaseDir): Promise<File>;
copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise<void>;
copyFile(srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir): Promise<void>;
readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise<string | ArrayBuffer>;
writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File): Promise<void>;
createDir(path: string, base: BaseDir, recursive?: boolean): Promise<void>;
+54
View File
@@ -61,3 +61,57 @@ class EventDispatcher {
}
export const eventDispatcher = new EventDispatcher();
// ─── Settled events (one-shot, replay-on-subscribe) ────────────────────
//
// `markSettled` records that a one-shot event has fired and dispatches
// it to currently-subscribed listeners. Subsequent calls with the same
// name are no-ops — the event has already settled.
//
// `onSettled` subscribes a listener that fires exactly once: synchronously
// if the event has already settled (replay), otherwise on the next
// markSettled. The listener auto-unsubscribes after firing. Returns an
// unsubscribe function that cancels the subscription if the event hasn't
// settled yet.
//
// Use case: boot-readiness signals like "replica-sync-ready" or
// "transferManager-ready" where late subscribers must still observe
// that the milestone happened. The plain eventDispatcher fires-and-
// forgets, so a listener that subscribes after dispatch misses it
// forever.
const settledEvents = new Map<string, unknown>();
export const markSettled = async (name: string, detail?: unknown): Promise<void> => {
if (settledEvents.has(name)) return;
settledEvents.set(name, detail);
await eventDispatcher.dispatch(name, detail);
};
export const onSettled = <T = unknown>(
name: string,
listener: (detail: T) => void,
): (() => void) => {
if (settledEvents.has(name)) {
listener(settledEvents.get(name) as T);
return () => {};
}
let invoked = false;
const wrapped = (event: CustomEvent) => {
if (invoked) return;
invoked = true;
eventDispatcher.off(name, wrapped);
listener(event.detail as T);
};
eventDispatcher.on(name, wrapped);
return () => {
if (!invoked) {
eventDispatcher.off(name, wrapped);
}
};
};
/** Test seam — clear the settled-events registry between specs. */
export const __resetSettledEventsForTests = (): void => {
settledEvents.clear();
};
+1 -1
View File
@@ -12,7 +12,7 @@ export const copyFiles = async (appService: AppService, srcDir: string, dstDir:
const file = filesToCopy[i]!;
const srcPath = `${srcDir}/${file.path}`;
const destPath = `${dstDir}/${file.path}`;
await appService.copyFile(srcPath, destPath, 'None');
await appService.copyFile(srcPath, 'None', destPath, 'None');
}
const filesCopied = await appService.readDirectory(dstDir, 'None');
+4 -3
View File
@@ -64,9 +64,10 @@ export const webDownload = async (
const responseHeaders = Object.fromEntries(response.headers.entries());
const contentLength =
response.headers.get('Content-Length') || response.headers.get('X-Content-Length');
if (!contentLength && onProgress)
throw new Error('Cannot track progress: Content-Length missing');
// R2/S3 signed URLs frequently don't expose Content-Length over CORS, so
// missing length is common in the wild. Fall back to indeterminate
// progress (total=0) instead of failing the download. UI callers already
// guard `total === 0` to skip percentage updates.
const totalSize = parseInt(contentLength || '0', 10);
let receivedSize = 0;
const reader = response.body!.getReader();
@@ -0,0 +1,13 @@
-- Migration 006: Extend the replicas.kind allowlist beyond 'dictionary'.
-- Pre-allows the kinds we plan to ship in upcoming client releases so
-- each adapter only needs a coordinated client + server-validation
-- update, not another DB migration. The DB CHECK is belt-and-
-- suspenders; src/libs/replicaSchemas.ts (KIND_ALLOWLIST) is the
-- actual gate that decides which kinds the server accepts on push.
ALTER TABLE public.replicas
DROP CONSTRAINT IF EXISTS replicas_kind_allowlist;
ALTER TABLE public.replicas
ADD CONSTRAINT replicas_kind_allowlist
CHECK (kind IN ('dictionary', 'font', 'texture'));
@@ -0,0 +1,15 @@
-- Migration 007: Per-replica grouping for binaries in `files`.
--
-- Replica-kind binaries (custom dictionary mdx/mdd, font ttf/woff, ...)
-- have no book_hash, so without these columns Storage Manager would
-- lump every replica binary into one "no-book" bucket. Per-replica
-- grouping needs the row to carry the replica's identity alongside
-- file_key/size.
ALTER TABLE public.files
ADD COLUMN IF NOT EXISTS replica_kind text NULL,
ADD COLUMN IF NOT EXISTS replica_id text NULL;
-- Composite filter index for "list / count files by replica row".
CREATE INDEX IF NOT EXISTS idx_files_replica_lookup
ON public.files (user_id, replica_kind, replica_id);