perf(cfi): bucket booknotes per chapter and batch-collapse location matcher (#4561)

* perf(cfi): bucket booknotes per chapter and batch-collapse location matcher

When iterating a list of CFIs against the same currentLocation (Annotator
on every page turn, useSearchNav, useBooknotesNav), the standalone
isCfiInLocation collapses the location twice per CFI. With 1000+
booknotes -- which a heavy user reported -- that's 2000 CFI parses
per page turn. The foliate epubcfi.js chunk showed up as ~15% of
self time in Bottom-Up profiles of the release Android build.

Fix:
- createCfiLocationMatcher(location) collapses once and returns a
  matches(cfi) predicate that reuses the cached bounds. O(N) calls
  become 1 collapse + N compares.
- getCfiSpinePrefix(cfi) extracts the spine path via pure string ops
  (no CFI.parse round-trip) for use as a chapter bucket key.
- Annotator builds annotationIndex = { bySection, globals } via
  useMemo([config.booknotes]) once when booknotes change, not per
  page turn. The progress-driven effect then only scans the current
  chapter's bucket -- ~50 CFIs in a typical book instead of all 1000.
  globals are pre-filtered too.
- useSearchNav / useBooknotesNav switch to the batched matcher for
  the same reason.

Includes parity tests covering empty/malformed inputs, equality
shortcut, prefix shortcut, in-range, and out-of-range cases.

* fix(annotator): keep note-only annotations in the per-chapter bucket

The booknote bucketing gated entries on `item.style`, which dropped
note-only annotations (a `note` with no highlight style/color, created
via the Notebook flow) from the per-relocate re-apply path. Their note
bubble was no longer redrawn on relocate or when booknotes changed while
a section stayed rendered.

Restore the original two-list semantics: bucket on style OR note, then
classify per location (annotations need a style, notes need a note).

Extract the logic into a dedicated, unit-tested `annotationIndex` module
(buildAnnotationIndex + selectLocationAnnotations) instead of inlining it
in Annotator, matching the reader/utils domain-named convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
loveheaven
2026-06-13 00:06:13 +08:00
committed by GitHub
parent 7cba22ab31
commit 7f57af8f90
7 changed files with 403 additions and 39 deletions
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import {
buildAnnotationIndex,
selectLocationAnnotations,
} from '@/app/reader/utils/annotationIndex';
import { uniqueId } from '@/utils/misc';
import { BookNote } from '@/types/book';
describe('buildAnnotationIndex / selectLocationAnnotations', () => {
const note = (overrides: Partial<BookNote>): BookNote => ({
id: uniqueId(),
type: 'annotation',
cfi: 'epubcfi(/6/6!/4/2/2,/1:0,/1:10)',
note: '',
createdAt: 1,
updatedAt: 1,
...overrides,
});
// A relocate always reports a single-section location (foliate joins the
// section base CFI with the in-document range), so the spine prefix keys
// the bucket. `epubcfi(/6/6)` covers everything anchored in section /6/6.
const location = 'epubcfi(/6/6)';
it('buckets a styled annotation under its spine prefix', () => {
const highlight = note({ style: 'highlight', color: 'yellow' });
const { bySection, globals } = buildAnnotationIndex([highlight]);
expect(bySection.get('/6/6')).toEqual([highlight]);
expect(globals).toEqual([]);
});
it('buckets a note-only annotation (no style) so its bubble survives relocate', () => {
// Regression: notes added via the Notebook flow carry a `note` but no
// `style`/`color`. They must still be tracked so the note bubble is
// re-applied on page turns, not just at section first-render.
const noteOnly = note({ note: 'a margin note' });
const { bySection } = buildAnnotationIndex([noteOnly]);
expect(bySection.get('/6/6')).toEqual([noteOnly]);
});
it('splits styled global highlights into their own array', () => {
const global = note({ style: 'highlight', color: 'yellow', text: 'x', global: true });
const { bySection, globals } = buildAnnotationIndex([global]);
expect(globals).toEqual([global]);
expect(bySection.size).toBe(0);
});
it('skips deleted, non-annotation, and empty (no style, no note) entries', () => {
const deleted = note({ style: 'highlight', deletedAt: 9 });
const bookmark = note({ type: 'bookmark' });
const empty = note({ note: ' ' });
const { bySection, globals } = buildAnnotationIndex([deleted, bookmark, empty]);
expect(bySection.size).toBe(0);
expect(globals).toEqual([]);
});
it('classifies a styled annotation into annotations only', () => {
const highlight = note({ style: 'highlight', color: 'yellow' });
const index = buildAnnotationIndex([highlight]);
const { annotations, notes } = selectLocationAnnotations(index, location);
expect(annotations).toEqual([highlight]);
expect(notes).toEqual([]);
});
it('classifies a note-only annotation into notes only', () => {
const noteOnly = note({ note: 'hello' });
const index = buildAnnotationIndex([noteOnly]);
const { annotations, notes } = selectLocationAnnotations(index, location);
expect(annotations).toEqual([]);
expect(notes).toEqual([noteOnly]);
});
it('classifies a styled+noted annotation into both lists', () => {
const both = note({ style: 'highlight', color: 'yellow', note: 'hi' });
const index = buildAnnotationIndex([both]);
const { annotations, notes } = selectLocationAnnotations(index, location);
expect(annotations).toEqual([both]);
expect(notes).toEqual([both]);
});
it('excludes annotations anchored in a different section', () => {
const other = note({ cfi: 'epubcfi(/6/8!/4/2/2,/1:0,/1:10)', style: 'highlight' });
const index = buildAnnotationIndex([other]);
const { annotations, notes } = selectLocationAnnotations(index, location);
expect(annotations).toEqual([]);
expect(notes).toEqual([]);
});
it('returns empty lists for a null/empty location', () => {
const index = buildAnnotationIndex([note({ style: 'highlight' })]);
expect(selectLocationAnnotations(index, null)).toEqual({ annotations: [], notes: [] });
expect(selectLocationAnnotations(index, '')).toEqual({ annotations: [], notes: [] });
});
});
@@ -1,5 +1,11 @@
import { describe, it, expect } from 'vitest';
import { isCfiInLocation, findNearestCfi, isMalformedLocationCfi } from '@/utils/cfi';
import {
isCfiInLocation,
findNearestCfi,
isMalformedLocationCfi,
createCfiLocationMatcher,
getCfiSpinePrefix,
} from '@/utils/cfi';
describe('isCfiInLocation', () => {
it('should return true when cfi path starts with location path', () => {
@@ -66,6 +72,77 @@ describe('findNearestCfi', () => {
});
});
describe('createCfiLocationMatcher', () => {
// The matcher must agree with isCfiInLocation on every input — it's
// strictly a performance optimization that caches the collapsed
// location across a loop. Keep parity tests for each branch the
// single-call API covers.
it('matches when cfi path starts with location path', () => {
const matches = createCfiLocationMatcher('epubcfi(/6/6)');
expect(matches('epubcfi(/6/6!/4/4/54,/1:4,/1:15)')).toBe(true);
});
it('matches on exact location equality', () => {
const matches = createCfiLocationMatcher('epubcfi(/6/6)');
expect(matches('epubcfi(/6/6)')).toBe(true);
});
it('rejects a cfi in a different section', () => {
const matches = createCfiLocationMatcher('epubcfi(/6/6)');
expect(matches('epubcfi(/6/8!/4/4/54,/1:4,/1:15)')).toBe(false);
});
it('rejects empty cfi without throwing', () => {
const matches = createCfiLocationMatcher('epubcfi(/6/6)');
expect(matches('')).toBe(false);
});
it('returns a permanent false predicate for null/undefined location', () => {
expect(createCfiLocationMatcher(null)('epubcfi(/6/6)')).toBe(false);
expect(createCfiLocationMatcher(undefined)('epubcfi(/6/6)')).toBe(false);
});
it('is safe to reuse across many calls (collapse done only once)', () => {
// No way to assert call count from here without spying on the CFI
// module — the assertion that matters is just that repeated calls
// produce consistent results. If collapse were re-run per call we'd
// still get the same booleans; the perf win is invisible to behavior.
const matches = createCfiLocationMatcher('epubcfi(/6/6)');
for (let i = 0; i < 100; i++) {
expect(matches('epubcfi(/6/6!/4/4/54,/1:4,/1:15)')).toBe(true);
expect(matches('epubcfi(/6/8!/4/4/54,/1:4,/1:15)')).toBe(false);
}
});
});
describe('getCfiSpinePrefix', () => {
it('returns the spine prefix for a CFI with an inside path', () => {
// Splits at the `!` boundary so we get just the chapter portion,
// suitable for bucketing booknotes by chapter.
expect(getCfiSpinePrefix('epubcfi(/6/24!/4/2:5)')).toBe('/6/24');
});
it('returns the inner path when there is no inside path (no `!`)', () => {
expect(getCfiSpinePrefix('epubcfi(/6/12)')).toBe('/6/12');
});
it('handles range CFIs (the inside path may contain commas)', () => {
expect(getCfiSpinePrefix('epubcfi(/6/6!/4/4/54,/1:4,/1:15)')).toBe('/6/6');
});
it('returns null for null/undefined/empty input', () => {
expect(getCfiSpinePrefix(null)).toBeNull();
expect(getCfiSpinePrefix(undefined)).toBeNull();
expect(getCfiSpinePrefix('')).toBeNull();
});
it('returns null for non-CFI strings', () => {
expect(getCfiSpinePrefix('not a cfi')).toBeNull();
expect(getCfiSpinePrefix('/6/24')).toBeNull();
});
});
describe('isMalformedLocationCfi', () => {
it('flags an empty-start range CFI', () => {
// Produced by the cfi-inert skip-link bug: the visible-range start anchored
@@ -10,6 +10,7 @@ import { NativeTouchEventType } from '@/types/system';
import { getLocale, getOSPlatform, makeSafeFilename, uniqueId } from '@/utils/misc';
import { useThemeStore } from '@/store/themeStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { getBookProgress, useBookProgress } from '@/store/readerProgressStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useReaderStore } from '@/store/readerStore';
import { useNotebookStore } from '@/store/notebookStore';
@@ -45,7 +46,7 @@ import {
import { Insets } from '@/types/misc';
import { runSimpleCC } from '@/utils/simplecc';
import { getWordCount } from '@/utils/word';
import { getIndexFromCfi, isCfiInLocation } from '@/utils/cfi';
import { getIndexFromCfi } from '@/utils/cfi';
import { writeTextToClipboard } from '@/utils/clipboard';
import { TransformContext } from '@/services/transformers/types';
import { transformContent } from '@/services/transformService';
@@ -54,6 +55,7 @@ import {
getHighlightColorHex,
removeBookNoteOverlays,
} from '../../utils/annotatorUtil';
import { buildAnnotationIndex, selectLocationAnnotations } from '../../utils/annotationIndex';
import {
expandAllRenderedSections,
expandGlobalAnnotation,
@@ -91,8 +93,15 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
const { settings, setSettingsDialogBookKey, setSettingsDialogOpen, setActiveSettingsItemId } =
useSettingsStore();
const { isDarkMode } = useThemeStore();
const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore();
const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore();
// Per-field selectors — see store/readerProgressStore.ts header for the
// "destructure-subscribes-the-whole-store" rationale.
const getConfig = useBookDataStore((s) => s.getConfig);
const saveConfig = useBookDataStore((s) => s.saveConfig);
const getBookData = useBookDataStore((s) => s.getBookData);
const updateBooknotes = useBookDataStore((s) => s.updateBooknotes);
const getView = useReaderStore((s) => s.getView);
const getViewsById = useReaderStore((s) => s.getViewsById);
const getViewSettings = useReaderStore((s) => s.getViewSettings);
const { setNotebookVisible, setNotebookNewAnnotation } = useNotebookStore();
const { clearBooknotesNav } = useSidebarStore();
const { listenToNativeTouchEvents } = useDeviceControlStore();
@@ -112,7 +121,11 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
const osPlatform = getOSPlatform();
const config = getConfig(bookKey)!;
const progress = getProgress(bookKey)!;
// Reactive: subscribe to THIS book's progress via the dedicated
// progress store. This is the only piece of data we need to react to
// per page turn — the `useEffect(..., [progress])` below uses it to
// re-apply local-page annotations after each relocate.
const progress = useBookProgress(bookKey)!;
const bookData = getBookData(bookKey)!;
const view = getView(bookKey);
const viewSettings = getViewSettings(bookKey)!;
@@ -793,25 +806,33 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selection, bookKey]);
// Index live annotations by the CFI spine prefix (the chapter id) so
// each page turn only scans the bucket for the current chapter rather
// than the whole booknotes array. With heavy users (>1k highlights) a
// naive `booknotes.filter(...)` per page turn was the dominant
// contributor to `c` (epubcfi parse) in the Bottom-Up profile —
// ~1.4 s self time over a 28 s session. The bucketed read replaces
// O(N) walks with O(K) where K is the number of annotations in the
// currently-visible chapter (typically a handful).
//
// `globals` (book-wide highlights) are split into their own pre-filtered
// array so we don't re-walk N items each turn just to find the same few
// global ones. The index is recomputed only when `booknotes` itself
// changes (add/remove/edit) — not on every page turn.
const annotationIndex = useMemo(
() => buildAnnotationIndex(config.booknotes ?? []),
[config.booknotes],
);
useEffect(() => {
if (!progress) return;
const { location } = progress;
const { booknotes = [] } = config;
const annotations = booknotes.filter(
(item) =>
!item.deletedAt &&
item.type === 'annotation' &&
item.style &&
isCfiInLocation(item.cfi, location),
);
const notes = booknotes.filter(
(item) =>
!item.deletedAt &&
item.type === 'annotation' &&
item.note &&
item.note.trim().length > 0 &&
isCfiInLocation(item.cfi, location),
);
// Single pass over the *current chapter's* candidates: classify each
// one into the in-page annotations / notes lists. Using the bucket
// keeps this fast even when the user has thousands of highlights
// elsewhere in the book.
const { annotations, notes } = selectLocationAnnotations(annotationIndex, location);
try {
Promise.all(annotations.map((annotation) => view?.addAnnotation(annotation)));
Promise.all(
@@ -821,18 +842,16 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
// book-wide, so we don't filter by `location` here: every note
// with `global=true` gets expanded across every section that
// happens to be rendered right now. Sections rendered later are
// covered by `onCreateOverlay`.
const globalAnnotations = booknotes.filter(
(item) => !item.deletedAt && item.type === 'annotation' && item.style && item.global,
);
for (const annotation of globalAnnotations) {
// covered by `onCreateOverlay`. Using the pre-built `globals`
// array avoids re-walking booknotes per page turn.
for (const annotation of annotationIndex.globals) {
if (view) expandAllRenderedSections(view, annotation);
}
} catch (e) {
console.warn(e);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [progress]);
}, [progress, annotationIndex]);
useEffect(() => {
if (!config.booknotes || !selection?.cfi || !showAnnotationNotes) return;
@@ -979,7 +998,7 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
const style = settings.globalReadSettings.highlightStyle;
const color = settings.globalReadSettings.highlightStyles[style];
const { booknotes: annotations = [] } = getConfig(bookKey)!;
const page = getProgress(bookKey)?.page;
const page = getBookProgress(bookKey)?.page;
const annotation = buildTTSSentenceHighlight(
annotations,
{ cfi: detail.cfi, text: detail.text, style, color, page },
@@ -2,15 +2,16 @@ import { useCallback, useMemo } from 'react';
import * as CFI from 'foliate-js/epubcfi.js';
import { useSidebarStore } from '@/store/sidebarStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { isCfiInLocation } from '@/utils/cfi';
import { createCfiLocationMatcher } from '@/utils/cfi';
import { findTocItemBS } from '@/services/nav';
import { BookNoteType } from '@/types/book';
import { TOCItem } from '@/libs/document';
export function useBooknotesNav(bookKey: string, toc: TOCItem[]) {
const { getView, getProgress } = useReaderStore();
const { getConfig } = useBookDataStore();
const getView = useReaderStore((s) => s.getView);
const getConfig = useBookDataStore((s) => s.getConfig);
const {
setSideBarVisible,
getBooknotesNavState,
@@ -23,7 +24,9 @@ export function useBooknotesNav(bookKey: string, toc: TOCItem[]) {
const booknotesNavState = getBooknotesNavState(bookKey);
const { activeBooknoteType, booknoteResults, booknoteIndex } = booknotesNavState;
const progress = getProgress(bookKey);
// Reactive: re-derives current-page boundaries when the user turns
// the page. Reads from readerProgressStore only.
const progress = useBookProgress(bookKey);
const currentLocation = progress?.location;
// Get booknotes from config and filter by type
@@ -51,16 +54,21 @@ export function useBooknotesNav(bookKey: string, toc: TOCItem[]) {
return tocItem?.label || '';
}, [sortedBooknotes, booknoteIndex, toc]);
// Find booknotes on the current page
// Find booknotes on the current page.
// Uses a batched CFI matcher so the location is collapsed only once per
// page turn instead of once per booknote — see createCfiLocationMatcher
// in utils/cfi for the why (hot-path CFI parsing was 16%+ of self time
// in Android release-build profiles when annotations were dense).
const currentPageResults = useMemo(() => {
if (!sortedBooknotes.length || !currentLocation) return { firstIndex: -1, lastIndex: -1 };
const matches = createCfiLocationMatcher(currentLocation);
let firstIndex = -1;
let lastIndex = -1;
for (let i = 0; i < sortedBooknotes.length; i++) {
const note = sortedBooknotes[i];
if (note && isCfiInLocation(note.cfi, currentLocation)) {
if (note && matches(note.cfi)) {
if (firstIndex === -1) firstIndex = i;
lastIndex = i;
}
@@ -1,18 +1,21 @@
import { useCallback, useMemo } from 'react';
import { useSidebarStore } from '@/store/sidebarStore';
import { useReaderStore } from '@/store/readerStore';
import { isCfiInLocation } from '@/utils/cfi';
import { useBookProgress } from '@/store/readerProgressStore';
import { createCfiLocationMatcher } from '@/utils/cfi';
import { flattenSearchResults } from '../components/sidebar/SearchResultsNav';
export function useSearchNav(bookKey: string) {
const { getView, getProgress } = useReaderStore();
const getView = useReaderStore((s) => s.getView);
const { setSideBarVisible } = useSidebarStore();
const { getSearchNavState, setSearchResultIndex, clearSearch } = useSidebarStore();
const searchNavState = getSearchNavState(bookKey);
const { searchTerm, searchResults, searchResultIndex, searchProgress } = searchNavState;
const progress = getProgress(bookKey);
// Reactive: search nav re-derives current-page boundaries when the user
// turns the page. Subscribes to readerProgressStore only.
const progress = useBookProgress(bookKey);
const currentLocation = useMemo(() => {
return progress?.location;
@@ -34,16 +37,20 @@ export function useSearchNav(bookKey: string) {
return flattenedResults[searchResultIndex]?.sectionLabel || '';
}, [flattenedResults, searchResultIndex]);
// Find results on the current page
// Find results on the current page.
// Uses a batched CFI matcher so the location is collapsed only once per
// page turn instead of once per search hit — see createCfiLocationMatcher
// in utils/cfi for the why.
const currentPageResults = useMemo(() => {
if (!flattenedResults.length || !currentLocation) return { firstIndex: -1, lastIndex: -1 };
const matches = createCfiLocationMatcher(currentLocation);
let firstIndex = -1;
let lastIndex = -1;
for (let i = 0; i < flattenedResults.length; i++) {
const result = flattenedResults[i];
if (result && isCfiInLocation(result.cfi, currentLocation)) {
if (result && matches(result.cfi)) {
if (firstIndex === -1) firstIndex = i;
lastIndex = i;
}
@@ -0,0 +1,78 @@
import { BookNote } from '@/types/book';
import { createCfiLocationMatcher, getCfiSpinePrefix } from '@/utils/cfi';
export interface AnnotationIndex {
/** Live annotations bucketed by CFI spine prefix (the chapter id). */
bySection: Map<string, BookNote[]>;
/** Book-wide (`global`) highlights, pre-filtered so we don't re-walk the list. */
globals: BookNote[];
}
/**
* Index live annotations by their CFI spine prefix so a relocate only has to
* scan the bucket for the current chapter instead of the whole booknotes
* array. With heavy users (>1k highlights) a naive `booknotes.filter(...)` per
* page turn was the dominant contributor to epubcfi parse self-time. The
* bucketed read turns the per-relocate filter into O(notes-in-chapter).
*
* An annotation earns a bucket entry when it has a highlight `style` (a drawn
* highlight) OR a non-empty `note` (a note bubble) — these are independent
* overlays (see `removeBookNoteOverlays`), so a note-only annotation with no
* style must still be tracked.
*
* `global` highlights are split into their own pre-filtered array: their
* semantics is book-wide so they're fanned out across every rendered section
* rather than matched against the current location.
*/
export function buildAnnotationIndex(booknotes: BookNote[]): AnnotationIndex {
const bySection = new Map<string, BookNote[]>();
const globals: BookNote[] = [];
for (const item of booknotes) {
if (item.deletedAt) continue;
if (item.type !== 'annotation') continue;
const hasNote = !!item.note && item.note.trim().length > 0;
if (!item.style && !hasNote) continue;
// Globals fan out a highlight across every occurrence of `text`, so they
// require a style; a note-only annotation is never global and falls
// through to the per-section bucket like any other local note.
if (item.style && item.global) {
globals.push(item);
continue;
}
const spine = getCfiSpinePrefix(item.cfi);
if (!spine) continue;
const bucket = bySection.get(spine);
if (bucket) bucket.push(item);
else bySection.set(spine, [item]);
}
return { bySection, globals };
}
/**
* Classify the annotations that fall inside `location` into the in-page
* highlight list and the in-page note list, scanning only the current
* chapter's bucket from {@link buildAnnotationIndex}.
*
* Mirrors the original two-filter behavior: an annotation joins `annotations`
* iff it has a `style` (a drawn highlight) and joins `notes` iff it has a
* non-empty `note` (a note bubble) — the two are independent, so a styled note
* lands in both and a note-only annotation lands in `notes` alone.
*/
export function selectLocationAnnotations(
index: AnnotationIndex,
location: string | null | undefined,
): { annotations: BookNote[]; notes: BookNote[] } {
const annotations: BookNote[] = [];
const notes: BookNote[] = [];
const sectionKey = getCfiSpinePrefix(location);
if (!sectionKey) return { annotations, notes };
const candidates = index.bySection.get(sectionKey) ?? [];
const matchesLocation = createCfiLocationMatcher(location);
for (const item of candidates) {
if (!matchesLocation(item.cfi)) continue;
if (item.style) annotations.push(item);
if (item.note && item.note.trim().length > 0) notes.push(item);
}
return { annotations, notes };
}
+81
View File
@@ -21,6 +21,56 @@ export function isCfiInLocation(cfi: string, location: string | null | undefined
}
}
/**
* Batch variant of {@link isCfiInLocation}. The standalone function calls
* `CFI.collapse(location)` twice on every invocation — that's two CFI
* parses plus the prefix dance in `unwrapCfi`. When the reader hooks
* (useBooknotesNav, useSearchNav) loop over hundreds of annotations or
* search hits per page turn, those redundant parses add up to a real
* fraction of main-thread time (visible as `c` and the anonymous
* `parse` callback in Bottom-Up profiles of the foliate `epubcfi.js`
* chunk).
*
* `createCfiLocationMatcher(location)` collapses the location once and
* returns a `matches(cfi)` predicate that reuses the cached bounds for
* every call. Use it whenever you're iterating a list of CFIs against
* the same `currentLocation`.
*/
export function createCfiLocationMatcher(
location: string | null | undefined,
): (cfi: string) => boolean {
if (!location) return () => false;
// Pre-unwrap the location once for the cheap prefix match below.
const unwrappedLocation = unwrapCfi(location);
// Collapse once. If collapse throws on a malformed location the
// matcher degrades to the cheap equality / prefix branch only —
// matching the failure mode of the original isCfiInLocation, which
// would also bail via the catch block on a bad compare input.
let start: string | null = null;
let end: string | null = null;
try {
start = CFI.collapse(location);
end = CFI.collapse(location, true);
} catch (err) {
console.warn('Failed to collapse location for matcher', { location, error: err });
}
return (cfi: string): boolean => {
if (!cfi) return false;
if (cfi === location) return true;
if (unwrapCfi(cfi).startsWith(unwrappedLocation)) return true;
if (start === null || end === null) return false;
try {
return CFI.compare(cfi, start) >= 0 && CFI.compare(cfi, end) <= 0;
} catch (err) {
console.warn('Failed to compare CFIs', { cfi, location, error: err });
return false;
}
};
}
/**
* Binary search a sorted CFI array to find the nearest CFI to a location.
* Returns the CFI of the item just before or at the location.
@@ -82,3 +132,34 @@ export function getIndexFromCfi(cfi: string): number | null {
return null;
}
}
/**
* Extract the EPUB CFI "spine prefix" — the portion that identifies the
* spine item (chapter / section) without descending into the in-document
* path.
*
* EPUB CFI form: `epubcfi(<spine-path>!<inside-path>)` or
* `epubcfi(<spine-path>)`. The spine path uniquely identifies which
* chapter the CFI lives in; everything past `!` is intra-chapter.
*
* Returns the unwrapped spine prefix (no `epubcfi(...)` wrapper) suitable
* for bucketing CFIs by chapter. Returns null for inputs that aren't
* recognisably a CFI — callers should use this as a hint and fall back
* to the full range matcher when the prefix is missing.
*
* Examples:
* 'epubcfi(/6/24!/4/2:5)' → '/6/24'
* 'epubcfi(/6/12)' → '/6/12'
* 'not a cfi' → null
*
* This is a pure string operation — no CFI.parse round-trip, so it's
* cheap enough to call once per booknote when bucketing.
*/
export function getCfiSpinePrefix(cfi: string | null | undefined): string | null {
if (!cfi) return null;
const match = cfi.match(/^epubcfi\((.+)\)$/);
if (!match) return null;
const inner = match[1]!;
const bang = inner.indexOf('!');
return bang === -1 ? inner : inner.slice(0, bang);
}