Files
readest/apps/readest-app/src/utils/serializer.ts
T
Huang Xin 163487b5e3 feat(reader): add regex and nearby-words search modes (#4560) (#4764)
Add Calibre-parity search modes to the reader's full-text search. The
"Match Whole Words" toggle becomes a single-select mode group: Contains,
Whole Words, Regular Expression, Nearby Words.

- Regex and nearby-words matching live in the foliate-js submodule
  (bumped here); the sidebar threads `mode` and `nearbyWords` through.
- Nearby distance is chosen with a "within N words" control (5/10/20/50,
  default 10), not parsed from the query, so trailing numbers stay
  literal search words.
- Per-mode modifiers: Match Diacritics is greyed out for regex (no-op).
- Calm inline error for invalid regex / too-few nearby words, a
  no-results state, and a results-count footer.
- Nearby matches render a segmented excerpt emphasizing each matched
  word and highlight every word in the book.
- BookConfig schema v2 -> v3 migrates the deprecated `matchWholeWords`
  boolean to `mode` (still written for sync back-compat).

Also fix two search interactions:
- option changes (e.g. within-N-words) now take effect immediately by
  reading the latest config at search time instead of a stale closure.
- closing search from the results nav bar now exits the sidebar search
  mode, not just the results (search-bar visibility lifted to the store).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 17:23:09 +02:00

102 lines
4.4 KiB
TypeScript

import {
BOOK_CONFIG_SCHEMA_VERSION,
BookConfig,
BookSearchConfig,
ViewSettings,
} from '@/types/book';
import { unifyAnnotations } from '@/utils/booknoteMigration';
import { DEFAULT_NEARBY_WORDS, ensureSearchMode, modeToWholeWords } from '@/utils/searchConfig';
export const stampBookConfigSchema = <T extends Partial<BookConfig>>(config: T): T => {
return { ...config, schemaVersion: BOOK_CONFIG_SCHEMA_VERSION };
};
export const serializeRawConfig = (config: Partial<BookConfig>): string => {
return JSON.stringify(stampBookConfigSchema(config));
};
// Compare a per-book view-setting value with the global one by value, not
// reference. serializeConfig deep-clones the config first, so array/object
// settings (e.g. annotationToolbarItems) are always fresh references; a reference
// check would persist them as per-book overrides even when identical to global,
// which then shadows later global changes on reopen. Primitives short-circuit;
// the JSON compare covers arrays and plain config objects (all JSON-serializable).
const isSameViewSettingValue = (a: unknown, b: unknown): boolean =>
a === b || JSON.stringify(a) === JSON.stringify(b);
export const serializeConfig = (
config: BookConfig,
globalViewSettings: ViewSettings,
defaultSearchConfig: BookSearchConfig,
): string => {
config = JSON.parse(JSON.stringify(config));
// Tolerate configs that arrive without these fields. Two real-world
// call sites can produce that shape:
// 1. A freshly-initialised config (`INIT_BOOK_CONFIG`) that has
// never been touched by the reader yet.
// 2. The WebDAV sync download path, which merges `{ updatedAt: 0,
// booknotes: [] }` with a remote `compressConfig` payload — the
// latter omits viewSettings/searchConfig entirely when they
// match global defaults.
// Treating null/undefined as `{}` is semantically identical to "no
// overrides vs global", so the reduce below correctly emits an empty
// object that downstream `deserializeConfig` re-hydrates from globals.
const viewSettings = (config.viewSettings ?? {}) as Partial<ViewSettings>;
const searchConfig = (config.searchConfig ?? {}) as Partial<BookSearchConfig>;
config.viewSettings = Object.entries(viewSettings).reduce(
(acc: Partial<Record<keyof ViewSettings, unknown>>, [key, value]) => {
if (!isSameViewSettingValue(globalViewSettings[key as keyof ViewSettings], value)) {
acc[key as keyof ViewSettings] = value;
}
return acc;
},
{} as Partial<Record<keyof ViewSettings, unknown>>,
) as Partial<ViewSettings>;
config.searchConfig = Object.entries(searchConfig).reduce(
(acc: Partial<Record<keyof BookSearchConfig, unknown>>, [key, value]) => {
if (defaultSearchConfig[key as keyof BookSearchConfig] !== value) {
acc[key as keyof BookSearchConfig] = value;
}
return acc;
},
{} as Partial<BookSearchConfig>,
) as Partial<BookSearchConfig>;
config.schemaVersion = BOOK_CONFIG_SCHEMA_VERSION;
return JSON.stringify(config);
};
export const deserializeConfig = (
str: string,
globalViewSettings: ViewSettings,
defaultSearchConfig: BookSearchConfig,
): BookConfig => {
const config = JSON.parse(str) as BookConfig;
const { viewSettings, searchConfig } = config;
config.viewSettings = { ...globalViewSettings, ...viewSettings };
config.searchConfig = { ...defaultSearchConfig, ...searchConfig };
// v2 -> v3: search gained a `mode` enum (contains/whole-words/regex/nearby-words)
// replacing the `matchWholeWords` boolean. Derive `mode` from the boolean when a
// pre-v3 config (or sync peer) omits it, then keep the boolean mirrored on the wire.
const sc = config.searchConfig as BookSearchConfig;
sc.mode = ensureSearchMode(searchConfig ?? {});
sc.matchWholeWords = modeToWholeWords(sc.mode);
sc.nearbyWords ??= DEFAULT_NEARBY_WORDS;
// v1 -> v2: collapse split highlight+note records into one unified record so a
// note renders with its highlight and round-trips cleanly to KOReader.
if ((config.schemaVersion ?? 0) < 2 && config.booknotes?.length) {
config.booknotes = unifyAnnotations(config.booknotes);
}
config.schemaVersion ??= BOOK_CONFIG_SCHEMA_VERSION;
config.updatedAt ??= Date.now();
return config;
};
export const compressConfig = (
config: BookConfig,
globalViewSettings: ViewSettings,
defaultSearchConfig: BookSearchConfig,
): string => {
return JSON.parse(serializeConfig(config, globalViewSettings, defaultSearchConfig));
};