diff --git a/apps/readest-app/src/__tests__/foliate-search-modes.test.ts b/apps/readest-app/src/__tests__/foliate-search-modes.test.ts new file mode 100644 index 00000000..d6dbf520 --- /dev/null +++ b/apps/readest-app/src/__tests__/foliate-search-modes.test.ts @@ -0,0 +1,109 @@ +// Tests for the Calibre-parity search modes added in #4560: +// - regex : JS RegExp over the section's joined text +// - nearby-words : whole words occurring within N words of each other +// +// These exercise `search(strs, query, opts)` directly (the same unit seam the +// existing excerpt regression test uses), where `strs` is the per-text-node +// string array `textWalker` produces. +import { describe, expect, it } from 'vitest'; + +import { search } from 'foliate-js/search.js'; + +interface Excerpt { + pre: string; + match: string; + post: string; + segments?: { text: string; emphasized: boolean }[]; +} +interface FlatRange { + startIndex: number; + startOffset: number; + endIndex: number; + endOffset: number; +} +interface Result { + range: FlatRange; + excerpt: Excerpt; + subRanges?: FlatRange[]; +} + +const run = (strs: string[], query: string, opts: Record): Result[] => + [...search(strs, query, opts)] as Result[]; + +describe('regex mode', () => { + it('matches a pattern and reports the matched text', () => { + const r = run(['the cat sat on the mat'], 'c.t', { mode: 'regex' }); + expect(r.map((x) => x.excerpt.match)).toEqual(['cat']); + }); + + it('is case-insensitive by default and case-sensitive with matchCase', () => { + expect(run(['Cat cat'], 'cat', { mode: 'regex' }).length).toBe(2); + const cased = run(['Cat cat'], 'cat', { mode: 'regex', matchCase: true }); + expect(cased.length).toBe(1); + expect(cased[0]!.excerpt.match).toBe('cat'); + }); + + it('maps a match spanning multiple text nodes back to a range', () => { + const r = run(['the qu', 'ick fox'], 'qu.ck', { mode: 'regex' }); + expect(r.length).toBe(1); + expect(r[0]!.excerpt.match).toBe('quick'); + expect(r[0]!.range.startIndex).toBe(0); + expect(r[0]!.range.endIndex).toBe(1); + }); + + it('does not loop forever on a zero-width pattern', () => { + expect(run(['abc'], 'x*', { mode: 'regex' })).toEqual([]); + }); + + it('throws a typed error on an invalid pattern', () => { + expect(() => run(['abc'], '(', { mode: 'regex' })).toThrow(/regular expression/i); + try { + run(['abc'], '(', { mode: 'regex' }); + } catch (e) { + expect((e as { code?: string }).code).toBe('INVALID_REGEX'); + } + }); +}); + +describe('nearby-words mode', () => { + const opts = (nearbyWords: number) => ({ mode: 'nearby-words', nearbyWords }); + + it('matches when all words fall within N words of each other', () => { + const r = run(['alpha one two three beta end'], 'alpha beta', opts(10)); + expect(r.length).toBe(1); + expect(r[0]!.excerpt.match).toContain('alpha'); + expect(r[0]!.excerpt.match).toContain('beta'); + expect(r[0]!.subRanges?.length).toBe(2); + }); + + it('rejects clusters whose word span exceeds N', () => { + expect(run(['alpha one two three beta'], 'alpha beta', opts(2)).length).toBe(0); + }); + + it('is order independent', () => { + const r = run(['beta x y alpha'], 'alpha beta', opts(10)); + expect(r.length).toBe(1); + }); + + it('emits one cluster per non-overlapping occurrence pair', () => { + const r = run(['alpha beta gamma delta alpha beta'], 'alpha beta', opts(3)); + expect(r.length).toBe(2); + }); + + it('produces a segmented excerpt emphasizing only the matched words', () => { + const r = run(['alpha one beta'], 'alpha beta', opts(10)); + const segments = r[0]!.excerpt.segments ?? []; + const emphasized = segments.filter((s) => s.emphasized).map((s) => s.text); + expect(emphasized).toEqual(['alpha', 'beta']); + expect(segments.some((s) => !s.emphasized && s.text.includes('one'))).toBe(true); + }); + + it('throws when fewer than two distinct words are given', () => { + expect(() => run(['alpha alpha'], 'alpha', opts(10))).toThrow(); + try { + run(['alpha'], 'alpha', opts(10)); + } catch (e) { + expect((e as { code?: string }).code).toBe('NEARBY_NEEDS_TWO_WORDS'); + } + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/serializer.test.ts b/apps/readest-app/src/__tests__/utils/serializer.test.ts index 4117ddb9..b37d5bfb 100644 --- a/apps/readest-app/src/__tests__/utils/serializer.test.ts +++ b/apps/readest-app/src/__tests__/utils/serializer.test.ts @@ -145,6 +145,40 @@ describe('BookConfig serialization', () => { expect(parsed.viewSettings.annotationToolbarItems).toEqual(['copy']); }); + it('migrates v2 search config: matchWholeWords:true -> mode "whole-words"', () => { + const config = deserializeConfig( + JSON.stringify({ schemaVersion: 2, searchConfig: { matchWholeWords: true } }), + globalViewSettings, + defaultSearchConfig, + ); + const sc = config.searchConfig as BookSearchConfig; + expect(sc.mode).toBe('whole-words'); + expect(sc.matchWholeWords).toBe(true); + expect(sc.nearbyWords).toBe(10); + }); + + it('migrates v2 search config: matchWholeWords:false -> mode "contains"', () => { + const config = deserializeConfig( + JSON.stringify({ schemaVersion: 2, searchConfig: { matchWholeWords: false } }), + globalViewSettings, + defaultSearchConfig, + ); + const sc = config.searchConfig as BookSearchConfig; + expect(sc.mode).toBe('contains'); + expect(sc.matchWholeWords).toBe(false); + }); + + it('preserves an explicit mode and mirrors the deprecated boolean', () => { + const config = deserializeConfig( + JSON.stringify({ schemaVersion: 3, searchConfig: { mode: 'regex' } }), + globalViewSettings, + defaultSearchConfig, + ); + const sc = config.searchConfig as BookSearchConfig; + expect(sc.mode).toBe('regex'); + expect(sc.matchWholeWords).toBe(false); + }); + it('does not migrate annotations when schemaVersion is already 2', () => { const config = deserializeConfig( JSON.stringify({ diff --git a/apps/readest-app/src/app/reader/components/sidebar/SearchBar.tsx b/apps/readest-app/src/app/reader/components/sidebar/SearchBar.tsx index d93ae6e7..9789bf5a 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/SearchBar.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/SearchBar.tsx @@ -38,12 +38,12 @@ const SearchBar: React.FC = ({ isVisible, bookKey, onHideSearchB const { getBookData } = useBookDataStore(); const { getConfig, setConfig, saveConfig } = useBookDataStore(); const { getView, getProgress, getViewSettings } = useReaderStore(); - const { setSearchTerm, setSearchResults, setSearchProgress } = useSidebarStore(); + const { setSearchTerm, setSearchResults, setSearchProgress, setSearchError } = useSidebarStore(); const { getSearchNavState, getSearchStatus, setSearchStatus } = useSidebarStore(); const viewSettings = getViewSettings(bookKey); const searchNavState = getSearchNavState(bookKey); - const { searchTerm } = searchNavState; + const { searchTerm, searchError } = searchNavState; const queuedSearchTerm = useRef(''); const inputRef = useRef(null); const inputFocusedRef = useRef(false); @@ -94,9 +94,10 @@ const SearchBar: React.FC = ({ isVisible, bookKey, onHideSearchB const getSearchCacheKey = useCallback((term: string, config: BookSearchConfig) => { const configStr = JSON.stringify({ scope: config.scope, + mode: config.mode, matchCase: config.matchCase, - matchWholeWords: config.matchWholeWords, matchDiacritics: config.matchDiacritics, + nearbyWords: config.nearbyWords, }); return md5(`${term}-${configStr}`); }, []); @@ -158,6 +159,7 @@ const SearchBar: React.FC = ({ isVisible, bookKey, onHideSearchB const bookData = getBookData(bookKey)!; const progress = getProgress(bookKey)!; const primaryLang = bookData.book?.primaryLanguage || 'en'; + const searchMode = (config.searchConfig as BookSearchConfig).mode; const iconSize12 = useResponsiveSize(12); const iconSize16 = useResponsiveSize(16); @@ -209,11 +211,14 @@ const SearchBar: React.FC = ({ isVisible, bookKey, onHideSearchB const handleSearchConfigChange = (searchConfig: BookSearchConfig) => { setConfig(bookKey, { searchConfig: { ...searchConfig } }); - saveConfig(envConfig, bookKey, config, settings); + // setConfig is synchronous, so getConfig now returns the merged config to persist. + saveConfig(envConfig, bookKey, getConfig(bookKey)!, settings); handleSearchTermChange(searchTerm); }; const exceedMinSearchTermLength = (searchTerm: string) => { + // Regex patterns can be a single character (e.g. \d), so bypass the gate. + if (searchMode === 'regex') return searchTerm.length >= 1; const minLength = isCJKStr(searchTerm) ? MINIMUM_SEARCH_TERM_LENGTH_CJK : MINIMUM_SEARCH_TERM_LENGTH_DEFAULT; @@ -225,7 +230,11 @@ const SearchBar: React.FC = ({ isVisible, bookKey, onHideSearchB async (term: string) => { console.log('searching for:', term); - const searchConfig = config.searchConfig as BookSearchConfig; + // Read the latest config from the store, not the render closure: an option + // change (e.g. "within N words") calls setConfig then triggers this search + // synchronously, before this callback is recreated — so the closure's + // `config` is stale by one change. getConfig reflects the just-set value. + const searchConfig = getConfig(bookKey)!.searchConfig as BookSearchConfig; const cachedResults = await getSearchCache(term, searchConfig); if (cachedResults) { setSearchResults(bookKey, cachedResults); @@ -238,6 +247,7 @@ const SearchBar: React.FC = ({ isVisible, bookKey, onHideSearchB // Reset progress at start of search setSearchProgress(bookKey, 0); setSearchStatus(bookKey, 'searching'); + setSearchError(bookKey, null); const { section } = progress; const index = searchConfig.scope === 'section' ? section.current : undefined; @@ -256,42 +266,57 @@ const SearchBar: React.FC = ({ isVisible, bookKey, onHideSearchB let lastProgressLogTime = 0; const processResults = async () => { - for await (const result of generator) { - if (getSearchStatus(bookKey) === 'terminated') { - console.log('search terminated'); - return; - } - if (typeof result === 'string') { - if (result === 'done') { - setSearchStatus(bookKey, 'completed'); - setSearchResults(bookKey, [...results]); - setSearchProgress(bookKey, 1); - if (results.length > 0) { - addToHistory(term); - await saveSearchCache(term, searchConfig, results); - } - console.log('search done'); + try { + for await (const result of generator) { + if (getSearchStatus(bookKey) === 'terminated') { + console.log('search terminated'); + return; } - } else { - if (result.progress) { - setSearchProgress(bookKey, result.progress); - const now = Date.now(); - if (now - lastProgressLogTime >= 1000) { - console.log('search progress:', result.progress); - lastProgressLogTime = now; - } - if (queuedSearchTerm.current !== term) { - console.log('search term changed, resetting search'); - resetSearch(); - return; + if (typeof result === 'string') { + if (result === 'done') { + setSearchStatus(bookKey, 'completed'); + setSearchResults(bookKey, [...results]); + setSearchProgress(bookKey, 1); + if (results.length > 0) { + addToHistory(term); + await saveSearchCache(term, searchConfig, results); + } + console.log('search done'); } } else { - results.push(result); - setSearchResults(bookKey, [...results]); + if (result.progress) { + setSearchProgress(bookKey, result.progress); + const now = Date.now(); + if (now - lastProgressLogTime >= 1000) { + console.log('search progress:', result.progress); + lastProgressLogTime = now; + } + if (queuedSearchTerm.current !== term) { + console.log('search term changed, resetting search'); + resetSearch(); + return; + } + } else { + results.push(result); + setSearchResults(bookKey, [...results]); + } } - } - await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } catch (err) { + const code = (err as { code?: string }).code; + const message = + code === 'INVALID_REGEX' + ? _('Invalid regular expression') + : code === 'NEARBY_NEEDS_TWO_WORDS' + ? _('Enter at least two words') + : _('Search failed'); + if (!code) console.error('search failed:', err); + setSearchError(bookKey, message); + setSearchResults(bookKey, []); + setSearchStatus(bookKey, 'completed'); + setSearchProgress(bookKey, 1); } }; @@ -300,9 +325,11 @@ const SearchBar: React.FC = ({ isVisible, bookKey, onHideSearchB // eslint-disable-next-line react-hooks/exhaustive-deps [ progress, - config.searchConfig, + bookKey, + getConfig, setSearchResults, setSearchProgress, + setSearchError, addToHistory, getSearchCache, saveSearchCache, @@ -340,7 +367,13 @@ const SearchBar: React.FC = ({ isVisible, bookKey, onHideSearchB value={searchTerm} spellCheck={false} onChange={handleInputChange} - placeholder={_('Search...')} + placeholder={ + searchMode === 'regex' + ? _('Search with regex') + : searchMode === 'nearby-words' + ? _('Words to find near each other') + : _('Search...') + } className='search-input w-full bg-transparent p-2 pr-0 ps-10 font-sans text-sm font-light focus:outline-none' /> @@ -382,6 +415,8 @@ const SearchBar: React.FC = ({ isVisible, bookKey, onHideSearchB + {searchError &&
{searchError}
} + {searchHistory.length > 0 && !searchTerm && (
void; + disabled?: boolean; + caption?: string; } -const Option: React.FC = ({ label, isActive, onClick }) => ( +const Option: React.FC = ({ label, isActive, onClick, disabled, caption }) => ( ); +const NEARBY_WORDS_PRESETS = [5, 10, 20, 50]; + const SearchOptions: React.FC = ({ isEink, searchConfig, @@ -41,10 +51,23 @@ const SearchOptions: React.FC = ({ setIsDropdownOpen, }) => { const _ = useTranslation(); - const updateConfig = (key: keyof BookSearchConfig, value: boolean | string) => { + const iconSize = useDefaultIconSize(); + // Align nested nearby controls with the option label column, not the checkmark + // icon. An option label sits at button p-2 (8px) + icon width + ml-2 gap (8px); + // this inline padding-inline-start replaces the row's own px-2 left padding. + const labelIndent = `${iconSize + 16}px`; + const updateConfig = (key: keyof BookSearchConfig, value: boolean | string | number) => { onSearchConfigChanged({ ...searchConfig, [key]: value }); setIsDropdownOpen?.(false); }; + const setMode = (mode: SearchMode) => { + onSearchConfigChanged({ ...searchConfig, mode, matchWholeWords: modeToWholeWords(mode) }); + setIsDropdownOpen?.(false); + }; + + const mode = searchConfig.mode; + // regex matches raw text, so the diacritics modifier has no effect there. + const diacriticsDisabled = mode === 'regex'; return (
= ({ onClick={() => updateConfig('scope', 'section')} /> +
diff --git a/apps/readest-app/src/app/reader/components/sidebar/SearchResults.tsx b/apps/readest-app/src/app/reader/components/sidebar/SearchResults.tsx index 0ea527b4..10b22377 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/SearchResults.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/SearchResults.tsx @@ -1,6 +1,8 @@ import React, { useMemo } from 'react'; import { BookSearchMatch, BookSearchResult, SearchExcerpt } from '@/types/book'; import { useReaderStore } from '@/store/readerStore'; +import { useSidebarStore } from '@/store/sidebarStore'; +import { useTranslation } from '@/hooks/useTranslation'; import { findNearestCfi } from '@/utils/cfi'; import useScrollToItem from '../../hooks/useScrollToItem'; import clsx from 'clsx'; @@ -13,6 +15,34 @@ interface SearchResultItemProps { onSelectResult: (cfi: string) => void; } +// nearby-words excerpts emphasize each matched word; other modes bold the single match span. +const ExcerptBody: React.FC<{ excerpt: SearchExcerpt }> = ({ excerpt }) => { + if (excerpt.segments) { + return ( + <> + {excerpt.pre} + {excerpt.segments.map((seg, i) => + seg.emphasized ? ( + + {seg.text} + + ) : ( + {seg.text} + ), + )} + {excerpt.post} + + ); + } + return ( + <> + {excerpt.pre} + {excerpt.match} + {excerpt.post} + + ); +}; + const SearchResultItem: React.FC = ({ bookKey, cfi, @@ -44,9 +74,7 @@ const SearchResultItem: React.FC = ({ }} >
- {excerpt.pre} - {excerpt.match} - {excerpt.post} +
); @@ -58,8 +86,11 @@ interface SearchResultsProps { } const SearchResults: React.FC = ({ bookKey, results, onSelectResult }) => { + const _ = useTranslation(); const { getProgress } = useReaderStore(); + const { getSearchNavState } = useSidebarStore(); const progress = getProgress(bookKey); + const { searchProgress, searchError } = getSearchNavState(bookKey); const nearestCfi = useMemo(() => { const allCfis: string[] = []; @@ -73,6 +104,23 @@ const SearchResults: React.FC = ({ bookKey, results, onSelec return findNearestCfi(allCfis, progress?.location); }, [progress?.location, results]); + const totalMatches = useMemo( + () => + results.reduce((sum, result) => sum + ('subitems' in result ? result.subitems.length : 1), 0), + [results], + ); + + // The error itself is surfaced in the search bar; once the search has finished + // with no hits, say so instead of leaving a blank panel. + if (results.length === 0) { + if (searchError || searchProgress < 1) return null; + return ( +
+ {_('No results found')} +
+ ); + } + return (
    @@ -109,6 +157,11 @@ const SearchResults: React.FC = ({ bookKey, results, onSelec } })}
+ {searchProgress >= 1 && ( +
+ {_('{{count}} results', { count: totalMatches })} +
+ )}
); }; diff --git a/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx b/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx index c7dced74..6c95a19c 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/SideBar.tsx @@ -32,11 +32,11 @@ const SideBar = ({}) => { const { updateAppTheme, safeAreaInsets, systemUIVisible, statusBarHeight } = useThemeStore(); const { sideBarBookKey, setSideBarBookKey, getSearchNavState, setSearchTerm, clearSearch } = useSidebarStore(); + const { isSearchBarVisible, setSearchBarVisible } = useSidebarStore(); const searchNavState = sideBarBookKey ? getSearchNavState(sideBarBookKey) : null; const { searchTerm = '', searchResults = null } = searchNavState || {}; const { getBookData } = useBookDataStore(); const { getView, getViewSettings } = useReaderStore(); - const [isSearchBarVisible, setIsSearchBarVisible] = useState(false); const searchTermRef = useRef(searchTerm); const isMobile = window.innerWidth < 640; const [isFullHeightInMobile, setIsFullHeightInMobile] = useState(isMobile); @@ -57,7 +57,7 @@ const SideBar = ({}) => { const { term, bookKey } = event.detail; setSideBarVisible(true); setSideBarBookKey(bookKey); - setIsSearchBarVisible(true); + setSearchBarVisible(true); if (term !== undefined && term !== null) { setSearchTerm(bookKey, term); } @@ -122,22 +122,23 @@ const SideBar = ({}) => { }; const handleToggleSearchBar = () => { - setIsSearchBarVisible((prev) => { - if (prev) handleHideSearchBar(); - return !prev; - }); + if (isSearchBarVisible) { + handleHideSearchBar(); + } else { + setSearchBarVisible(true); + } }; const handleShowSearchBar = useCallback(() => { setTimeout(() => { setSideBarVisible(true); - setIsSearchBarVisible(true); + setSearchBarVisible(true); }, 100); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleHideSearchBar = useCallback(() => { - setIsSearchBarVisible(false); + setSearchBarVisible(false); setTimeout(() => { if (sideBarBookKey) clearSearch(sideBarBookKey); }, 100); diff --git a/apps/readest-app/src/app/reader/hooks/useSearchNav.ts b/apps/readest-app/src/app/reader/hooks/useSearchNav.ts index f9836a4e..391f1ebe 100644 --- a/apps/readest-app/src/app/reader/hooks/useSearchNav.ts +++ b/apps/readest-app/src/app/reader/hooks/useSearchNav.ts @@ -7,7 +7,7 @@ import { flattenSearchResults } from '../components/sidebar/SearchResultsNav'; export function useSearchNav(bookKey: string) { const getView = useReaderStore((s) => s.getView); - const { setSideBarVisible } = useSidebarStore(); + const { setSideBarVisible, setSearchBarVisible } = useSidebarStore(); const { getSearchNavState, setSearchResultIndex, clearSearch } = useSidebarStore(); const searchNavState = getSearchNavState(bookKey); @@ -83,8 +83,11 @@ export function useSearchNav(bookKey: string) { const handleCloseSearch = useCallback(() => { clearSearch(bookKey); + // Exit the sidebar's search mode too, not just the results — otherwise + // reopening the sidebar still shows the (empty) search bar. + setSearchBarVisible(false); getView(bookKey)?.clearSearch(); - }, [clearSearch, bookKey, getView]); + }, [clearSearch, bookKey, getView, setSearchBarVisible]); // Navigate to the previous page with results (last result before current page) const handlePreviousResult = useCallback(() => { diff --git a/apps/readest-app/src/services/constants.ts b/apps/readest-app/src/services/constants.ts index b35acd72..ce59e61a 100644 --- a/apps/readest-app/src/services/constants.ts +++ b/apps/readest-app/src/services/constants.ts @@ -424,9 +424,12 @@ export const DEFAULT_SCREEN_CONFIG: ScreenConfig = { export const DEFAULT_BOOK_SEARCH_CONFIG: BookSearchConfig = { scope: 'book', + mode: 'contains', matchCase: false, - matchWholeWords: false, matchDiacritics: false, + nearbyWords: 10, + // kept for sync wire back-compat with pre-v3 clients (mirrors mode === 'whole-words') + matchWholeWords: false, }; export const DEFAULT_VIEW_SETTINGS_CONFIG: ViewSettingsConfig = { diff --git a/apps/readest-app/src/store/sidebarStore.ts b/apps/readest-app/src/store/sidebarStore.ts index a52d0b19..749b2583 100644 --- a/apps/readest-app/src/store/sidebarStore.ts +++ b/apps/readest-app/src/store/sidebarStore.ts @@ -9,6 +9,7 @@ interface SearchNavState { searchResults: BookSearchResult[] | BookSearchMatch[] | null; searchResultIndex: number; searchProgress: number; // 0 to 1, where 1 means search complete + searchError: string | null; // invalid regex / nearby-words parse error, shown inline } // Per-book booknotes navigation state @@ -23,6 +24,7 @@ interface SidebarState { sideBarWidth: string; isSideBarVisible: boolean; isSideBarPinned: boolean; + isSearchBarVisible: boolean; // Per-book navigation states searchNavStates: Record; booknotesNavStates: Record; @@ -35,6 +37,7 @@ interface SidebarState { toggleSideBarPin: () => void; setSideBarVisible: (visible: boolean) => void; setSideBarPin: (pinned: boolean) => void; + setSearchBarVisible: (visible: boolean) => void; // Search actions (per bookKey) getSearchNavState: (bookKey: string) => SearchNavState; setSearchTerm: (bookKey: string, term: string) => void; @@ -46,6 +49,7 @@ interface SidebarState { ) => void; setSearchResultIndex: (bookKey: string, index: number) => void; setSearchProgress: (bookKey: string, progress: number) => void; + setSearchError: (bookKey: string, error: string | null) => void; clearSearch: (bookKey: string) => void; // Booknotes navigation actions (per bookKey) getBooknotesNavState: (bookKey: string) => BooknotesNavState; @@ -60,6 +64,7 @@ const defaultSearchNavState: SearchNavState = { searchResults: null, searchResultIndex: 0, searchProgress: 1, + searchError: null, }; const defaultBooknotesNavState: BooknotesNavState = { @@ -73,6 +78,7 @@ export const useSidebarStore = create((set, get) => ({ sideBarWidth: '', isSideBarVisible: false, isSideBarPinned: false, + isSearchBarVisible: false, // Per-book navigation states searchNavStates: {}, booknotesNavStates: {}, @@ -85,6 +91,7 @@ export const useSidebarStore = create((set, get) => ({ toggleSideBarPin: () => set((state) => ({ isSideBarPinned: !state.isSideBarPinned })), setSideBarVisible: (visible: boolean) => set({ isSideBarVisible: visible }), setSideBarPin: (pinned: boolean) => set({ isSideBarPinned: pinned }), + setSearchBarVisible: (visible: boolean) => set({ isSearchBarVisible: visible }), // Search actions getSearchStatus: (bookKey: string) => { return get().searchStatuses[bookKey] || null; @@ -132,6 +139,16 @@ export const useSidebarStore = create((set, get) => ({ }, }, })), + setSearchError: (bookKey: string, error: string | null) => + set((state) => ({ + searchNavStates: { + ...state.searchNavStates, + [bookKey]: { + ...(state.searchNavStates[bookKey] || defaultSearchNavState), + searchError: error, + }, + }, + })), clearSearch: (bookKey: string) => set((state) => ({ searchNavStates: { diff --git a/apps/readest-app/src/types/book.ts b/apps/readest-app/src/types/book.ts index fbfdc9b7..c4fbd464 100644 --- a/apps/readest-app/src/types/book.ts +++ b/apps/readest-app/src/types/book.ts @@ -420,11 +420,17 @@ export interface BookProgress { page: number; } +export type SearchMode = 'contains' | 'whole-words' | 'regex' | 'nearby-words'; + export interface BookSearchConfig { scope: 'book' | 'section'; + mode: SearchMode; matchCase: boolean; - matchWholeWords: boolean; matchDiacritics: boolean; + // nearby-words: maximum number of words separating the matched words + nearbyWords?: number; + /** @deprecated since schema v3 — mirrors `mode === 'whole-words'`; kept for sync wire back-compat. */ + matchWholeWords?: boolean; index?: number; query?: string; acceptNode?: (node: Node) => number; @@ -437,10 +443,14 @@ export interface SearchExcerpt { pre: string; match: string; post: string; + // nearby-words: the cluster window split into matched (emphasized) words and gaps + segments?: { text: string; emphasized: boolean }[]; } export interface BookSearchMatch { cfi: string; + // nearby-words: per-word CFIs to highlight (>= 2); absent for single-span matches + cfis?: string[]; excerpt: SearchExcerpt; } @@ -451,7 +461,7 @@ export interface BookSearchResult { progress?: number; } -export const BOOK_CONFIG_SCHEMA_VERSION = 2; +export const BOOK_CONFIG_SCHEMA_VERSION = 3; export interface BookConfig { schemaVersion?: number; diff --git a/apps/readest-app/src/utils/searchConfig.ts b/apps/readest-app/src/utils/searchConfig.ts new file mode 100644 index 00000000..5b7899e6 --- /dev/null +++ b/apps/readest-app/src/utils/searchConfig.ts @@ -0,0 +1,10 @@ +import { BookSearchConfig, SearchMode } from '@/types/book'; + +export const DEFAULT_NEARBY_WORDS = 10; + +export const modeToWholeWords = (mode: SearchMode): boolean => mode === 'whole-words'; + +// v2 configs (and pre-v3 sync peers) encode whole-word matching as a boolean and +// have no `mode`. Derive the mode from the boolean when `mode` is absent. +export const ensureSearchMode = (config: Partial): SearchMode => + config.mode ?? (config.matchWholeWords ? 'whole-words' : 'contains'); diff --git a/apps/readest-app/src/utils/serializer.ts b/apps/readest-app/src/utils/serializer.ts index e2531fc5..310dbdef 100644 --- a/apps/readest-app/src/utils/serializer.ts +++ b/apps/readest-app/src/utils/serializer.ts @@ -5,6 +5,7 @@ import { ViewSettings, } from '@/types/book'; import { unifyAnnotations } from '@/utils/booknoteMigration'; +import { DEFAULT_NEARBY_WORDS, ensureSearchMode, modeToWholeWords } from '@/utils/searchConfig'; export const stampBookConfigSchema = >(config: T): T => { return { ...config, schemaVersion: BOOK_CONFIG_SCHEMA_VERSION }; @@ -74,6 +75,13 @@ export const deserializeConfig = ( 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) { diff --git a/packages/foliate-js b/packages/foliate-js index 20ab3ec1..982f168c 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 20ab3ec1fd6ca48d72d803f7331b2eeae3335a74 +Subproject commit 982f168c668dedcf38b2304bec788337712d60aa