import { create } from 'zustand'; import { EnvConfigType } from '@/services/environment'; import type { DictionarySettings, ImportedDictionary, WebSearchEntry, } from '@/services/dictionaries/types'; import { BUILTIN_PROVIDER_IDS, BUILTIN_WEB_SEARCH_IDS } from '@/services/dictionaries/types'; import { useSettingsStore } from './settingsStore'; import { publishReplicaDelete, publishReplicaUpsert } from '@/services/sync/replicaPublish'; import { DICTIONARY_KIND } from '@/services/sync/adapters/dictionary'; import { markExplicitProviderOrderPublish } from '@/services/sync/replicaSettingsSync'; 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 * default — users opt in. This preserves the principle that we don't push * users onto external pages without consent, while still surfacing the * options in the settings list. */ const BUILTIN_WEB_ORDER = [ BUILTIN_WEB_SEARCH_IDS.google, BUILTIN_WEB_SEARCH_IDS.urban, BUILTIN_WEB_SEARCH_IDS.merriamWebster, BUILTIN_WEB_SEARCH_IDS.goodreads, ]; const DEFAULT_DICTIONARY_SETTINGS: DictionarySettings = { providerOrder: [ BUILTIN_PROVIDER_IDS.systemDictionary, BUILTIN_PROVIDER_IDS.wiktionary, BUILTIN_PROVIDER_IDS.wikipedia, ...BUILTIN_WEB_ORDER, ], providerEnabled: { // System dictionary is opt-in — enabling it disables the rest (and // vice versa) via the settings UI's exclusivity rule. Default off // so existing users see no behavior change on upgrade. [BUILTIN_PROVIDER_IDS.systemDictionary]: false, [BUILTIN_PROVIDER_IDS.wiktionary]: true, [BUILTIN_PROVIDER_IDS.wikipedia]: true, [BUILTIN_WEB_SEARCH_IDS.google]: false, [BUILTIN_WEB_SEARCH_IDS.urban]: false, [BUILTIN_WEB_SEARCH_IDS.merriamWebster]: false, [BUILTIN_WEB_SEARCH_IDS.goodreads]: false, }, webSearches: [], }; interface DictionaryStoreState { /** Imported (non-builtin) dictionaries. Soft-deleted entries are kept until next save. */ dictionaries: ImportedDictionary[]; settings: DictionarySettings; /** Imported entries currently visible (not soft-deleted, sorted by addedAt desc). */ getAvailableDictionaries(): ImportedDictionary[]; getDictionary(id: string): ImportedDictionary | undefined; /** Add (or revive) an imported dictionary. New entries are appended to providerOrder + enabled. */ addDictionary(dict: ImportedDictionary): void; /** * Add a dictionary received via replica sync from another device. Same * effect on local state as addDictionary, but does NOT call * publishDictionaryUpsert — the row already exists on the server (we * just pulled it). Re-publishing would create a tight feedback loop * with stale HLCs. Used exclusively by the pull-side orchestrator. */ applyRemoteDictionary(dict: ImportedDictionary): void; /** * Look up a local imported entry by its cross-device contentId. Used * by the pull-side orchestrator to detect "row from another device" * vs "row originated here" cases. */ findByContentId(contentId: string): ImportedDictionary | undefined; /** * Clears the `unavailable` flag on the dict matching `contentId`. Called * by the replica-transfer-complete listener after a remote-sourced dict * finishes downloading from cloud storage. No-op if the dict isn't found. */ markAvailableByContentId(contentId: string): void; /** * Soft-delete by contentId, skipping the publishDictionaryDelete call * that removeDictionary does. Used by the pull orchestrator when a * server row arrives tombstoned — the row is already deleted on the * server; we just observed it and need to mirror locally. */ softDeleteByContentId(contentId: string): void; /** * Patch an imported dictionary's mutable display fields (currently just * `name`). The on-disk bundle is untouched. No-op if the id is unknown * or refers to a deleted entry. */ updateDictionary(id: string, patch: { name?: string }): void; /** * Drop one or more existing dictionaries by id and insert `newDict` in * the first removed entry's slot in `providerOrder`, inheriting that * entry's enabled flag. Used by the importer when a re-imported dict * matches an existing one (or several) by name. */ replaceDictionaries(oldIds: string[], newDict: ImportedDictionary): void; /** Soft-delete an imported entry by id; remove from providerOrder + providerEnabled. */ removeDictionary(id: string): boolean; /** Replace a subset of provider ids in providerOrder; ignores unknown ids. */ reorder(ids: string[]): void; /** Toggle a provider's enabled flag. Both builtin and imported ids are accepted. */ setEnabled(id: string, enabled: boolean): void; /** Persist the last-used tab id so the popup re-opens on it. */ setDefaultProviderId(id: string | undefined): void; /** Add a custom web search (id is generated). Appended + enabled by default. */ addWebSearch(name: string, urlTemplate: string): WebSearchEntry; /** Update an existing custom web search; no-op if id is unknown or built-in. */ updateWebSearch(id: string, patch: { name?: string; urlTemplate?: string }): void; /** Soft-delete a custom web search and remove from order/enabled. */ removeWebSearch(id: string): boolean; /** * Mirror an inbound dictionarySettings patch from the bundled * `settings` replica into the in-memory store so the dictionary * panel and the reader popup pick up the change without a reload. * Pull-side only — no publish, no save. */ applyRemoteDictionarySettings(patch: Partial): void; /** Hydrate from `settings.customDictionaries` + `settings.dictionarySettings` + check on-disk availability. */ loadCustomDictionaries(envConfig: EnvConfigType): Promise; /** * Persist current state back into settings (which then syncs to * cloud). Pass `{ publishOrderChange: true }` from explicit user * actions that mutated `providerOrder` (drag-drop reorder, dict * import, dict delete, web-search add/remove) so the auto-mutation * gate releases providerOrder for that single push. Auto-save * callers (replica pull, download-complete) leave it false so * automatic local order changes never publish back to the server. */ saveCustomDictionaries( envConfig: EnvConfigType, opts?: { publishOrderChange?: boolean }, ): Promise; } function toSettingsDict(dict: ImportedDictionary): ImportedDictionary { // Strip transient fields before persisting. `unavailable` is recomputed at // load time from the actual filesystem state, so don't write it. // eslint-disable-next-line @typescript-eslint/no-unused-vars const { unavailable: _u, ...rest } = dict; return rest; } // Replica-side mutators (applyRemoteDictionary, softDeleteByContentId, // markAvailableByContentId) fire from boot-time pull / download-complete // 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 * persisted `settings.customDictionaries` when the in-memory store is * empty. The pull-side orchestrator runs at app boot — earlier than * Annotator/CustomDictionaries mount, so loadCustomDictionaries hasn't * hydrated the zustand store yet. Without the fallback every refresh * looks like a brand-new device, mints a fresh bundleDir per row, and * re-downloads all binaries. */ export const findDictionaryByContentId = (contentId: string): ImportedDictionary | undefined => { if (!contentId) return undefined; const inMemory = useCustomDictionaryStore.getState().findByContentId(contentId); if (inMemory) return inMemory; const persisted = useSettingsStore.getState().settings?.customDictionaries ?? []; return persisted.find((d) => d.contentId === contentId && !d.deletedAt); }; export const useCustomDictionaryStore = create((set, get) => ({ dictionaries: [], settings: { ...DEFAULT_DICTIONARY_SETTINGS }, getAvailableDictionaries: () => get() .dictionaries.filter((d) => !d.deletedAt) .sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0)), getDictionary: (id) => get().dictionaries.find((d) => d.id === id), addDictionary: (dict) => { set((state) => { const existingIdx = state.dictionaries.findIndex((d) => d.id === dict.id); const dictionaries = existingIdx >= 0 ? state.dictionaries.map((d, i) => i === existingIdx ? { ...dict, deletedAt: undefined } : d, ) : [...state.dictionaries, dict]; // Fresh imports go to the TOP of providerOrder so the user sees // the dict they just added without scrolling. Reviving an // existing entry preserves its current slot — we only insert // when the id is genuinely new to the order. const order = state.settings.providerOrder.includes(dict.id) ? state.settings.providerOrder : [dict.id, ...state.settings.providerOrder]; const enabled = { ...state.settings.providerEnabled }; if (!(dict.id in enabled)) enabled[dict.id] = !dict.unsupported; return { dictionaries, settings: { ...state.settings, providerOrder: order, providerEnabled: enabled }, }; }); publishDictUpsert(dict); }, applyRemoteDictionary: (dict) => { // Same local-state mutation as addDictionary, minus the publish call. // The row already exists on the server (we just pulled it). set((state) => { const existingIdx = state.dictionaries.findIndex((d) => d.id === dict.id); const dictionaries = existingIdx >= 0 ? state.dictionaries.map((d, i) => i === existingIdx ? { ...dict, deletedAt: undefined } : d, ) : [...state.dictionaries, dict]; const order = state.settings.providerOrder.includes(dict.id) ? state.settings.providerOrder : [...state.settings.providerOrder, dict.id]; const enabled = { ...state.settings.providerEnabled }; if (!(dict.id in enabled)) enabled[dict.id] = !dict.unsupported; return { dictionaries, settings: { ...state.settings, providerOrder: order, providerEnabled: enabled }, }; }); const env = getReplicaPersistEnv(); if (env) void get().saveCustomDictionaries(env); }, findByContentId: (contentId) => contentId ? get().dictionaries.find((d) => d.contentId === contentId) : undefined, markAvailableByContentId: (contentId) => { set((state) => ({ dictionaries: state.dictionaries.map((d) => d.contentId === contentId ? { ...d, unavailable: undefined } : d, ), })); const env = getReplicaPersistEnv(); if (env) void get().saveCustomDictionaries(env); }, softDeleteByContentId: (contentId) => { // Scrub providerOrder/providerEnabled by contentId regardless of // whether a local dict matches — Device B fresh-install pulls the // contentId via the settings replica's providerOrder/providerEnabled // before (or even without) the dict replica row arriving alive, so // we still need to clean the provider-side entries when the dict // arrives tombstoned. For sync-era dicts, dict.id === contentId, // so a contentId-keyed scrub also covers the local-id case. // Match the local entry by contentId regardless of its deletedAt // status: stale provider-side entries can survive a partial cleanup // in a prior session and would otherwise be republished with a // fresh HLC and clobber the cleaned server state under per-field LWW. const target = get().dictionaries.find((d) => d.contentId === contentId); const alreadyDeleted = !target || !!target.deletedAt; // Scrub by both contentId AND any local id — they're usually equal // for sync-era dicts, but legacy entries (pre-replica-sync) may // have a separate bundleDir-derived id. const idsToScrub = new Set([contentId]); if (target) idsToScrub.add(target.id); set((state) => ({ dictionaries: target && !alreadyDeleted ? state.dictionaries.map((d) => d.id === target.id ? { ...d, deletedAt: Date.now() } : d, ) : state.dictionaries, settings: { ...state.settings, providerOrder: state.settings.providerOrder.filter((p) => !idsToScrub.has(p)), providerEnabled: Object.fromEntries( Object.entries(state.settings.providerEnabled).filter(([k]) => !idsToScrub.has(k)), ), }, })); const env = getReplicaPersistEnv(); if (env) void get().saveCustomDictionaries(env); }, updateDictionary: (id, patch) => { let updated: ImportedDictionary | null = null; set((state) => { const idx = state.dictionaries.findIndex((d) => d.id === id); if (idx < 0) return state; const old = state.dictionaries[idx]!; if (old.deletedAt) return state; const trimmedName = patch.name?.trim(); // Reject undefined (no patch), empty (would clear the label), and // unchanged (no-op). if (!trimmedName || trimmedName === old.name) return state; updated = { ...old, name: trimmedName }; const dictionaries = state.dictionaries.map((d, i) => (i === idx ? updated! : d)); return { dictionaries }; }); if (updated) publishDictUpsert(updated); }, replaceDictionaries: (oldIds, newDict) => { if (oldIds.length === 0) { get().addDictionary(newDict); return; } const oldIdSet = new Set(oldIds); // Capture contentIds of replaced dicts so we can tombstone them on the // server. Only contentId-bearing entries actually existed cross-device; // legacy bundleDir-only ids never published, so nothing to tombstone. const oldContentIds = get() .dictionaries.filter((d) => oldIdSet.has(d.id)) .map((d) => d.contentId) .filter((id): id is string => Boolean(id)); set((state) => { // Drop all old entries (hard-remove since the disk bundles are gone) // and append the new one. Soft-delete isn't needed: the previously // stored entries are no longer recoverable. const dictionaries = state.dictionaries.filter((d) => !oldIdSet.has(d.id)); dictionaries.push(newDict); // Splice the new id into providerOrder at the first old slot. Drop // any further old slots. const oldOrder = state.settings.providerOrder; const providerOrder: string[] = []; let inserted = false; for (const id of oldOrder) { if (oldIdSet.has(id)) { if (!inserted) { providerOrder.push(newDict.id); inserted = true; } } else { providerOrder.push(id); } } if (!inserted) providerOrder.push(newDict.id); // Inherit the first old entry's enabled flag (default to !unsupported // if the old wasn't recorded). const firstOldId = oldIds[0]!; const inheritedEnabled = state.settings.providerEnabled[firstOldId] !== undefined ? state.settings.providerEnabled[firstOldId] !== false : !newDict.unsupported; const providerEnabled = { ...state.settings.providerEnabled }; for (const oldId of oldIds) delete providerEnabled[oldId]; providerEnabled[newDict.id] = inheritedEnabled; return { dictionaries, settings: { ...state.settings, providerOrder, providerEnabled }, }; }); // When reincarnating (re-import after delete), the server-side row is // already tombstoned — re-publishing the tombstone is redundant. The // upsert below carries a reincarnation token so clients see the row // as alive again. For non-reincarnation replacements (re-import of a // still-live entry, importer collapsing duplicate names), we skip // tombstoning if the contentId is preserved across the swap (same // content → same row → no need to delete then immediately recreate). const isContentSurvivingSwap = Boolean(newDict.contentId) && oldContentIds.includes(newDict.contentId!); if (!isContentSurvivingSwap) { for (const contentId of oldContentIds) publishDictDelete(contentId); } publishDictUpsert(newDict); }, removeDictionary: (id) => { const dict = get().dictionaries.find((d) => d.id === id); if (!dict) return false; set((state) => ({ dictionaries: state.dictionaries.map((d) => d.id === id ? { ...d, deletedAt: Date.now() } : d, ), settings: { ...state.settings, providerOrder: state.settings.providerOrder.filter((p) => p !== id), providerEnabled: Object.fromEntries( Object.entries(state.settings.providerEnabled).filter(([k]) => k !== id), ), }, })); if (dict.contentId) publishDictDelete(dict.contentId); return true; }, reorder: (ids) => { set((state) => { // Keep only ids that still exist; tail any known ids missing from the input. const known = new Set(state.settings.providerOrder); const filtered = ids.filter((id) => known.has(id)); const tail = state.settings.providerOrder.filter((id) => !filtered.includes(id)); return { settings: { ...state.settings, providerOrder: [...filtered, ...tail] }, }; }); }, setEnabled: (id, enabled) => { set((state) => { // System-dictionary exclusivity is enforced at LOOKUP time: // `isSystemDictionaryEnabled` short-circuits to the OS handoff before // any in-app provider runs. Persisting each provider's enabled state // independently lets the user toggle System on/off without losing // their preferred set of in-app providers — every flag is restored // verbatim the moment System is turned back off. const next: Record = { ...state.settings.providerEnabled, [id]: enabled, }; return { settings: { ...state.settings, providerEnabled: next }, }; }); }, setDefaultProviderId: (id) => { set((state) => ({ settings: { ...state.settings, defaultProviderId: id }, })); }, addWebSearch: (name, urlTemplate) => { const trimmedName = name.trim(); const trimmedUrl = urlTemplate.trim(); const id = `web:${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; const entry: WebSearchEntry = { id, name: trimmedName, urlTemplate: trimmedUrl }; set((state) => { const list = state.settings.webSearches ?? []; const order = state.settings.providerOrder.includes(id) ? state.settings.providerOrder : [...state.settings.providerOrder, id]; const enabled = { ...state.settings.providerEnabled, [id]: true }; return { settings: { ...state.settings, webSearches: [...list, entry], providerOrder: order, providerEnabled: enabled, }, }; }); return entry; }, updateWebSearch: (id, patch) => { if (id.startsWith('web:builtin:')) return; set((state) => { const list = state.settings.webSearches ?? []; if (!list.some((t) => t.id === id)) return state; const next = list.map((t) => t.id === id ? { ...t, name: patch.name?.trim() ?? t.name, urlTemplate: patch.urlTemplate?.trim() ?? t.urlTemplate, } : t, ); return { settings: { ...state.settings, webSearches: next } }; }); }, removeWebSearch: (id) => { if (id.startsWith('web:builtin:')) return false; const list = get().settings.webSearches ?? []; if (!list.some((t) => t.id === id)) return false; set((state) => ({ settings: { ...state.settings, webSearches: (state.settings.webSearches ?? []).map((t) => t.id === id ? { ...t, deletedAt: Date.now() } : t, ), providerOrder: state.settings.providerOrder.filter((p) => p !== id), providerEnabled: Object.fromEntries( Object.entries(state.settings.providerEnabled).filter(([k]) => k !== id), ), }, })); return true; }, applyRemoteDictionarySettings: (patch) => { set((state) => ({ settings: { ...state.settings, ...patch }, })); }, loadCustomDictionaries: async (envConfig) => { try { const { settings } = useSettingsStore.getState(); const persisted = settings?.customDictionaries ?? []; const persistedSettings = settings?.dictionarySettings ?? DEFAULT_DICTIONARY_SETTINGS; const appService = await envConfig.getAppService(); const dictionaries = await Promise.all( persisted.map(async (dict) => { if (dict.deletedAt) return dict; const exists = await appService.exists(dict.bundleDir, 'Dictionaries'); return exists ? dict : { ...dict, unavailable: true }; }), ); // Self-healing reconciliation: drop providerOrder / providerEnabled // entries whose customDictionaries row is tombstoned. Without this, // a prior partial cleanup (e.g. a remote tombstone arriving while // the local was already deletedAt) can leave dangling provider // entries that get republished with a fresh HLC and stomp the // cleaned server state under per-field LWW. Conservative: ids // with NO matching customDictionaries row are kept (might be // in-flight from the dict replica pull). const tombstonedIds = new Set(persisted.filter((d) => d.deletedAt).map((d) => d.id)); const dropTombstoned = (id: string) => !tombstonedIds.has(id); // Merge defaults to back-fill any missing keys (e.g. new builtin added in a release). // For providerOrder, we append any newly-defaulted ids (like the // built-in web searches added in this release) so existing users see // them appear at the end of the list. const persistedOrder = persistedSettings.providerOrder.filter(dropTombstoned); const orderSet = new Set(persistedOrder); const merged: string[] = persistedOrder.length ? [...persistedOrder] : [...DEFAULT_DICTIONARY_SETTINGS.providerOrder]; for (const id of DEFAULT_DICTIONARY_SETTINGS.providerOrder) { if (!orderSet.has(id)) { merged.push(id); orderSet.add(id); } } const persistedEnabled = Object.fromEntries( Object.entries(persistedSettings.providerEnabled).filter(([id]) => dropTombstoned(id)), ); // Collect providerEnabled keys that have no slot in providerOrder. // Settings replica pushes are per-field LWW: a Device A push that // grew providerEnabled but didn't ship a matching providerOrder // (or whose providerOrder push was overwritten) leaves the dict // registered-but-invisible. Surface it in the list so the user // can see and use it. const orphans: string[] = []; for (const id of Object.keys(persistedEnabled)) { if (!orderSet.has(id)) { orphans.push(id); orderSet.add(id); } } if (orphans.length > 0) { // Insert orphans BEFORE the first builtin in providerOrder so // user-imported dicts stay contiguous near the top of the list. // Appending at the very end strands them after the builtins — // the user's UX feedback was that imports felt "lost" below // the wikipedia/wiktionary/web-search section. If providerOrder // contains no builtin yet (degenerate state), fall back to // appending at the end. const isBuiltinOrWebBuiltin = (id: string): boolean => id.startsWith('builtin:') || id.startsWith('web:builtin:'); const firstBuiltinIdx = merged.findIndex(isBuiltinOrWebBuiltin); if (firstBuiltinIdx < 0) { merged.push(...orphans); } else { merged.splice(firstBuiltinIdx, 0, ...orphans); } } const settingsMerged: DictionarySettings = { providerOrder: merged, providerEnabled: { ...DEFAULT_DICTIONARY_SETTINGS.providerEnabled, ...persistedEnabled, }, defaultProviderId: persistedSettings.defaultProviderId, webSearches: persistedSettings.webSearches ?? [], }; set({ dictionaries, settings: settingsMerged }); } catch (error) { console.error('Failed to load custom dictionaries settings:', error); } }, saveCustomDictionaries: async (envConfig, opts) => { try { const { settings, setSettings, saveSettings } = useSettingsStore.getState(); const { dictionaries, settings: dictSettings } = get(); // Build a NEW settings object — Zustand subscribers (notably // replicaSettingsSync.initSettingsSync) compare references to // detect changes, so mutating the existing object in place // bypasses the bundled-settings publish path entirely. const next = { ...settings, customDictionaries: dictionaries.map(toSettingsDict), dictionarySettings: dictSettings, }; // Open the auto-mutation gate for providerOrder when this save // originates from a user action that intentionally changed the // order (drag-drop, dict import, dict delete, web-search add). // Auto-saves from replica pull / download-complete leave it // closed so automatic local order changes never publish. if (opts?.publishOrderChange) { markExplicitProviderOrderPublish(); } setSettings(next); saveSettings(envConfig, next); } catch (error) { console.error('Failed to save custom dictionaries settings:', error); throw error; } }, }));