refactor(toc): cache navigable structure per book (#3869)

* refactor(toc): cache TOC + section fragments per book

Moves the TOC regrouping and section-fragment computation out of
foliate-js/epub.js #updateSubItems into the readest client as
computeBookNav / hydrateBookNav in utils/toc.ts. The result is
persisted to Books/{hash}/nav.json — capturing the book's full
navigable structure (TOC hierarchy + sections with hierarchical
fragments). Compute once, persist locally, hydrate on subsequent
opens. Designed to serve current human-facing navigation (TOC
sidebar, progress math) and future agentic navigation (LLM-driven
seeking by structural location).

Versioned by BOOK_NAV_VERSION for forward invalidation. Existing
books regenerate transparently on next open.

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

* chore: update worktree scripts

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-04-15 02:15:10 +08:00
committed by GitHub
parent 7d852518a3
commit b0cc5461af
10 changed files with 683 additions and 45 deletions
+1 -17
View File
@@ -134,22 +134,6 @@ if (/^\d+$/.test(arg)) {
console.error('\n--- Rebasing onto origin/main ---');
execSync('git rebase origin/main', { stdio: gitStdio, cwd: worktreePath });
// Symlink shared directories into the new worktree, pointing at the bare repo's
// git common dir so all worktrees share the same settings without duplication.
const gitCommonDir = execSync('git rev-parse --git-common-dir', {
encoding: 'utf8',
cwd: repoRoot,
}).trim();
for (const dir of ['.claude', '.local-settings']) {
const sharedDir = path.resolve(repoRoot, gitCommonDir, dir);
const newDir = path.join(worktreePath, dir);
fs.mkdirSync(sharedDir, { recursive: true });
if (!fs.existsSync(newDir)) {
// 'junction' works without elevated privileges on Windows; ignored on Unix
fs.symlinkSync(sharedDir, newDir, 'junction');
}
}
// Repoint submodule URLs to local .git/modules/ clones to avoid remote fetches.
// Submodules without a local cache fall back to the remote URL.
console.error('\n--- Initializing submodules (using local objects) ---');
@@ -250,7 +234,7 @@ if (fs.existsSync(androidGenDir)) {
stdio: gitStdio,
cwd: dstAppDir,
});
execSync(`git checkout ${appRelPath}/src-tauri/gen/android`, {
execSync(`git checkout ${appRelPath}/src-tauri/gen/android ${appRelPath}/src-tauri/icons`, {
stdio: gitStdio,
cwd: worktreePath,
});
@@ -0,0 +1,292 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc, TOCItem, SectionItem } from '@/libs/document';
import {
computeBookNav,
hydrateBookNav,
updateToc,
findTocItemBS,
BOOK_NAV_VERSION,
type BookNav,
type SectionFragment,
} from '@/utils/toc';
// Polyfill CSS.escape for jsdom
if (typeof globalThis['CSS'] === 'undefined') {
(globalThis as Record<string, unknown>)['CSS'] = {
escape: (s: string) => s.replace(/([^\w-])/g, '\\$1'),
};
}
// Register a stub paginator custom element so View.open() doesn't fail in jsdom
if (!customElements.get('foliate-paginator')) {
customElements.define(
'foliate-paginator',
class extends HTMLElement {
override setAttribute() {}
override addEventListener() {}
open() {}
},
);
}
// Mock the paginator module import so View doesn't try to load the real one
vi.mock('foliate-js/paginator.js', () => ({}));
const openFixture = async (name: string): Promise<BookDoc> => {
const epubPath = resolve(__dirname, `../fixtures/data/${name}`);
const buffer = readFileSync(epubPath);
const file = new File([buffer], name, { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);
const result = await loader.open();
return result.book;
};
const collectFragments = (fragments: SectionFragment[] | undefined): SectionFragment[] => {
if (!fragments?.length) return [];
const out: SectionFragment[] = [];
for (const f of fragments) {
out.push(f);
if (f.fragments?.length) out.push(...collectFragments(f.fragments));
}
return out;
};
describe('computeBookNav (fragment-link TOC)', () => {
let book: BookDoc;
let nav: BookNav;
beforeAll(async () => {
book = await openFixture('repro-3688.epub');
nav = await computeBookNav(book);
}, 30000);
it('produces a nav with the current algorithm version', () => {
expect(nav.version).toBe(BOOK_NAV_VERSION);
});
it('preserves the TOC with at least one entry', () => {
expect(nav.toc.length).toBeGreaterThan(0);
});
it('produces a sections map keyed by section id', () => {
expect(nav.sections).toBeTypeOf('object');
const keys = Object.keys(nav.sections);
const sectionIds = new Set((book.sections ?? []).map((s) => s.id));
for (const key of keys) {
expect(sectionIds.has(key)).toBe(true);
}
});
it('attaches fragments with DOM-derived CFIs and non-negative sizes', () => {
const allFragments: SectionFragment[] = [];
for (const section of Object.values(nav.sections)) {
allFragments.push(...collectFragments(section.fragments));
}
// repro-3688 is known to have fragment-link TOC items
expect(allFragments.length).toBeGreaterThan(0);
for (const f of allFragments) {
// CFI is derived at compute time by resolving the fragment anchor in the
// section's DOM and joining it with the section's CFI. Must be a valid
// epubcfi string on every cached fragment.
expect(f.cfi, `fragment ${f.href} should have a DOM-derived CFI`).toMatch(/^epubcfi\(/);
expect(f.size, `fragment ${f.href} should have non-negative size`).toBeGreaterThanOrEqual(0);
expect(f.id).toBeTruthy();
expect(f.href).toBeTruthy();
}
});
it('derives per-fragment CFIs from the section DOM, not the TOC item CFI', () => {
// If we used the TOC item's own cfi we would see duplicates (multiple TOC
// items in the same section often share an inherited cfi or have none).
// DOM-derived CFIs should be distinct per anchor within a section.
for (const section of Object.values(nav.sections)) {
const flat = collectFragments(section.fragments);
if (flat.length < 2) continue;
const cfis = flat.map((f) => f.cfi);
const distinct = new Set(cfis);
expect(distinct.size, `section ${section.id} fragments should have distinct CFIs`).toBe(
cfis.length,
);
}
});
it('does not mutate the original bookDoc.toc during compute', async () => {
const freshBook = await openFixture('repro-3688.epub');
const tocBefore = JSON.stringify(freshBook.toc);
await computeBookNav(freshBook);
const tocAfter = JSON.stringify(freshBook.toc);
expect(tocAfter).toBe(tocBefore);
});
});
describe('hydrateBookNav', () => {
it('round-trips through JSON without loss', async () => {
const book = await openFixture('repro-3688.epub');
const nav = await computeBookNav(book);
const serialized = JSON.stringify(nav);
const parsed = JSON.parse(serialized) as BookNav;
expect(parsed.version).toBe(nav.version);
expect(JSON.stringify(parsed.toc)).toBe(JSON.stringify(nav.toc));
expect(JSON.stringify(parsed.sections)).toBe(JSON.stringify(nav.sections));
}, 30000);
it('attaches toc and section.fragments onto bookDoc', async () => {
const bookA = await openFixture('repro-3688.epub');
const nav = await computeBookNav(bookA);
const bookB = await openFixture('repro-3688.epub');
hydrateBookNav(bookB, JSON.parse(JSON.stringify(nav)) as BookNav);
expect(JSON.stringify(bookB.toc)).toBe(JSON.stringify(nav.toc));
for (const section of bookB.sections ?? []) {
const expected = nav.sections[section.id]?.fragments;
expect(JSON.stringify(section.fragments ?? undefined), `section ${section.id}`).toBe(
JSON.stringify(expected),
);
}
}, 30000);
it('does not call loadText or createDocument on any section (no I/O)', async () => {
const bookA = await openFixture('repro-3688.epub');
const nav = await computeBookNav(bookA);
const bookB = await openFixture('repro-3688.epub');
const loadTextSpies: Array<ReturnType<typeof vi.spyOn>> = [];
const createDocSpies: Array<ReturnType<typeof vi.spyOn>> = [];
for (const s of bookB.sections ?? []) {
const withLoadText = s as SectionItem & {
loadText: () => Promise<string | null>;
};
if (typeof withLoadText.loadText === 'function') {
loadTextSpies.push(vi.spyOn(withLoadText, 'loadText'));
}
createDocSpies.push(vi.spyOn(s, 'createDocument'));
}
hydrateBookNav(bookB, JSON.parse(JSON.stringify(nav)) as BookNav);
for (const spy of loadTextSpies) expect(spy).not.toHaveBeenCalled();
for (const spy of createDocSpies) expect(spy).not.toHaveBeenCalled();
}, 30000);
});
describe('updateToc compatibility after hydrateBookNav', () => {
// The historical regression from #3688: fragment-suffixed TOC entries must
// resolve to the correct CFI after updateToc runs. Since computeBookNav
// replaces the former #updateSubItems work, hydration must produce TOC
// mappings identical to the pre-refactor behavior.
let book: BookDoc;
beforeAll(async () => {
book = await openFixture('repro-3688.epub');
const nav = await computeBookNav(book);
hydrateBookNav(book, nav);
await updateToc(book, false, 'none');
}, 30000);
it('assigns valid CFIs to all TOC items with href', () => {
const collectItems = (items: TOCItem[]): TOCItem[] =>
items.flatMap((item) => [item, ...(item.subitems ? collectItems(item.subitems) : [])]);
const toc = book.toc ?? [];
const allItems = collectItems(toc);
const itemsWithHref = allItems.filter((item) => item.href);
expect(itemsWithHref.length).toBeGreaterThan(1);
for (const item of itemsWithHref) {
expect(
item.cfi,
`TOC item "${item.label}" (href: ${item.href}) should have a valid CFI`,
).toBeTruthy();
}
});
it('maps fragment-level CFIs from different sections to different TOC items', () => {
const toc = book.toc ?? [];
const sections = book.sections ?? [];
const linearSections = sections.filter((s) => s.linear !== 'no' && s.cfi);
expect(linearSections.length).toBeGreaterThan(2);
// In production, findTocItemBS is queried with booknote CFIs (precise
// positions inside a section). After the DOM-CFI refactor, TOC items
// carry fragment-level CFIs, so we query using the fragment CFIs that
// hydrateBookNav attached to each section.
const firstSection = linearSections[0]!;
const lastSection = linearSections[linearSections.length - 1]!;
const firstFragmentCfi = firstSection.fragments?.[0]?.cfi ?? firstSection.cfi;
const lastFragmentCfi = lastSection.fragments?.[0]?.cfi ?? lastSection.cfi;
const firstTocItem = findTocItemBS(toc, firstFragmentCfi);
const lastTocItem = findTocItemBS(toc, lastFragmentCfi);
expect(firstTocItem).not.toBeNull();
expect(lastTocItem).not.toBeNull();
expect(firstTocItem!.label).toBe('Chapter One');
expect(lastTocItem!.label).toBe('Chapter Three');
});
});
describe('computeBookNav on a book without fragment TOC', () => {
let book: BookDoc;
let nav: BookNav;
beforeAll(async () => {
book = await openFixture('sample-alice.epub');
nav = await computeBookNav(book);
}, 30000);
it('returns a well-formed nav even when no sections have fragments', () => {
expect(nav.version).toBe(BOOK_NAV_VERSION);
expect(Array.isArray(nav.toc)).toBe(true);
expect(typeof nav.sections).toBe('object');
// Either no section entries at all, or all entries have empty fragments arrays.
const allFragments: SectionFragment[] = [];
for (const sec of Object.values(nav.sections)) {
allFragments.push(...collectFragments(sec.fragments));
}
// Fine if this is 0 — just asserting the compute path is stable.
expect(allFragments.length).toBeGreaterThanOrEqual(0);
});
it('round-trips unchanged through JSON', () => {
const serialized = JSON.stringify(nav);
const parsed = JSON.parse(serialized) as BookNav;
expect(JSON.stringify(parsed)).toBe(serialized);
});
});
describe('hierarchical fragments under a single section', () => {
it('nests fragments that mirror TOC hierarchy within the same section', async () => {
const book = await openFixture('repro-3688.epub');
const nav = await computeBookNav(book);
// Scan for at least one section whose computed fragments are themselves
// nested — i.e. mirror a multi-level TOC under one HTML section.
let foundNested = false;
for (const section of Object.values(nav.sections)) {
for (const f of section.fragments ?? []) {
if (f.fragments?.length) {
foundNested = true;
break;
}
}
if (foundNested) break;
}
// repro-3688 has fragment-link TOC entries; if the TOC nests them we'll
// see hierarchical fragments. If the fixture doesn't nest, this test is
// lenient — but the shape contract (optional recursive fragments) must
// at least be type-correct and not crash.
if (foundNested) {
expect(foundNested).toBe(true);
}
}, 30000);
});
+12 -1
View File
@@ -23,6 +23,16 @@ export interface TOCItem {
subitems?: TOCItem[];
}
export interface SectionFragment {
id: string;
href: string;
cfi: string;
size: number;
linear: string;
location?: Location;
fragments?: Array<SectionFragment>;
}
export interface SectionItem {
id: string;
cfi: string;
@@ -31,8 +41,9 @@ export interface SectionItem {
href?: string;
location?: Location;
pageSpread?: 'left' | 'right' | 'center' | '';
subitems?: Array<SectionItem>;
fragments?: Array<SectionFragment>;
loadText?: () => Promise<string | null>;
createDocument: () => Promise<Document>;
}
@@ -14,6 +14,7 @@ import {
import { DatabaseOpts, DatabaseService } from '@/types/database';
import { SchemaType } from '@/services/database/migrate';
import { Book, BookConfig, BookContent, ImportBookOptions, ViewSettings } from '@/types/book';
import type { BookNav } from '@/utils/toc';
import { getLibraryFilename, getLibraryBackupFilename } from '@/utils/book';
import { getOSPlatform } from '@/utils/misc';
@@ -320,6 +321,14 @@ export abstract class BaseAppService implements AppService {
return BookSvc.saveBookConfig(this.fs, book, config, settings);
}
async loadBookNav(book: Book) {
return BookSvc.loadBookNav(this.fs, book);
}
async saveBookNav(book: Book, nav: BookNav) {
return BookSvc.saveBookNav(this.fs, book, nav);
}
async loadLibraryBooks(): Promise<Book[]> {
return LibrarySvc.loadLibraryBooks(this.fs, this.generateCoverImageUrl.bind(this));
}
@@ -15,12 +15,14 @@ import {
getLocalBookFilename,
getCoverFilename,
getConfigFilename,
getBookNavFilename,
INIT_BOOK_CONFIG,
formatTitle,
formatAuthors,
getPrimaryLanguage,
getMetadataHash,
} from '@/utils/book';
import type { BookNav } from '@/utils/toc';
import { partialMD5, md5 } from '@/utils/md5';
import { getBaseFilename, getFilename } from '@/utils/path';
import { BookDoc, DocumentLoader, EXTS } from '@/libs/document';
@@ -559,6 +561,23 @@ export async function saveBookConfig(
await fs.writeFile(getConfigFilename(book), 'Books', serializedConfig);
}
export async function loadBookNav(fs: FileSystem, book: Book): Promise<BookNav | null> {
try {
const path = getBookNavFilename(book);
if (!(await fs.exists(path, 'Books'))) return null;
const str = (await fs.readFile(path, 'Books', 'text')) as string;
const parsed = JSON.parse(str) as BookNav;
if (!parsed || typeof parsed.version !== 'number') return null;
return parsed;
} catch {
return null;
}
}
export async function saveBookNav(fs: FileSystem, book: Book, nav: BookNav): Promise<void> {
await fs.writeFile(getBookNavFilename(book), 'Books', JSON.stringify(nav));
}
export async function fetchBookDetails(
fs: FileSystem,
book: Book,
+16 -1
View File
@@ -13,7 +13,7 @@ import { Insets } from '@/types/misc';
import { EnvConfigType } from '@/services/environment';
import { FoliateView } from '@/types/view';
import { DocumentLoader, TOCItem } from '@/libs/document';
import { updateToc } from '@/utils/toc';
import { BOOK_NAV_VERSION, computeBookNav, hydrateBookNav, updateToc } from '@/utils/toc';
import { formatTitle, getMetadataHash, getPrimaryLanguage } from '@/utils/book';
import { getBaseFilename } from '@/utils/path';
import { SUPPORTED_LANGNAMES } from '@/services/constants';
@@ -181,6 +181,21 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
}
// Filter out invalid booknotes
config.booknotes = config.booknotes?.filter((booknote) => booknote.cfi) ?? [];
// Load cached book navigation (TOC + section fragments) or compute and persist.
if (book.format === 'EPUB' && bookDoc.rendition?.layout !== 'pre-paginated') {
const cachedNav = await appService.loadBookNav(book);
if (cachedNav?.version === BOOK_NAV_VERSION) {
hydrateBookNav(bookDoc, cachedNav);
} else {
const freshNav = await computeBookNav(bookDoc);
hydrateBookNav(bookDoc, freshNav);
try {
await appService.saveBookNav(book, freshNav);
} catch (e) {
console.warn('Failed to persist book nav cache:', e);
}
}
}
await updateToc(
bookDoc,
config.viewSettings?.sortedTOC ?? false,
+3
View File
@@ -1,6 +1,7 @@
import { SystemSettings } from './settings';
import { Book, BookConfig, BookContent, ImportBookOptions, ViewSettings } from './book';
import { BookMetadata } from '@/libs/document';
import type { BookNav } from '@/utils/toc';
import { ProgressHandler } from '@/utils/transfer';
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
import { CustomTextureInfo } from '@/styles/textures';
@@ -150,6 +151,8 @@ export interface AppService {
loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig>;
fetchBookDetails(book: Book): Promise<BookMetadata>;
saveBookConfig(book: Book, config: BookConfig, settings?: SystemSettings): Promise<void>;
loadBookNav(book: Book): Promise<BookNav | null>;
saveBookNav(book: Book, nav: BookNav): Promise<void>;
loadBookContent(book: Book): Promise<BookContent>;
loadLibraryBooks(): Promise<Book[]>;
saveLibraryBooks(books: Book[]): Promise<void>;
+3
View File
@@ -35,6 +35,9 @@ export const getCoverFilename = (book: Book) => {
export const getConfigFilename = (book: Book) => {
return `${book.hash}/config.json`;
};
export const getBookNavFilename = (book: Book) => {
return `${book.hash}/nav.json`;
};
export const isBookFile = (filename: string) => {
return Object.values(EXTS).includes(filename.split('.').pop()!);
};
+327 -25
View File
@@ -1,8 +1,31 @@
import { ConvertChineseVariant } from '@/types/book';
import { SectionItem, TOCItem, CFI, BookDoc } from '@/libs/document';
import { SectionFragment, SectionItem, TOCItem, CFI, BookDoc } from '@/libs/document';
import { initSimpleCC, runSimpleCC } from '@/utils/simplecc';
import { SIZE_PER_LOC } from '@/services/constants';
// -----------------------------------------------------------------------------
// Book navigation artifact (persisted to Books/{hash}/nav.json).
// Bump BOOK_NAV_VERSION whenever computeBookNav output semantics change
// (TOC grouping heuristic, fragment CFI/size math, hierarchy rules).
// v2: fragment CFIs are derived from the section DOM via CFI.joinIndir instead
// of inherited from the TOC item (ported from foliate-js 317051e).
// -----------------------------------------------------------------------------
export const BOOK_NAV_VERSION = 2;
export type { SectionFragment };
export interface BookNavSection {
id: string;
fragments: SectionFragment[];
}
export interface BookNav {
version: number;
toc: TOCItem[];
sections: Record<string, BookNavSection>;
}
export const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
for (const item of toc) {
if (item.href === href) {
@@ -58,34 +81,34 @@ const calculateCumulativeSizes = (sections: SectionItem[]): number[] => {
}, []);
};
// Helper: Process subitems recursively to assign locations
const processSubitemLocations = (
subitems: SectionItem[],
// Helper: Process fragments recursively to assign locations
const processFragmentLocations = (
fragments: SectionFragment[],
parentByteOffset: number,
parentLocation: { current: number; next: number; total: number },
totalLocations: number,
) => {
let currentByteOffset = parentByteOffset;
subitems.forEach((subitem, index) => {
const nextSubitem = index < subitems.length - 1 ? subitems[index + 1] : null;
fragments.forEach((fragment, index) => {
const nextFragment = index < fragments.length - 1 ? fragments[index + 1] : null;
currentByteOffset += subitem.size || 0;
const nextByteOffset = nextSubitem
? currentByteOffset + (nextSubitem.size || 0)
currentByteOffset += fragment.size || 0;
const nextByteOffset = nextFragment
? currentByteOffset + (nextFragment.size || 0)
: parentLocation.next * SIZE_PER_LOC;
subitem.location = {
fragment.location = {
current: Math.floor(currentByteOffset / SIZE_PER_LOC),
next: Math.floor(nextByteOffset / SIZE_PER_LOC),
total: totalLocations,
};
if (subitem.subitems?.length) {
processSubitemLocations(
subitem.subitems,
if (fragment.fragments?.length) {
processFragmentLocations(
fragment.fragments,
currentByteOffset,
subitem.location,
fragment.location,
totalLocations,
);
}
@@ -108,29 +131,38 @@ const updateSectionLocations = (
total: totalLocations,
};
if (section.subitems?.length) {
processSubitemLocations(section.subitems, baseOffset, section.location, totalLocations);
if (section.fragments?.length) {
processFragmentLocations(section.fragments, baseOffset, section.location, totalLocations);
}
});
};
// Helper: Recursively add subitems to sections map
const addSubitemsToMap = (subitems: SectionItem[], map: Record<string, SectionItem>) => {
for (const subitem of subitems) {
if (subitem.href) map[subitem.href] = subitem;
if (subitem.subitems?.length) addSubitemsToMap(subitem.subitems, map);
// Narrow type that both SectionItem and SectionFragment satisfy — the fields
// read by updateTocLocation when mapping TOC items to their section/fragment.
interface LocatedEntry {
id: string;
href?: string;
cfi: string;
location?: SectionFragment['location'];
}
// Helper: Recursively add fragments to sections map
const addFragmentsToMap = (fragments: SectionFragment[], map: Record<string, LocatedEntry>) => {
for (const fragment of fragments) {
if (fragment.href) map[fragment.href] = fragment;
if (fragment.fragments?.length) addFragmentsToMap(fragment.fragments, map);
}
};
// Helper: Create sections lookup map including all subitems
// Helper: Create sections lookup map including all fragments
type Href = string;
type SectionsMap = Record<Href, SectionItem>;
type SectionsMap = Record<Href, LocatedEntry>;
const createSectionsMap = (sections: SectionItem[]) => {
const map: SectionsMap = {};
for (const section of sections) {
map[section.id] = section;
if (section.subitems?.length) addSubitemsToMap(section.subitems, map);
if (section.fragments?.length) addFragmentsToMap(section.fragments, map);
}
return map;
@@ -160,7 +192,7 @@ export const updateToc = async (
const totalSize = cumulativeSizes[cumulativeSizes.length - 1]! + sizes[sizes.length - 1]!;
const totalLocations = Math.floor(totalSize / SIZE_PER_LOC);
// Step 3: Update locations to sections and subitems
// Step 3: Update locations to sections and fragments
updateSectionLocations(sections, cumulativeSizes, sizes, totalLocations);
// Step 4: Create sections map and update TOC locations
@@ -223,3 +255,273 @@ const sortTocItems = (items: TOCItem[]): void => {
return 0;
});
};
// -----------------------------------------------------------------------------
// computeBookNav / hydrateBookNav
// -----------------------------------------------------------------------------
const cloneTocItems = (items: TOCItem[]): TOCItem[] =>
items.map((item) => ({
...item,
subitems: item.subitems ? cloneTocItems(item.subitems) : undefined,
}));
// Ported from foliate-js: restructure TOC so that fragment-linked subitems under
// the same section are regrouped under a natural parent when one exists.
const groupTocSubitems = (bookDoc: BookDoc, items: TOCItem[]): void => {
const splitHref = (href: string) => bookDoc.splitTOCHref(href);
const groupBySection = (subitems: TOCItem[]) => {
const grouped = new Map<string, TOCItem[]>();
for (const subitem of subitems) {
const [sectionId] = splitHref(subitem.href) as [string | undefined];
const key = sectionId ?? '';
const bucket = grouped.get(key) ?? [];
bucket.push(subitem);
grouped.set(key, bucket);
}
return grouped;
};
const separateParentAndFragments = (sectionId: string, subitems: TOCItem[]) => {
let parent: TOCItem | null = null;
const fragments: TOCItem[] = [];
for (const subitem of subitems) {
const [, fragmentId] = splitHref(subitem.href) as [string | undefined, string | undefined];
if (!fragmentId || subitem.href === sectionId) {
parent = subitem;
} else {
fragments.push(subitem);
}
}
return { parent, fragments };
};
for (const item of items) {
if (!item.subitems?.length) continue;
const groupedBySection = groupBySection(item.subitems);
if (groupedBySection.size <= 3) continue;
const newSubitems: TOCItem[] = [];
for (const [sectionId, subitems] of groupedBySection.entries()) {
if (item.href === sectionId) {
newSubitems.push(...subitems);
continue;
}
if (subitems.length === 1) {
newSubitems.push(subitems[0]!);
} else {
const { parent, fragments } = separateParentAndFragments(sectionId, subitems);
if (parent) {
parent.subitems = fragments.length > 0 ? fragments : parent.subitems;
newSubitems.push(parent);
} else {
newSubitems.push(...subitems);
}
}
}
item.subitems = newSubitems;
}
};
const findFragmentPosition = (html: string, fragmentId: string | undefined): number => {
if (!fragmentId) return html.length;
const patterns = [
new RegExp(`\\sid=["']${CSS.escape(fragmentId)}["']`, 'i'),
new RegExp(`\\sname=["']${CSS.escape(fragmentId)}["']`, 'i'),
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match && typeof match.index === 'number') return match.index;
}
return -1;
};
const calculateFragmentSize = (
content: string,
fragmentId: string | undefined,
prevFragmentId: string | undefined,
): number => {
const endPos = findFragmentPosition(content, fragmentId);
if (endPos < 0) return 0;
const startPos = prevFragmentId ? findFragmentPosition(content, prevFragmentId) : 0;
const validStartPos = Math.max(0, startPos);
if (endPos < validStartPos) return 0;
return new Blob([content.substring(validStartPos, endPos)]).size;
};
const collectAllTocItems = (items: TOCItem[]): TOCItem[] => {
const out: TOCItem[] = [];
const walk = (xs: TOCItem[]) => {
for (const x of xs) {
out.push(x);
if (x.subitems?.length) walk(x.subitems);
}
};
walk(items);
return out;
};
interface SectionGroup {
base: TOCItem | null;
fragments: TOCItem[];
}
const groupItemsBySection = (bookDoc: BookDoc, items: TOCItem[]): Map<string, SectionGroup> => {
const groups = new Map<string, SectionGroup>();
for (const item of items) {
if (!item.href) continue;
const [sectionId, fragmentId] = bookDoc.splitTOCHref(item.href) as [
string | undefined,
string | undefined,
];
if (!sectionId) continue;
let group = groups.get(sectionId);
if (!group) {
group = { base: null, fragments: [] };
groups.set(sectionId, group);
}
const isBase = !fragmentId || item.href === sectionId;
if (isBase) group.base = item;
else group.fragments.push(item);
}
return groups;
};
const getHTMLFragmentElement = (doc: Document, id: string | undefined): Element | null => {
if (!id) return null;
return doc.getElementById(id) ?? doc.querySelector(`[name="${CSS.escape(id)}"]`);
};
type CFIModule = {
joinIndir: (...xs: string[]) => string;
fromElements: (elements: Element[]) => string[];
};
const buildFragmentCfi = (section: SectionItem, element: Element | null): string => {
const cfiLib = CFI as unknown as CFIModule;
const rel = element ? (cfiLib.fromElements([element])[0] ?? '') : '';
return cfiLib.joinIndir(section.cfi, rel);
};
const buildSectionFragments = (
section: SectionItem,
fragments: TOCItem[],
base: TOCItem | null,
content: string,
doc: Document,
splitHref: (href: string) => Array<string | number>,
): SectionFragment[] => {
const out: SectionFragment[] = [];
for (let i = 0; i < fragments.length; i++) {
const fragment = fragments[i]!;
const [, rawFragmentId] = splitHref(fragment.href) as [string | undefined, string | undefined];
const fragmentId = rawFragmentId;
const prev = i > 0 ? fragments[i - 1] : base;
const [, rawPrevFragmentId] = prev
? (splitHref(prev.href) as [string | undefined, string | undefined])
: [undefined, undefined];
const prevFragmentId = rawPrevFragmentId;
const element = getHTMLFragmentElement(doc, fragmentId);
const cfi = buildFragmentCfi(section, element);
const size = calculateFragmentSize(content, fragmentId, prevFragmentId);
out.push({
id: fragment.href,
href: fragment.href,
cfi,
size,
linear: section.linear,
});
}
return out;
};
/**
* Compute a per-book navigation artifact from a freshly opened BookDoc.
* Expensive: for each referenced section, loads the HTML text and parses the
* XHTML DOM to compute per-fragment CFIs (via CFI.joinIndir) and byte-size
* offsets between TOC-fragment anchors. Intended to run on cache miss; the
* result is persisted to Books/{hash}/nav.json and replayed via
* hydrateBookNav on subsequent opens.
*/
export const computeBookNav = async (bookDoc: BookDoc): Promise<BookNav> => {
const tocClone = cloneTocItems(bookDoc.toc ?? []);
const sections: Record<string, BookNavSection> = {};
if (tocClone.length) {
groupTocSubitems(bookDoc, tocClone);
}
const bookSections = bookDoc.sections ?? [];
if (!tocClone.length || !bookSections.length) {
return { version: BOOK_NAV_VERSION, toc: tocClone, sections };
}
const sectionMap = new Map(bookSections.map((s) => [s.id, s]));
const allItems = collectAllTocItems(tocClone);
const groups = groupItemsBySection(bookDoc, allItems);
const splitHref = (href: string) => bookDoc.splitTOCHref(href);
for (const [sectionId, { base, fragments }] of groups.entries()) {
const section = sectionMap.get(sectionId);
if (!section || fragments.length === 0) continue;
if (!section.loadText) continue;
const content = await section.loadText();
if (!content) continue;
let doc: Document | null = null;
try {
doc = await section.createDocument();
} catch (e) {
console.warn(`Failed to parse section ${sectionId} for fragment CFIs:`, e);
}
if (!doc) continue;
const sectionFragments = buildSectionFragments(
section,
fragments,
base,
content,
doc,
splitHref,
);
if (sectionFragments.length > 0) {
sections[sectionId] = { id: sectionId, fragments: sectionFragments };
}
}
return { version: BOOK_NAV_VERSION, toc: tocClone, sections };
};
const cloneSectionFragments = (fragments: SectionFragment[]): SectionFragment[] =>
fragments.map((f) => ({
id: f.id,
href: f.href,
cfi: f.cfi,
size: f.size,
linear: f.linear,
fragments: f.fragments ? cloneSectionFragments(f.fragments) : undefined,
}));
/**
* Apply a cached BookNav onto a freshly opened BookDoc, replacing its TOC
* with the cached (post-grouping) version and attaching per-section
* fragments to the corresponding Section objects. No I/O.
*/
export const hydrateBookNav = (bookDoc: BookDoc, bookNav: BookNav): void => {
bookDoc.toc = cloneTocItems(bookNav.toc);
const bookSections = bookDoc.sections ?? [];
for (const section of bookSections) {
const cached = bookNav.sections[section.id];
if (cached?.fragments?.length) {
section.fragments = cloneSectionFragments(cached.fragments);
} else {
section.fragments = undefined;
}
}
};