Files
readest/apps/readest-app/src/store/customDictionaryStore.ts
T
Huang Xin ceddee3793 feat(library): search a book on Goodreads from the library and reader (#4543) (#4548)
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>
2026-06-12 08:30:17 +02:00

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;
}
},
}));