From 981579c255df4a9409d69ff71b751407bc051abc Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 7 May 2026 14:28:44 +0800 Subject: [PATCH] feat(sync): cross-device custom font sync (#4077) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/hooks/useReplicaPull.test.tsx | 59 ++++- .../src/__tests__/libs/replicaSchemas.test.ts | 4 +- .../__tests__/libs/replicaSyncServer.test.ts | 2 +- .../__tests__/services/app-service.test.ts | 4 +- .../services/node-app-service.test.ts | 2 +- .../services/sync/adapters/font.test.ts | 187 ++++++++++++++++ .../services/sync/replicaBinaryUpload.test.ts | 5 + .../services/sync/replicaBootstrap.test.ts | 19 +- .../services/sync/replicaPublish.test.ts | 62 ++--- ...es.test.ts => replicaPullAndApply.test.ts} | 129 +++++++---- .../services/sync/replicaRegistry.test.ts | 1 + .../services/sync/replicaSync.test.ts | 2 + .../sync/replicaTransferIntegration.test.ts | 110 ++++++--- .../services/transferMessages.test.ts | 24 +- .../store/custom-dictionary-store.test.ts | 29 ++- .../__tests__/store/custom-font-store.test.ts | 154 ++++++++++++- .../src/__tests__/styles/fonts.test.ts | 16 ++ .../src/__tests__/utils/event.test.ts | 95 +++++++- .../src/__tests__/utils/transfer.test.ts | 94 ++++++++ .../library/components/MigrateDataWindow.tsx | 2 +- apps/readest-app/src/app/library/page.tsx | 8 +- apps/readest-app/src/app/opds/page.tsx | 2 +- .../src/app/reader/components/Reader.tsx | 11 +- .../app/reader/hooks/useAutoSaveBookCover.ts | 3 +- .../app/user/components/StorageManager.tsx | 38 +++- .../src/components/settings/CustomFonts.tsx | 5 + apps/readest-app/src/context/EnvContext.tsx | 2 +- apps/readest-app/src/hooks/useReplicaPull.ts | 140 ++++++++---- apps/readest-app/src/libs/replicaSchemas.ts | 19 ++ apps/readest-app/src/libs/storage.ts | 6 + .../readest-app/src/pages/api/storage/list.ts | 50 +++-- .../src/pages/api/storage/upload.ts | 6 +- apps/readest-app/src/services/appService.ts | 9 +- apps/readest-app/src/services/bookService.ts | 8 +- apps/readest-app/src/services/cloudService.ts | 2 +- apps/readest-app/src/services/fontService.ts | 60 ++++- apps/readest-app/src/services/imageService.ts | 2 +- .../src/services/nativeAppService.ts | 16 +- .../src/services/nodeAppService.ts | 12 +- .../src/services/opds/autoDownload.ts | 2 +- .../src/services/sync/adapters/dictionary.ts | 6 + .../src/services/sync/adapters/font.ts | 129 +++++++++++ .../src/services/sync/replicaBinaryUpload.ts | 78 ++++--- .../src/services/sync/replicaBootstrap.ts | 29 ++- .../src/services/sync/replicaPersist.ts | 21 ++ .../src/services/sync/replicaPublish.ts | 72 +++--- .../src/services/sync/replicaPullAndApply.ts | 185 +++++++++++++++ .../services/sync/replicaPullDictionaries.ts | 145 ------------ .../src/services/sync/replicaRegistry.ts | 15 ++ .../src/services/sync/replicaSync.ts | 23 ++ .../sync/replicaTransferIntegration.ts | 51 ++++- .../src/services/transferMessages.ts | 37 ++- .../readest-app/src/services/webAppService.ts | 11 +- .../src/store/customDictionaryStore.ts | 46 ++-- apps/readest-app/src/store/customFontStore.ts | 211 +++++++++++++++++- apps/readest-app/src/styles/fonts.ts | 37 ++- apps/readest-app/src/types/system.ts | 4 +- apps/readest-app/src/utils/event.ts | 54 +++++ apps/readest-app/src/utils/files.ts | 2 +- apps/readest-app/src/utils/transfer.ts | 7 +- .../db/migrations/006_replica_more_kinds.sql | 13 ++ .../migrations/007_files_replica_grouping.sql | 15 ++ 62 files changed, 2066 insertions(+), 526 deletions(-) create mode 100644 apps/readest-app/src/__tests__/services/sync/adapters/font.test.ts rename apps/readest-app/src/__tests__/services/sync/{replicaPullDictionaries.test.ts => replicaPullAndApply.test.ts} (71%) create mode 100644 apps/readest-app/src/__tests__/utils/transfer.test.ts create mode 100644 apps/readest-app/src/services/sync/adapters/font.ts create mode 100644 apps/readest-app/src/services/sync/replicaPersist.ts create mode 100644 apps/readest-app/src/services/sync/replicaPullAndApply.ts delete mode 100644 apps/readest-app/src/services/sync/replicaPullDictionaries.ts create mode 100644 docker/volumes/db/migrations/006_replica_more_kinds.sql create mode 100644 docker/volumes/db/migrations/007_files_replica_grouping.sql diff --git a/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx b/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx index c1051748..d74a0e0f 100644 --- a/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx @@ -3,17 +3,37 @@ import { act, cleanup, renderHook } from '@testing-library/react'; const pullSpy = vi.fn<(...args: unknown[]) => Promise>(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 () => { diff --git a/apps/readest-app/src/__tests__/libs/replicaSchemas.test.ts b/apps/readest-app/src/__tests__/libs/replicaSchemas.test.ts index 777ea871..114e87ce 100644 --- a/apps/readest-app/src/__tests__/libs/replicaSchemas.test.ts +++ b/apps/readest-app/src/__tests__/libs/replicaSchemas.test.ts @@ -28,9 +28,9 @@ const baseRow = (overrides: Partial = {}): 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); }); diff --git a/apps/readest-app/src/__tests__/libs/replicaSyncServer.test.ts b/apps/readest-app/src/__tests__/libs/replicaSyncServer.test.ts index 79e872b4..14264683 100644 --- a/apps/readest-app/src/__tests__/libs/replicaSyncServer.test.ts +++ b/apps/readest-app/src/__tests__/libs/replicaSyncServer.test.ts @@ -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); diff --git a/apps/readest-app/src/__tests__/services/app-service.test.ts b/apps/readest-app/src/__tests__/services/app-service.test.ts index 768e2548..b06a3100 100644 --- a/apps/readest-app/src/__tests__/services/app-service.test.ts +++ b/apps/readest-app/src/__tests__/services/app-service.test.ts @@ -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 () => { diff --git a/apps/readest-app/src/__tests__/services/node-app-service.test.ts b/apps/readest-app/src/__tests__/services/node-app-service.test.ts index e5da4f3b..da14872c 100644 --- a/apps/readest-app/src/__tests__/services/node-app-service.test.ts +++ b/apps/readest-app/src/__tests__/services/node-app-service.test.ts @@ -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'); }); diff --git a/apps/readest-app/src/__tests__/services/sync/adapters/font.test.ts b/apps/readest-app/src/__tests__/services/sync/adapters/font.test.ts new file mode 100644 index 00000000..c4ec0250 --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/adapters/font.test.ts @@ -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 => ({ + 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 => ({ + 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)['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}$/); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaBinaryUpload.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaBinaryUpload.test.ts index dae163a5..6b66682d 100644 --- a/apps/readest-app/src/__tests__/services/sync/replicaBinaryUpload.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/replicaBinaryUpload.test.ts @@ -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 = {}): ImportedDictiona beforeEach(() => { vi.clearAllMocks(); + clearReplicaAdapters(); + registerReplicaAdapter(dictionaryAdapter); }); afterEach(() => { vi.restoreAllMocks(); + clearReplicaAdapters(); }); describe('queueDictionaryBinaryUpload', () => { diff --git a/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts index 63cd9bd8..87302197 100644 --- a/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/replicaBootstrap.test.ts @@ -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']); }); }); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaPublish.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaPublish.test.ts index e92e5776..091d0e1b 100644 --- a/apps/readest-app/src/__tests__/services/sync/replicaPublish.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/replicaPublish.test.ts @@ -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 => + 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).mockReturnValue(null); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).mockResolvedValue(null); - await publishDictionaryUpsert(baseDict()); + await upsertDict(baseDict()); expect(ctx.manager.markDirty).not.toHaveBeenCalled(); }); @@ -83,7 +92,7 @@ describe('publishDictionaryUpsert', () => { (getReplicaSync as ReturnType).mockReturnValue(ctx); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).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).mockReturnValue(null); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).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).mockReturnValue(null); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).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).mockReturnValue(ctx); (getUserID as ReturnType).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([]); }); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaPullDictionaries.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaPullAndApply.test.ts similarity index 71% rename from apps/readest-app/src/__tests__/services/sync/replicaPullDictionaries.test.ts rename to apps/readest-app/src/__tests__/services/sync/replicaPullAndApply.test.ts index 8621a052..78e36291 100644 --- a/apps/readest-app/src/__tests__/services/sync/replicaPullDictionaries.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/replicaPullAndApply.test.ts @@ -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 = {}): 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; 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; + 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; (deps.pull as ReturnType).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; (deps.pull as ReturnType).mockResolvedValue([row]); (deps.findByContentId as ReturnType).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).mockResolvedValue([row]); (deps.findByContentId as ReturnType).mockReturnValue(undefined); - await pullDictionariesAndApply(deps); + await replicaPullAndApply(deps); expect(deps.createBundleDir).toHaveBeenCalledOnce(); - expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce(); - const applied = (deps.applyRemoteDictionary as ReturnType).mock.calls[0]![0]; + expect(deps.applyRemote).toHaveBeenCalledOnce(); + const applied = (deps.applyRemote as ReturnType).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>( + async () => {}, + ); + const deps = { + ...makeDeps(), + queueLocalBinaryUpload, + } satisfies PullAndApplyDeps; + (deps.pull as ReturnType).mockResolvedValue([row]); + (deps.findByContentId as ReturnType).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>( + async () => {}, + ); + const deps = { + ...makeDeps(), + queueLocalBinaryUpload, + } satisfies PullAndApplyDeps; + (deps.pull as ReturnType).mockResolvedValue([row]); + (deps.findByContentId as ReturnType).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).mockResolvedValue([row]); (deps.findByContentId as ReturnType).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).mockReturnValue(local); (deps.filesExist as ReturnType).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).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).mock.calls[0]; // bundleDir is the existing local entry's, NOT a fresh one. @@ -200,9 +245,9 @@ describe('pullDictionariesAndApply', () => { (deps.findByContentId as ReturnType).mockReturnValue(undefined); (deps.filesExist as ReturnType).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).mockResolvedValue([row]); (deps.findByContentId as ReturnType).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).mockResolvedValue([row]); (deps.findByContentId as ReturnType).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).mockResolvedValue([row]); (deps.findByContentId as ReturnType).mockReturnValue(undefined); - await pullDictionariesAndApply(deps); + await replicaPullAndApply(deps); - expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce(); - const applied = (deps.applyRemoteDictionary as ReturnType).mock.calls[0]![0]; + expect(deps.applyRemote).toHaveBeenCalledOnce(); + const applied = (deps.applyRemote as ReturnType).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).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).mockResolvedValue([badRow, goodRow]); (deps.findByContentId as ReturnType).mockReturnValue(undefined); - await pullDictionariesAndApply(deps); + await replicaPullAndApply(deps); - expect(deps.applyRemoteDictionary).toHaveBeenCalledOnce(); + expect(deps.applyRemote).toHaveBeenCalledOnce(); }); }); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaRegistry.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaRegistry.test.ts index b9fb9b86..4c927a1b 100644 --- a/apps/readest-app/src/__tests__/services/sync/replicaRegistry.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/replicaRegistry.test.ts @@ -19,6 +19,7 @@ const dictionaryAdapter: ReplicaAdapter = { 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()); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaSync.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaSync.test.ts index f247f452..91781429 100644 --- a/apps/readest-app/src/__tests__/services/sync/replicaSync.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/replicaSync.test.ts @@ -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', () => { diff --git a/apps/readest-app/src/__tests__/services/sync/replicaTransferIntegration.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaTransferIntegration.test.ts index d56770c8..6fcf6d6a 100644 --- a/apps/readest-app/src/__tests__/services/sync/replicaTransferIntegration.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/replicaTransferIntegration.test.ts @@ -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; +const mockPublish = publishReplicaManifest as ReturnType; +const downloadHandler = vi.fn(); + +const fakeDictionaryAdapter: ReplicaAdapter = { + 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); diff --git a/apps/readest-app/src/__tests__/services/transferMessages.test.ts b/apps/readest-app/src/__tests__/services/transferMessages.test.ts index 9e1ecff3..24674a68 100644 --- a/apps/readest-app/src/__tests__/services/transferMessages.test.ts +++ b/apps/readest-app/src/__tests__/services/transferMessages.test.ts @@ -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'); }); }); diff --git a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts index 9e5cfab4..88fbd0b9 100644 --- a/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts +++ b/apps/readest-app/src/__tests__/store/custom-dictionary-store.test.ts @@ -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'); }); }); diff --git a/apps/readest-app/src/__tests__/store/custom-font-store.test.ts b/apps/readest-app/src/__tests__/store/custom-font-store.test.ts index 72ac2bc1..ab3a1d60 100644 --- a/apps/readest-app/src/__tests__/store/custom-font-store.test.ts +++ b/apps/readest-app/src/__tests__/store/custom-font-store.test.ts @@ -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('@/utils/md5'); + return { + ...actual, + partialMD5: vi.fn(async () => 'partial-md5-stub'), + }; +}); +vi.mock('@/utils/misc', async () => { + const actual = await vi.importActual('@/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 & { id: string; name: string }): CustomFont { return { @@ -386,4 +409,133 @@ describe('customFontStore', () => { expect(savedFonts![0]).not.toHaveProperty('error'); }); }); + + describe('migrateLegacyFonts', () => { + interface FakeAppService { + exists: ReturnType; + openFile: ReturnType; + createDir: ReturnType; + copyFile: ReturnType; + deleteFile: ReturnType; + } + 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(); + }); + }); }); diff --git a/apps/readest-app/src/__tests__/styles/fonts.test.ts b/apps/readest-app/src/__tests__/styles/fonts.test.ts index 466dac83..1c9db8e9 100644 --- a/apps/readest-app/src/__tests__/styles/fonts.test.ts +++ b/apps/readest-app/src/__tests__/styles/fonts.test.ts @@ -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'); diff --git a/apps/readest-app/src/__tests__/utils/event.test.ts b/apps/readest-app/src/__tests__/utils/event.test.ts index 10bdd3af..a85163e6 100644 --- a/apps/readest-app/src/__tests__/utils/event.test.ts +++ b/apps/readest-app/src/__tests__/utils/event.test.ts @@ -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', async () => { + interface BootPayload { + version: number; + } + let captured: BootPayload | null = null; + onSettled('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(); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/transfer.test.ts b/apps/readest-app/src/__tests__/utils/transfer.test.ts new file mode 100644 index 00000000..75b8935c --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/transfer.test.ts @@ -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); + }); +}); diff --git a/apps/readest-app/src/app/library/components/MigrateDataWindow.tsx b/apps/readest-app/src/app/library/components/MigrateDataWindow.tsx index 15632360..cb74a478 100644 --- a/apps/readest-app/src/app/library/components/MigrateDataWindow.tsx +++ b/apps/readest-app/src/app/library/components/MigrateDataWindow.tsx @@ -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 diff --git a/apps/readest-app/src/app/library/page.tsx b/apps/readest-app/src/app/library/page.tsx index ecb3da69..7e123246 100644 --- a/apps/readest-app/src/app/library/page.tsx +++ b/apps/readest-app/src/app/library/page.tsx @@ -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', ); diff --git a/apps/readest-app/src/app/opds/page.tsx b/apps/readest-app/src/app/opds/page.tsx index 97a16675..3c4e6a6c 100644 --- a/apps/readest-app/src/app/opds/page.tsx +++ b/apps/readest-app/src/app/opds/page.tsx @@ -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; diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index 21254c9e..0cb54fe7 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -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); diff --git a/apps/readest-app/src/app/reader/hooks/useAutoSaveBookCover.ts b/apps/readest-app/src/app/reader/hooks/useAutoSaveBookCover.ts index ee33c1b0..e08792fb 100644 --- a/apps/readest-app/src/app/reader/hooks/useAutoSaveBookCover.ts +++ b/apps/readest-app/src/app/reader/hooks/useAutoSaveBookCover.ts @@ -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', ); diff --git a/apps/readest-app/src/app/user/components/StorageManager.tsx b/apps/readest-app/src/app/user/components/StorageManager.tsx index 71453acf..dd5ed5f6 100644 --- a/apps/readest-app/src/app/user/components/StorageManager.tsx +++ b/apps/readest-app/src/app/user/components/StorageManager.tsx @@ -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(); - 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 diff --git a/apps/readest-app/src/components/settings/CustomFonts.tsx b/apps/readest-app/src/components/settings/CustomFonts.tsx index 5e982d52..dd3dbbce 100644 --- a/apps/readest-app/src/components/settings/CustomFonts.tsx +++ b/apps/readest-app/src/components/settings/CustomFonts.tsx @@ -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 = ({ 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); diff --git a/apps/readest-app/src/context/EnvContext.tsx b/apps/readest-app/src/context/EnvContext.tsx index c39579e4..84bf6efc 100644 --- a/apps/readest-app/src/context/EnvContext.tsx +++ b/apps/readest-app/src/context/EnvContext.tsx @@ -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; diff --git a/apps/readest-app/src/hooks/useReplicaPull.ts b/apps/readest-app/src/hooks/useReplicaPull.ts index 6581c35a..144d7900 100644 --- a/apps/readest-app/src/hooks/useReplicaPull.ts +++ b/apps/readest-app/src/hooks/useReplicaPull.ts @@ -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 => ({ + 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 => ({ + 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 | 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]); }; diff --git a/apps/readest-app/src/libs/replicaSchemas.ts b/apps/readest-app/src/libs/replicaSchemas.ts index dd9b20ce..b157a024 100644 --- a/apps/readest-app/src/libs/replicaSchemas.ts +++ b/apps/readest-app/src/libs/replicaSchemas.ts @@ -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 = { 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); diff --git a/apps/readest-app/src/libs/storage.ts b/apps/readest-app/src/libs/storage.ts index 0fea8508..de84b8c4 100644 --- a/apps/readest-app/src/libs/storage.ts +++ b/apps/readest-app/src/libs/storage.ts @@ -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; } diff --git a/apps/readest-app/src/pages/api/storage/list.ts b/apps/readest-app/src/pages/api/storage/list.ts index 36ca3c9a..a4361170 100644 --- a/apps/readest-app/src/pages/api/storage/list.ts +++ b/apps/readest-app/src/pages/api/storage/list.ts @@ -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 = { diff --git a/apps/readest-app/src/pages/api/storage/upload.ts b/apps/readest-app/src/pages/api/storage/upload.ts index 2c55dc93..2fda5f44 100644 --- a/apps/readest-app/src/pages/api/storage/upload.ts +++ b/apps/readest-app/src/pages/api/storage/upload.ts @@ -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, }, diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index 9cb6877a..246e90ab 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -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 { - return await this.fs.copyFile(srcPath, dstPath, base); + async copyFile( + srcPath: string, + srcBase: BaseDir, + dstPath: string, + dstBase: BaseDir, + ): Promise { + return await this.fs.copyFile(srcPath, srcBase, dstPath, dstBase); } async readFile(path: string, base: BaseDir, mode: 'text' | 'binary') { diff --git a/apps/readest-app/src/services/bookService.ts b/apps/readest-app/src/services/bookService.ts index c68762bc..51b51b15 100644 --- a/apps/readest-app/src/services/bookService.ts +++ b/apps/readest-app/src/services/bookService.ts @@ -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, - copyFile: (srcPath: string, dstPath: string, base: BaseDir) => Promise, + copyFile: (srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir) => Promise, 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 }); diff --git a/apps/readest-app/src/services/cloudService.ts b/apps/readest-app/src/services/cloudService.ts index 30b06ec8..bf44fc46 100644 --- a/apps/readest-app/src/services/cloudService.ts +++ b/apps/readest-app/src/services/cloudService.ts @@ -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(); diff --git a/apps/readest-app/src/services/fontService.ts b/apps/readest-app/src/services/fontService.ts index 95389128..9a4d8813 100644 --- a/apps/readest-app/src/services/fontService.ts +++ b/apps/readest-app/src/services/fontService.ts @@ -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 + * (`/`). 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 { - 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 { 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/` 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); + } + } } diff --git a/apps/readest-app/src/services/imageService.ts b/apps/readest-app/src/services/imageService.ts index 998164c1..514b6f3a 100644 --- a/apps/readest-app/src/services/imageService.ts +++ b/apps/readest-app/src/services/imageService.ts @@ -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); diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index d37cd540..e00dbdbb 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -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') { diff --git a/apps/readest-app/src/services/nodeAppService.ts b/apps/readest-app/src/services/nodeAppService.ts index 48d73929..32da3e59 100644 --- a/apps/readest-app/src/services/nodeAppService.ts +++ b/apps/readest-app/src/services/nodeAppService.ts @@ -236,10 +236,16 @@ export const nodeFileSystem: FileSystem = { return new File([buffer], fileName); }, - async copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise { - const fullDst = await toAbsolute(this.resolvePath(dstPath, base)); + async copyFile( + srcPath: string, + srcBase: BaseDir, + dstPath: string, + dstBase: BaseDir, + ): Promise { + 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( diff --git a/apps/readest-app/src/services/opds/autoDownload.ts b/apps/readest-app/src/services/opds/autoDownload.ts index a74900f4..7df684fa 100644 --- a/apps/readest-app/src/services/opds/autoDownload.ts +++ b/apps/readest-app/src/services/opds/autoDownload.ts @@ -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; } diff --git a/apps/readest-app/src/services/sync/adapters/dictionary.ts b/apps/readest-app/src/services/sync/adapters/dictionary.ts index 3d921522..d86d1baa 100644 --- a/apps/readest-app/src/services/sync/adapters/dictionary.ts +++ b/apps/readest-app/src/services/sync/adapters/dictionary.ts @@ -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 = { return d.id; }, + unpackRow(row: ReplicaRow, bundleDir: string): ImportedDictionary | null { + return buildLocalDictFromRow(row, bundleDir); + }, + binary: { localBaseDir: 'Dictionaries', enumerateFiles: enumerateDictionaryFiles, diff --git a/apps/readest-app/src/services/sync/adapters/font.ts b/apps/readest-app/src/services/sync/adapters/font.ts new file mode 100644 index 00000000..28e927b2 --- /dev/null +++ b/apps/readest-app/src/services/sync/adapters/font.ts @@ -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 = { + kind: FONT_KIND, + schemaVersion: FONT_SCHEMA_VERSION, + + pack(font: CustomFont): Record { + const fields: Record = { + 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): 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 { + 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, + }, + ]; + }, + }, +}; diff --git a/apps/readest-app/src/services/sync/replicaBinaryUpload.ts b/apps/readest-app/src/services/sync/replicaBinaryUpload.ts index a518c353..c4375d32 100644 --- a/apps/readest-app/src/services/sync/replicaBinaryUpload.ts +++ b/apps/readest-app/src/services/sync/replicaBinaryUpload.ts @@ -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 => { - const file = await appService.openFile(lfp, 'Dictionaries'); +const resolveByteSize = async ( + appService: AppService, + lfp: string, + base: BaseDir, +): Promise => { + 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( + kind: string, + record: T, appService: AppService, ): Promise => { - if (!dict.contentId) return null; + if (!record.contentId) return null; if (!transferManager.isReady()) return null; - const enumerated = enumerateDictionaryFiles(dict); + const adapter = getReplicaAdapter(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 = ( + dict: T, + appService: AppService, +): Promise => queueReplicaBinaryUpload('dictionary', dict, appService); diff --git a/apps/readest-app/src/services/sync/replicaBootstrap.ts b/apps/readest-app/src/services/sync/replicaBootstrap.ts index b3c4bc31..548e555a 100644 --- a/apps/readest-app/src/services/sync/replicaBootstrap.ts +++ b/apps/readest-app/src/services/sync/replicaBootstrap.ts @@ -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[] = [ dictionaryAdapter as unknown as ReplicaAdapter, + fontAdapter as unknown as ReplicaAdapter, ]; 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; }; diff --git a/apps/readest-app/src/services/sync/replicaPersist.ts b/apps/readest-app/src/services/sync/replicaPersist.ts new file mode 100644 index 00000000..ad17bafc --- /dev/null +++ b/apps/readest-app/src/services/sync/replicaPersist.ts @@ -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; diff --git a/apps/readest-app/src/services/sync/replicaPublish.ts b/apps/readest-app/src/services/sync/replicaPublish.ts index 04858455..9ea0bc81 100644 --- a/apps/readest-app/src/services/sync/replicaPublish.ts +++ b/apps/readest-app/src/services/sync/replicaPublish.ts @@ -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 => { +export const publishReplicaUpsert = async ( + kind: string, + record: T, + contentId: string, + reincarnation?: string, +): Promise => { const ctx = getReplicaSync(); if (!ctx) return; - if (!dict.contentId) return; + const adapter = getReplicaAdapter(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 => { +export const publishReplicaDelete = async (kind: string, contentId: string): Promise => { 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 => { 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); }; diff --git a/apps/readest-app/src/services/sync/replicaPullAndApply.ts b/apps/readest-app/src/services/sync/replicaPullAndApply.ts new file mode 100644 index 00000000..02fd939c --- /dev/null +++ b/apps/readest-app/src/services/sync/replicaPullAndApply.ts @@ -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 { + /** Replica adapter for this kind. Provides unpackRow + binary base dir. */ + adapter: ReplicaAdapter; + /** Pulls rows for this kind. Boot caller passes since=null for full sync. */ + pull(): Promise; + /** 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; + /** + * 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 + * `/` 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; + /** + * 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; + /** + * 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; + /** + * Optional auth precheck. When provided and resolves to false, the + * orchestrator skips the entire pull (no network call, no warnings). + */ + isAuthenticated?(): Promise; +} + +const MANIFEST_FILE_TO_TRANSFER = ( + filename: string, + byteSize: number, + bundleDir: string, +): ReplicaTransferFile => ({ + logical: filename, + lfp: `${bundleDir}/${filename}`, + byteSize, +}); + +const applyRow = async ( + row: ReplicaRow, + deps: PullAndApplyDeps, +): Promise => { + 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 ( + deps: PullAndApplyDeps, +): Promise => { + 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 }); + } + } +}; diff --git a/apps/readest-app/src/services/sync/replicaPullDictionaries.ts b/apps/readest-app/src/services/sync/replicaPullDictionaries.ts deleted file mode 100644 index fb083435..00000000 --- a/apps/readest-app/src/services/sync/replicaPullDictionaries.ts +++ /dev/null @@ -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; - /** 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; - /** - * 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 - * `/` 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; - /** - * 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; - /** - * 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; -} - -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 => { - 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 => { - 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 }); - } - } -}; diff --git a/apps/readest-app/src/services/sync/replicaRegistry.ts b/apps/readest-app/src/services/sync/replicaRegistry.ts index 7af32f61..d3db9701 100644 --- a/apps/readest-app/src/services/sync/replicaRegistry.ts +++ b/apps/readest-app/src/services/sync/replicaRegistry.ts @@ -1,3 +1,4 @@ +import type { ReplicaRow } from '@/types/replica'; import type { BaseDir, FileSystem } from '@/types/system'; export interface BinaryCapability { @@ -16,6 +17,20 @@ export interface ReplicaAdapter { pack(replica: T): Record; unpack(fields: Record): T; computeId(input: T): Promise; + /** + * 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; lifecycle?: LifecycleHooks; } diff --git a/apps/readest-app/src/services/sync/replicaSync.ts b/apps/readest-app/src/services/sync/replicaSync.ts index 8e8c17ed..1536e8ef 100644 --- a/apps/readest-app/src/services/sync/replicaSync.ts +++ b/apps/readest-app/src/services/sync/replicaSync.ts @@ -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; }; diff --git a/apps/readest-app/src/services/sync/replicaTransferIntegration.ts b/apps/readest-app/src/services/sync/replicaTransferIntegration.ts index 6947375e..7349735a 100644 --- a/apps/readest-app/src/services/sync/replicaTransferIntegration.ts +++ b/apps/readest-app/src/services/sync/replicaTransferIntegration.ts @@ -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(); + +/** + * 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) | null = null; @@ -22,29 +42,39 @@ let listener: ((event: CustomEvent) => Promise) | null = null; const handleReplicaUpload = async (detail: ReplicaTransferCompleteDetail): Promise => { 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 => { 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 * 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(); }; diff --git a/apps/readest-app/src/services/transferMessages.ts b/apps/readest-app/src/services/transferMessages.ts index cce9e4de..761374bb 100644 --- a/apps/readest-app/src/services/transferMessages.ts +++ b/apps/readest-app/src/services/transferMessages.ts @@ -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 }), }, }; }; diff --git a/apps/readest-app/src/services/webAppService.ts b/apps/readest-app/src/services/webAppService.ts index c482b023..4d654958 100644 --- a/apps/readest-app/src/services/webAppService.ts +++ b/apps/readest-app/src/services/webAppService.ts @@ -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((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}`)); } }; diff --git a/apps/readest-app/src/store/customDictionaryStore.ts b/apps/readest-app/src/store/customDictionaryStore.ts index 876397b5..931d7e5d 100644 --- a/apps/readest-app/src/store/customDictionaryStore.ts +++ b/apps/readest-app/src/store/customDictionaryStore.ts @@ -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((set, get) settings: { ...state.settings, providerOrder: order, providerEnabled: enabled }, }; }); - void publishDictionaryUpsert(dict); + publishDictUpsert(dict); }, applyRemoteDictionary: (dict) => { @@ -201,7 +208,8 @@ export const useCustomDictionaryStore = create((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((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((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((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((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((set, get) ), }, })); - if (dict.contentId) void publishDictionaryDelete(dict.contentId); + if (dict.contentId) publishDictDelete(dict.contentId); return true; }, diff --git a/apps/readest-app/src/store/customFontStore.ts b/apps/readest-app/src/store/customFontStore.ts index 1bca9c93..1df02b7f 100644 --- a/apps/readest-app/src/store/customFontStore.ts +++ b/apps/readest-app/src/store/customFontStore.ts @@ -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; + loadFont: (envConfig: EnvConfigType, fontId: string) => Promise; loadFonts: (envConfig: EnvConfigType, fontIds: string[]) => Promise; loadAllFonts: (envConfig: EnvConfigType) => Promise; @@ -57,7 +101,9 @@ export const useCustomFontStore = create((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((set, get) => ({ fonts: [...state.fonts, newFont], })); + publishFontUpsert(newFont); return newFont; }, @@ -89,6 +136,7 @@ export const useCustomFontStore = create((set, get) => ({ set((state) => ({ fonts: [...state.fonts], })); + if (font.contentId) publishFontDelete(font.contentId); return result; }, @@ -107,6 +155,61 @@ export const useCustomFontStore = create((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((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((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/`. + * 2. Compute partialMD5 + size → contentId. + * 3. Mint `bundleDir = uniqueId()`; copy the file to + * `Fonts//` 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 => { + 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(); diff --git a/apps/readest-app/src/styles/fonts.ts b/apps/readest-app/src/styles/fonts.ts index 6d82aac6..13139083 100644 --- a/apps/readest-app/src/styles/fonts.ts +++ b/apps/readest-app/src/styles/fonts.ts @@ -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 `/`; legacy imports keep their flat + * `` 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>, ): 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, }; } diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 9228d9f7..09acd12d 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -56,7 +56,7 @@ export interface FileSystem { getBlobURL(path: string, base: BaseDir): Promise; getImageURL(path: string): Promise; openFile(path: string, base: BaseDir, filename?: string): Promise; - copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise; + copyFile(srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir): Promise; readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise; writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File): Promise; removeFile(path: string, base: BaseDir): Promise; @@ -102,7 +102,7 @@ export interface AppService { init(): Promise; openFile(path: string, base: BaseDir): Promise; - copyFile(srcPath: string, dstPath: string, base: BaseDir): Promise; + copyFile(srcPath: string, srcBase: BaseDir, dstPath: string, dstBase: BaseDir): Promise; readFile(path: string, base: BaseDir, mode: 'text' | 'binary'): Promise; writeFile(path: string, base: BaseDir, content: string | ArrayBuffer | File): Promise; createDir(path: string, base: BaseDir, recursive?: boolean): Promise; diff --git a/apps/readest-app/src/utils/event.ts b/apps/readest-app/src/utils/event.ts index 57ca12b8..882abb0b 100644 --- a/apps/readest-app/src/utils/event.ts +++ b/apps/readest-app/src/utils/event.ts @@ -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(); + +export const markSettled = async (name: string, detail?: unknown): Promise => { + if (settledEvents.has(name)) return; + settledEvents.set(name, detail); + await eventDispatcher.dispatch(name, detail); +}; + +export const onSettled = ( + 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(); +}; diff --git a/apps/readest-app/src/utils/files.ts b/apps/readest-app/src/utils/files.ts index 23b2fc3d..1d29de31 100644 --- a/apps/readest-app/src/utils/files.ts +++ b/apps/readest-app/src/utils/files.ts @@ -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'); diff --git a/apps/readest-app/src/utils/transfer.ts b/apps/readest-app/src/utils/transfer.ts index ac5124b9..a3fb0eb0 100644 --- a/apps/readest-app/src/utils/transfer.ts +++ b/apps/readest-app/src/utils/transfer.ts @@ -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(); diff --git a/docker/volumes/db/migrations/006_replica_more_kinds.sql b/docker/volumes/db/migrations/006_replica_more_kinds.sql new file mode 100644 index 00000000..056e0b42 --- /dev/null +++ b/docker/volumes/db/migrations/006_replica_more_kinds.sql @@ -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')); diff --git a/docker/volumes/db/migrations/007_files_replica_grouping.sql b/docker/volumes/db/migrations/007_files_replica_grouping.sql new file mode 100644 index 00000000..cf389ab1 --- /dev/null +++ b/docker/volumes/db/migrations/007_files_replica_grouping.sql @@ -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);