Files
readest/apps/readest-app/src/store/customDictionaryStore.ts
T
Huang Xin 4110911011 fix(sync): keep dictionarySettings consistent across devices (#4105)
The bundled `settings` replica's `dictionarySettings.providerOrder`
and `providerEnabled` repeatedly drifted on multi-device setups: a
fresh-install Device B would overwrite Device A's authoritative
order with its own local default, dict tombstones referenced via
the settings replica left "skipped" gaps in the UI, and providerEnabled
keys missing from providerOrder rendered as silently lost imports.

Six related fixes (mostly orthogonal):

- **Disk-priming** in `initSettingsSync(initialSettings)`: seeds
  `lastPublishedFields` from the just-loaded disk settings so the
  first `setSettings(disk_default)` at boot diffs against the disk
  baseline (no diff → no push), instead of diffing every whitelisted
  field against `undefined` and clobbering the server with locals.
- **Settings boot pull is awaited first** in `useReplicaPull` (with
  a shared `settingsBootPullPromise`) so the dict/font/texture/opds
  pulls' auto-saves see server-primed `lastPublishedFields` rather
  than disk defaults — implicit even when the caller didn't request
  the `settings` kind.
- **Visibility / online / periodic auto-pull** in `useReplicaPull`:
  module-level listeners with a 30s visibility throttle and a 5-min
  interval keep long-lived foreground tabs in sync (previously the
  hook only did the once-per-session boot pull and `ReplicaSyncManager.startAutoSync`'s
  comments lied — it only flushed dirty pushes).
- **Tombstone scrubbing for no-local rows**: `softDeleteByContentId`
  scrubs `providerOrder` / `providerEnabled` by contentId regardless
  of whether a local dict matches, and `applyRow` always invokes it
  on tombstones — so Device B fresh-installs that pulled tombstoned
  contentIds via the settings replica without ever having a local row
  still get the provider-side entries cleaned.
- **Orphan rescue** in `loadCustomDictionaries`: providerEnabled keys
  that have no slot in providerOrder (per-field LWW splits a settings
  push) get spliced before the first builtin so user-imported dicts
  stay contiguous near the top of the list, not stranded after the
  builtins where users miss them.
- **`addDictionary` prepends** to `providerOrder` so a fresh local
  import shows up at the top of the list. Reviving a soft-deleted
  entry preserves its existing slot.
- **Explicit-publish gate for `providerOrder`**: `markExplicitProviderOrderPublish()`
  in `replicaSettingsSync` is the only way for `publishSettingsIfChanged`
  to ship `dictionarySettings.providerOrder`. UI handlers that
  intentionally reorder (drag-drop, dict import, dict delete,
  web-search add) opt in via `saveCustomDictionaries(env, { publishOrderChange: true })`.
  Auto-mutations from replica pull / orphan-rescue / tombstone-scrub
  no longer ever republish the local view of order.

12 new tests across `replicaSettingsSync`, `replicaPullAndApply`,
`useReplicaPull`, and `customDictionaryStore`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 10:40:19 +02:00

605 lines
25 KiB
TypeScript

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,
];
const DEFAULT_DICTIONARY_SETTINGS: DictionarySettings = {
providerOrder: [
BUILTIN_PROVIDER_IDS.wiktionary,
BUILTIN_PROVIDER_IDS.wikipedia,
...BUILTIN_WEB_ORDER,
],
providerEnabled: {
[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,
},
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<DictionarySettings>): void;
/** Hydrate from `settings.customDictionaries` + `settings.dictionarySettings` + check on-disk availability. */
loadCustomDictionaries(envConfig: EnvConfigType): Promise<void>;
/**
* 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<void>;
}
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<DictionaryStoreState>((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<string>([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) => ({
settings: {
...state.settings,
providerEnabled: { ...state.settings.providerEnabled, [id]: enabled },
},
}));
},
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;
}
},
}));