From 4625e47a6d01a1776c45bea6f04a9c3e0e03b680 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 7 May 2026 16:19:23 +0800 Subject: [PATCH] refactor(sync): extract shared adapter / pull-deps / legacy-migration primitives (#4081) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three kinds (dictionary, font, texture) of replica sync now visibly duplicate each other; this PR extracts the shared shape now that the abstraction is well-validated, per the plan's "extract only after the second kind validates" guidance. - New `services/sync/adapters/_helpers.ts` — `unwrap` field-envelope, `singleFileFilenameFromManifest`, `singleFileBinaryEnumerator`, and a default `computeId` for kinds with `record.contentId`. Wired into font + texture adapters (dictionary stays bespoke — multi-file enumeration and a different identity recipe). - `useReplicaPull.ts` collapses the three near-identical `buildXPullDeps` (~80 lines each) into a single `buildReplicaPullDeps` factory plus three small `ReplicaPullConfig` records (~12 lines each). Dispatch stays a typed `switch` to keep the generic record type sound under contravariance. - New `services/sync/migrateLegacy.ts` — `migrateLegacyReplicas` helper that owns the rehash-flat-path → `/` migration. `migrateLegacyFonts` and `migrateLegacyTextures` are now thin per-kind configs, ~15 lines each (down from ~60). Net: +143 / -291 across 5 files plus 2 new helpers. No behavior change; 3933 tests still pass. Co-authored-by: Claude Opus 4.7 (1M context) --- apps/readest-app/src/hooks/useReplicaPull.ts | 176 +++++++++--------- .../src/services/sync/adapters/font.ts | 36 +--- .../src/services/sync/adapters/helpers.ts | 53 ++++++ .../src/services/sync/adapters/texture.ts | 36 +--- .../src/services/sync/migrateLegacy.ts | 125 +++++++++++++ apps/readest-app/src/store/customFontStore.ts | 94 ++-------- .../src/store/customTextureStore.ts | 94 ++-------- 7 files changed, 323 insertions(+), 291 deletions(-) create mode 100644 apps/readest-app/src/services/sync/adapters/helpers.ts create mode 100644 apps/readest-app/src/services/sync/migrateLegacy.ts diff --git a/apps/readest-app/src/hooks/useReplicaPull.ts b/apps/readest-app/src/hooks/useReplicaPull.ts index 7c80fe19..ad78def4 100644 --- a/apps/readest-app/src/hooks/useReplicaPull.ts +++ b/apps/readest-app/src/hooks/useReplicaPull.ts @@ -17,11 +17,16 @@ import { dictionaryAdapter } from '@/services/sync/adapters/dictionary'; import { fontAdapter } from '@/services/sync/adapters/font'; import { textureAdapter } from '@/services/sync/adapters/texture'; import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload'; -import { replicaPullAndApply, type PullAndApplyDeps } from '@/services/sync/replicaPullAndApply'; +import { + replicaPullAndApply, + type PullAndApplyDeps, + type ReplicaLocalRecord, +} from '@/services/sync/replicaPullAndApply'; +import type { ReplicaAdapter } from '@/services/sync/replicaRegistry'; import { getAccessToken } from '@/utils/access'; import { uniqueId } from '@/utils/misc'; import type { EnvConfigType } from '@/services/environment'; -import type { AppService } from '@/types/system'; +import type { AppService, BaseDir } from '@/types/system'; import type { ReplicaSyncManager } from '@/services/sync/replicaSyncManager'; import type { ImportedDictionary } from '@/services/dictionaries/types'; import type { CustomFont } from '@/styles/fonts'; @@ -45,56 +50,85 @@ const REPLICA_PULL_DEFAULT_DELAY_MS = 10_000; // which is wired once in EnvContext. const pulledKinds = new Set(); -const buildDictionaryPullDeps = ( +/** + * Per-kind config consumed by `buildReplicaPullDeps`. The factory fills + * in everything that is structurally identical across kinds (creating + * bundle dirs, queueing downloads, checking files exist, queueing + * upload of the local copy, gating on auth). Per-kind logic stays + * here: the adapter, base dir, find/hydrate/apply/soft-delete store + * accessors. + */ +interface ReplicaPullConfig { + kind: ReplicaKind; + baseDir: BaseDir; + adapter: ReplicaAdapter; + findByContentId: (id: string) => T | undefined; + hydrateLocalStore?: (envConfig: EnvConfigType) => Promise; + applyRemote: (record: T) => void; + softDeleteByContentId: (id: string) => void; +} + +const buildReplicaPullDeps = ( manager: ReplicaSyncManager, service: AppService, envConfig: EnvConfigType, -): PullAndApplyDeps => ({ - adapter: dictionaryAdapter, + config: ReplicaPullConfig, +): PullAndApplyDeps => ({ + adapter: config.adapter, // 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. - pull: () => manager.pull('dictionary', { since: null }), - // The page may mount before loadCustomDictionaries has hydrated the - // in-memory store, so the dedup helper falls back to settings. - findByContentId: (id: string) => findDictionaryByContentId(id), - // Pull-side relies on the in-memory dict store reflecting persisted - // state — without this, the auto-persist fired by applyRemoteDictionary - // would write back only the just-applied rows and clobber every - // persisted dict that hadn't been hydrated by an Annotator/Settings - // mount. Library-page refreshes were the visible victim. - hydrateLocalStore: () => useCustomDictionaryStore.getState().loadCustomDictionaries(envConfig), - applyRemote: (dict) => useCustomDictionaryStore.getState().applyRemoteDictionary(dict), - softDeleteByContentId: (id) => useCustomDictionaryStore.getState().softDeleteByContentId(id), + pull: () => manager.pull(config.kind, { since: null }), + findByContentId: config.findByContentId, + hydrateLocalStore: config.hydrateLocalStore + ? () => config.hydrateLocalStore!(envConfig) + : undefined, + applyRemote: config.applyRemote, + softDeleteByContentId: config.softDeleteByContentId, createBundleDir: async () => { const id = uniqueId(); - await service.createDir(id, 'Dictionaries', true); + await service.createDir(id, config.baseDir, true); return id; }, queueReplicaDownload: (contentId, displayTitle, files, _bundleDir, base) => - transferManager.queueReplicaDownload('dictionary', contentId, displayTitle, files, base), + transferManager.queueReplicaDownload(config.kind, contentId, displayTitle, files, base), filesExist: async (bundleDir, filenames) => { for (const filename of filenames) { - const exists = await service.exists(`${bundleDir}/${filename}`, 'Dictionaries'); + const exists = await service.exists(`${bundleDir}/${filename}`, config.baseDir); if (!exists) return false; } return true; }, queueLocalBinaryUpload: async (record) => { - await queueReplicaBinaryUpload('dictionary', record, service); + await queueReplicaBinaryUpload(config.kind, record, service); }, isAuthenticated: async () => !!(await getAccessToken()), }); -const buildFontPullDeps = ( - manager: ReplicaSyncManager, - service: AppService, - envConfig: EnvConfigType, -): PullAndApplyDeps => ({ +const dictionaryPullConfig: ReplicaPullConfig = { + kind: 'dictionary', + baseDir: 'Dictionaries', + adapter: dictionaryAdapter, + // Page may mount before loadCustomDictionaries has hydrated the + // in-memory store, so the dedup helper falls back to settings. + findByContentId: findDictionaryByContentId, + // Pull-side relies on the in-memory dict store reflecting persisted + // state — without this, the auto-persist fired by applyRemoteDictionary + // would write back only the just-applied rows and clobber every + // persisted dict that hadn't been hydrated by an Annotator/Settings + // mount. Library-page refreshes were the visible victim. + hydrateLocalStore: (envConfig) => + useCustomDictionaryStore.getState().loadCustomDictionaries(envConfig), + applyRemote: (dict) => useCustomDictionaryStore.getState().applyRemoteDictionary(dict), + softDeleteByContentId: (id) => useCustomDictionaryStore.getState().softDeleteByContentId(id), +}; + +const fontPullConfig: ReplicaPullConfig = { + kind: 'font', + baseDir: 'Fonts', adapter: fontAdapter, - pull: () => manager.pull('font', { since: null }), - findByContentId: (id: string) => findFontByContentId(id), - hydrateLocalStore: async () => { + findByContentId: findFontByContentId, + hydrateLocalStore: async (envConfig) => { 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. @@ -102,35 +136,14 @@ const buildFontPullDeps = ( }, 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()), -}); +}; -const buildTexturePullDeps = ( - manager: ReplicaSyncManager, - service: AppService, - envConfig: EnvConfigType, -): PullAndApplyDeps => ({ +const texturePullConfig: ReplicaPullConfig = { + kind: 'texture', + baseDir: 'Images', adapter: textureAdapter, - pull: () => manager.pull('texture', { since: null }), - findByContentId: (id: string) => findTextureByContentId(id), - hydrateLocalStore: async () => { + findByContentId: findTextureByContentId, + hydrateLocalStore: async (envConfig) => { await useCustomTextureStore.getState().loadCustomTextures(envConfig); // Rehash legacy flat-path textures so the user doesn't have to // re-import them by hand to get them onto other devices. @@ -138,25 +151,7 @@ const buildTexturePullDeps = ( }, applyRemote: (texture) => useCustomTextureStore.getState().applyRemoteTexture(texture), softDeleteByContentId: (id) => useCustomTextureStore.getState().softDeleteByContentId(id), - createBundleDir: async () => { - const id = uniqueId(); - await service.createDir(id, 'Images', true); - return id; - }, - queueReplicaDownload: (contentId, displayTitle, files, _bundleDir, base) => - transferManager.queueReplicaDownload('texture', contentId, displayTitle, files, base), - filesExist: async (bundleDir, filenames) => { - for (const filename of filenames) { - const exists = await service.exists(`${bundleDir}/${filename}`, 'Images'); - if (!exists) return false; - } - return true; - }, - queueLocalBinaryUpload: async (record) => { - await queueReplicaBinaryUpload('texture', record, service); - }, - isAuthenticated: async () => !!(await getAccessToken()), -}); +}; const runPullForKind = async ( kind: ReplicaKind, @@ -165,19 +160,26 @@ const runPullForKind = async ( ): Promise => { const ctx = getReplicaSync(); if (!ctx) return; - if (kind === 'dictionary') { - await replicaPullAndApply(buildDictionaryPullDeps(ctx.manager, service, envConfig)); - return; + // Per-kind dispatch keeps the generic record type sound — collapsing + // the three configs into a Record> + // would force a contravariant cast that loses type safety. + switch (kind) { + case 'dictionary': + await replicaPullAndApply( + buildReplicaPullDeps(ctx.manager, service, envConfig, dictionaryPullConfig), + ); + return; + case 'font': + await replicaPullAndApply( + buildReplicaPullDeps(ctx.manager, service, envConfig, fontPullConfig), + ); + return; + case 'texture': + await replicaPullAndApply( + buildReplicaPullDeps(ctx.manager, service, envConfig, texturePullConfig), + ); + return; } - if (kind === 'font') { - await replicaPullAndApply(buildFontPullDeps(ctx.manager, service, envConfig)); - return; - } - if (kind === 'texture') { - await replicaPullAndApply(buildTexturePullDeps(ctx.manager, service, envConfig)); - return; - } - // Future: dispatch to other per-kind dep builders here. }; /** diff --git a/apps/readest-app/src/services/sync/adapters/font.ts b/apps/readest-app/src/services/sync/adapters/font.ts index 28e927b2..71cf769c 100644 --- a/apps/readest-app/src/services/sync/adapters/font.ts +++ b/apps/readest-app/src/services/sync/adapters/font.ts @@ -1,14 +1,17 @@ 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'; +import type { FieldsObject, ReplicaRow } from '@/types/replica'; +import { + defaultComputeId, + singleFileBinaryEnumerator, + singleFileFilenameFromManifest, + unwrap, +} from './helpers'; 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; @@ -38,12 +41,6 @@ const unwrapFontFields = (fields: FieldsObject): UnwrappedFontFields => { }; }; -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 = { @@ -78,14 +75,12 @@ export const fontAdapter: ReplicaAdapter = { }; }, - async computeId(f: CustomFont): Promise { - return f.contentId ?? f.id; - }, + computeId: defaultComputeId, unpackRow(row: ReplicaRow, bundleDir: string): CustomFont | null { const fields = unwrapFontFields(row.fields_jsonb); if (!fields.name) return null; - const filename = filenameFromManifest(row); + const filename = singleFileFilenameFromManifest(row); if (!filename) { // No manifest yet — placeholder with empty path; the manifest // commit on the publishing device will fill this in on the next @@ -113,17 +108,6 @@ export const fontAdapter: ReplicaAdapter = { 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, - }, - ]; - }, + enumerateFiles: singleFileBinaryEnumerator, }, }; diff --git a/apps/readest-app/src/services/sync/adapters/helpers.ts b/apps/readest-app/src/services/sync/adapters/helpers.ts new file mode 100644 index 00000000..b0899e3b --- /dev/null +++ b/apps/readest-app/src/services/sync/adapters/helpers.ts @@ -0,0 +1,53 @@ +import type { FieldEnvelope, ReplicaRow } from '@/types/replica'; + +/** + * Unwrap a CRDT field envelope back to its raw value. Returns `undefined` + * when the envelope is missing or malformed (and for cipher envelopes, + * which only the per-adapter encrypt/decrypt path knows how to handle). + * + * Shared across adapters that read scalar fields out of `fields_jsonb` — + * the dictionary, font, and texture adapters all unwrap the same shape. + */ +export const unwrap = (env: FieldEnvelope | undefined): unknown => + env && typeof env === 'object' && 'v' in env ? (env as FieldEnvelope).v : undefined; + +/** + * Pull the single manifest filename for a kind whose binary capability + * always lists exactly one file (font, texture). Returns `null` when the + * manifest hasn't been committed yet — the orchestrator skips such rows + * and re-pulls them on the next cycle once the publishing device's + * upload completes. + */ +export const singleFileFilenameFromManifest = (row: ReplicaRow): string | null => { + const f = row.manifest_jsonb?.files[0]; + return f?.filename ?? null; +}; + +/** + * `binary.enumerateFiles` for any single-file kind whose record carries + * a relative `path` (`/`) and an optional `byteSize`. + * Returns one entry; legacy flat-path records (no slash in `path`) work + * without modification — the filename falls back to the path itself. + */ +export const singleFileBinaryEnumerator = ( + record: T, +): { logical: string; lfp: string; byteSize: number }[] => { + const filename = record.path.split('/').pop() ?? record.path; + return [ + { + logical: filename, + lfp: record.path, + byteSize: record.byteSize ?? 0, + }, + ]; +}; + +/** + * Default `computeId` for kinds that compute their cross-device identity + * via a content hash stored in `record.contentId` (set at import time) + * and fall back to the local `record.id` for legacy entries that + * predate replica sync. + */ +export const defaultComputeId = async ( + record: T, +): Promise => record.contentId ?? record.id; diff --git a/apps/readest-app/src/services/sync/adapters/texture.ts b/apps/readest-app/src/services/sync/adapters/texture.ts index 1cc5c55b..3d6dedb1 100644 --- a/apps/readest-app/src/services/sync/adapters/texture.ts +++ b/apps/readest-app/src/services/sync/adapters/texture.ts @@ -1,14 +1,17 @@ import { computeTextureContentId } from '@/services/imageService'; import type { CustomTexture } from '@/styles/textures'; import type { ReplicaAdapter } from '@/services/sync/replicaRegistry'; -import type { FieldEnvelope, FieldsObject, ReplicaRow } from '@/types/replica'; +import type { FieldsObject, ReplicaRow } from '@/types/replica'; +import { + defaultComputeId, + singleFileBinaryEnumerator, + singleFileFilenameFromManifest, + unwrap, +} from './helpers'; export const TEXTURE_KIND = 'texture'; export const TEXTURE_SCHEMA_VERSION = 1; -const unwrap = (env: FieldEnvelope | undefined): unknown => - env && typeof env === 'object' && 'v' in env ? (env as FieldEnvelope).v : undefined; - interface UnwrappedTextureFields { name?: string; byteSize?: number; @@ -26,12 +29,6 @@ const unwrapTextureFields = (fields: FieldsObject): UnwrappedTextureFields => { }; }; -const filenameFromManifest = (row: ReplicaRow): string | null => { - // Texture kind is single-file; the manifest carries exactly one entry. - const f = row.manifest_jsonb?.files[0]; - return f?.filename ?? null; -}; - export { computeTextureContentId }; export const textureAdapter: ReplicaAdapter = { @@ -58,14 +55,12 @@ export const textureAdapter: ReplicaAdapter = { }; }, - async computeId(t: CustomTexture): Promise { - return t.contentId ?? t.id; - }, + computeId: defaultComputeId, unpackRow(row: ReplicaRow, bundleDir: string): CustomTexture | null { const fields = unwrapTextureFields(row.fields_jsonb); if (!fields.name) return null; - const filename = filenameFromManifest(row); + const filename = singleFileFilenameFromManifest(row); if (!filename) { // No manifest yet — placeholder with empty path; the manifest // commit on the publishing device will fill this in on the next @@ -89,17 +84,6 @@ export const textureAdapter: ReplicaAdapter = { binary: { localBaseDir: 'Images', - enumerateFiles: (texture: CustomTexture) => { - // Textures are single-file. The texture's `path` is the lfp, - // relative to the Images base dir. - const filename = texture.path.split('/').pop() ?? texture.path; - return [ - { - logical: filename, - lfp: texture.path, - byteSize: texture.byteSize ?? 0, - }, - ]; - }, + enumerateFiles: singleFileBinaryEnumerator, }, }; diff --git a/apps/readest-app/src/services/sync/migrateLegacy.ts b/apps/readest-app/src/services/sync/migrateLegacy.ts new file mode 100644 index 00000000..1ed54941 --- /dev/null +++ b/apps/readest-app/src/services/sync/migrateLegacy.ts @@ -0,0 +1,125 @@ +import { partialMD5 } from '@/utils/md5'; +import { uniqueId } from '@/utils/misc'; +import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload'; +import type { EnvConfigType } from '@/services/environment'; +import type { BaseDir } from '@/types/system'; + +/** + * Shared shape for legacy flat-path records that predate replica sync: + * a flat `` path under the kind's base dir, no `contentId` + * or `bundleDir`. The migration rehashes the bytes into a per-record + * `/` layout so the kind starts syncing without + * forcing the user to re-import. + */ +interface LegacyReplicaRecord { + id: string; + name: string; + path: string; + contentId?: string; + bundleDir?: string; + byteSize?: number; + reincarnation?: string; + blobUrl?: string; + loaded?: boolean; + error?: string; +} + +export interface MigrateLegacyReplicasDeps { + /** Replica kind name — passed to `queueReplicaBinaryUpload`. */ + kind: string; + /** App-service base dir for the kind (`'Fonts'`, `'Images'`, ...). */ + baseDir: BaseDir; + /** + * Snapshot of legacy candidates: live records with no `contentId` or + * `bundleDir` and a flat `path`. The caller filters in-store; this + * helper is purely the on-disk + publish side of the migration. + */ + getCandidates: () => T[]; + /** `md5(partialMd5 ‖ byteSize ‖ filename)` — same recipe per kind. */ + computeContentId: (partialMd5: string, byteSize: number, filename: string) => string; + /** Patch the in-memory record with the migrated fields. */ + updateRecord: (id: string, next: T) => void; + /** Persist the kind's settings entry post-migration. */ + saveStore: (envConfig: EnvConfigType) => Promise; + /** Publish the now-syncable record to the replica row. */ + publishUpsert: (record: T) => void; +} + +/** + * One-time migration: rehash legacy flat-path records (imported before + * replica sync shipped) into the per-bundle layout so they sync + * across devices without forcing the user to re-import. + * + * For each candidate: + * 1. Read bytes from `/`. + * 2. Compute partialMD5 + size + filename → contentId. + * 3. Mint `bundleDir = uniqueId()`; copy the file to + * `//` and remove the flat-path one. + * 4. Patch the in-memory record (contentId, bundleDir, byteSize, + * path), persist via `saveStore`, then publish through + * `publishUpsert` and queue the binary upload. + * + * Idempotent: a record that already carries `contentId` is filtered + * out upstream by `getCandidates`. If the file is missing on disk, + * the migration leaves the record untouched. Per-record failures are + * logged and don't block the rest. + */ +export const migrateLegacyReplicas = async ( + envConfig: EnvConfigType, + deps: MigrateLegacyReplicasDeps, +): Promise => { + const candidates = deps.getCandidates(); + if (candidates.length === 0) return; + + const appService = await envConfig.getAppService(); + const migrated: T[] = []; + + for (const legacy of candidates) { + try { + const exists = await appService.exists(legacy.path, deps.baseDir); + if (!exists) continue; + + const file = await appService.openFile(legacy.path, deps.baseDir); + const bytes = await file.arrayBuffer(); + const partialMd5 = await partialMD5(file); + const byteSize = bytes.byteLength; + const filename = legacy.path; + const contentId = deps.computeContentId(partialMd5, byteSize, filename); + const bundleDir = uniqueId(); + const newPath = `${bundleDir}/${filename}`; + + await appService.createDir(bundleDir, deps.baseDir, true); + await appService.copyFile(legacy.path, deps.baseDir, newPath, deps.baseDir); + await appService.deleteFile(legacy.path, deps.baseDir); + + const next: T = { + ...legacy, + contentId, + bundleDir, + byteSize, + path: newPath, + // Force a re-load on next render so the blob URL points at the + // new on-disk path. + blobUrl: undefined, + loaded: false, + error: undefined, + }; + deps.updateRecord(legacy.id, next); + migrated.push(next); + } catch (err) { + console.warn(`migrateLegacyReplicas[${deps.kind}]: failed for`, legacy.path, err); + } + } + + if (migrated.length === 0) return; + + try { + await deps.saveStore(envConfig); + } catch (err) { + console.warn(`migrateLegacyReplicas[${deps.kind}]: save failed`, err); + } + for (const record of migrated) { + deps.publishUpsert(record); + void queueReplicaBinaryUpload(deps.kind, record, appService); + } +}; diff --git a/apps/readest-app/src/store/customFontStore.ts b/apps/readest-app/src/store/customFontStore.ts index 1df02b7f..9b2c289e 100644 --- a/apps/readest-app/src/store/customFontStore.ts +++ b/apps/readest-app/src/store/customFontStore.ts @@ -12,9 +12,7 @@ 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'; +import { migrateLegacyReplicas } from '@/services/sync/migrateLegacy'; const publishFontUpsert = (font: CustomFont): void => { if (!font.contentId) return; @@ -420,81 +418,23 @@ export const findFontByContentId = (contentId: string): CustomFont | undefined = /** * 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. + * across devices without forcing the user to re-import. Idempotent; + * skips fonts that already carry `contentId`. Implementation lives in + * `migrateLegacyReplicas` — shared with custom textures. */ -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); - } -}; +export const migrateLegacyFonts = (envConfig: EnvConfigType): Promise => + migrateLegacyReplicas(envConfig, { + kind: FONT_KIND, + baseDir: 'Fonts', + getCandidates: () => + useCustomFontStore + .getState() + .fonts.filter((f) => !f.contentId && !f.bundleDir && !f.deletedAt && !f.path.includes('/')), + computeContentId: computeFontContentId, + updateRecord: (id, next) => useCustomFontStore.getState().updateFont(id, next), + saveStore: (env) => useCustomFontStore.getState().saveCustomFonts(env), + publishUpsert: publishFontUpsert, + }); if (typeof window !== 'undefined') { window.addEventListener('beforeunload', () => { diff --git a/apps/readest-app/src/store/customTextureStore.ts b/apps/readest-app/src/store/customTextureStore.ts index cae80f89..29dfb27c 100644 --- a/apps/readest-app/src/store/customTextureStore.ts +++ b/apps/readest-app/src/store/customTextureStore.ts @@ -12,9 +12,7 @@ import { getReplicaPersistEnv } from '@/services/sync/replicaPersist'; import { publishReplicaDelete, publishReplicaUpsert } from '@/services/sync/replicaPublish'; import { TEXTURE_KIND } from '@/services/sync/adapters/texture'; import { computeTextureContentId } from '@/services/imageService'; -import { queueReplicaBinaryUpload } from '@/services/sync/replicaBinaryUpload'; -import { partialMD5 } from '@/utils/md5'; -import { uniqueId } from '@/utils/misc'; +import { migrateLegacyReplicas } from '@/services/sync/migrateLegacy'; const publishTextureUpsert = (texture: CustomTexture): void => { if (!texture.contentId) return; @@ -438,79 +436,25 @@ export const findTextureByContentId = (contentId: string): CustomTexture | undef /** * One-time migration: rehash legacy flat-path textures (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 texture with no `contentId`: - * 1. Read bytes from `Images/`. - * 2. Compute partialMD5 + size → contentId. - * 3. Mint `bundleDir = uniqueId()`; copy the file to - * `Images//` and remove the flat-path one. - * 4. Patch the in-memory record (contentId, bundleDir, byteSize, - * path), persist via saveCustomTextures, then publish through - * publishReplicaUpsert. - * - * Idempotent: a texture that already carries `contentId` is skipped. - * If the file is missing on disk, the migration leaves the record - * untouched. Per-texture failures are logged and don't block the rest. + * across devices without forcing the user to re-import. Idempotent; + * skips textures that already carry `contentId`. Implementation lives + * in `migrateLegacyReplicas` — shared with custom fonts. */ -export const migrateLegacyTextures = async (envConfig: EnvConfigType): Promise => { - const candidates = useCustomTextureStore - .getState() - .textures.filter((t) => !t.contentId && !t.bundleDir && !t.deletedAt && !t.path.includes('/')); - if (candidates.length === 0) return; - - const appService = await envConfig.getAppService(); - const migrated: CustomTexture[] = []; - - for (const legacy of candidates) { - try { - const exists = await appService.exists(legacy.path, 'Images'); - if (!exists) continue; - - const file = await appService.openFile(legacy.path, 'Images'); - const bytes = await file.arrayBuffer(); - const partialMd5 = await partialMD5(file); - const byteSize = bytes.byteLength; - const filename = legacy.path; - const contentId = computeTextureContentId(partialMd5, byteSize, filename); - const bundleDir = uniqueId(); - const newPath = `${bundleDir}/${filename}`; - - await appService.createDir(bundleDir, 'Images', true); - await appService.copyFile(legacy.path, 'Images', newPath, 'Images'); - await appService.deleteFile(legacy.path, 'Images'); - - const next: CustomTexture = { - ...legacy, - contentId, - bundleDir, - byteSize, - path: newPath, - // Force a re-load on next render so the blob URL points at the - // new on-disk path. - blobUrl: undefined, - loaded: false, - error: undefined, - }; - useCustomTextureStore.getState().updateTexture(legacy.id, next); - migrated.push(next); - } catch (err) { - console.warn('migrateLegacyTextures: failed for', legacy.path, err); - } - } - - if (migrated.length === 0) return; - - try { - await useCustomTextureStore.getState().saveCustomTextures(envConfig); - } catch (err) { - console.warn('migrateLegacyTextures: save failed', err); - } - for (const texture of migrated) { - publishTextureUpsert(texture); - void queueReplicaBinaryUpload('texture', texture, appService); - } -}; +export const migrateLegacyTextures = (envConfig: EnvConfigType): Promise => + migrateLegacyReplicas(envConfig, { + kind: TEXTURE_KIND, + baseDir: 'Images', + getCandidates: () => + useCustomTextureStore + .getState() + .textures.filter( + (t) => !t.contentId && !t.bundleDir && !t.deletedAt && !t.path.includes('/'), + ), + computeContentId: computeTextureContentId, + updateRecord: (id, next) => useCustomTextureStore.getState().updateTexture(id, next), + saveStore: (env) => useCustomTextureStore.getState().saveCustomTextures(env), + publishUpsert: publishTextureUpsert, + }); // Cleanup blob URLs before page unload if (typeof window !== 'undefined') {