forked from akai/readest
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:
@@ -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([]);
|
||||
});
|
||||
|
||||
+87
-42
@@ -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
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user