ceddee3793
Adds a quick "Search on Goodreads" action so readers can jump straight to Goodreads to track a book instead of retyping the title there. - Library: a Goodreads button in the Book Details view (works on web, desktop and mobile) searching the book's title + author, plus a "Search on Goodreads" item in the desktop right-click context menu. - Reader: Goodreads is added as a built-in web-search provider so highlighted text (e.g. a short-story title inside a magazine) can be looked up on Goodreads. Disabled by default like the other built-ins; enable it in Settings -> Dictionaries. Both surfaces are used because the native context menu is desktop-only; the Book Details button covers web and mobile. Adds a shared openExternalUrl() helper and translates "Search on Goodreads" across all locales (the Goodreads brand name is kept verbatim). Closes #4543 Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
621 lines
26 KiB
TypeScript
621 lines
26 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,
|
|
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<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) => {
|
|
// 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<string, boolean> = {
|
|
...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;
|
|
}
|
|
},
|
|
}));
|