From 3e292af9904ccdcc48d950d146300abd6f367b55 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 15 Apr 2026 23:08:17 +0800 Subject: [PATCH] refactor(nav): refactor book nav service with TOC enrichment (#3874) --- apps/readest-app/src-tauri/Cargo.toml | 1 + .../__tests__/store/proofread-store.test.ts | 2 +- .../src/__tests__/store/reader-store.test.ts | 2 +- .../__tests__/utils/book-nav-cache.test.ts | 2 +- .../__tests__/utils/toc-cfi-mapping.test.ts | 7 +- .../utils/toc-nav-enrichment.test.ts | 167 ++++++ .../reader/components/annotator/Annotator.tsx | 2 +- .../annotator/ExportMarkdownDialog.tsx | 5 +- .../reader/components/sidebar/BookMenu.tsx | 13 +- .../components/sidebar/BooknoteView.tsx | 2 +- .../app/reader/components/sidebar/TOCView.tsx | 2 +- .../src/app/reader/hooks/useBooknotesNav.ts | 2 +- apps/readest-app/src/services/appService.ts | 2 +- apps/readest-app/src/services/bookService.ts | 2 +- .../src/services/nav/enrichment.ts | 147 +++++ .../readest-app/src/services/nav/fragments.ts | 78 +++ apps/readest-app/src/services/nav/grouping.ts | 117 ++++ apps/readest-app/src/services/nav/index.ts | 163 ++++++ .../readest-app/src/services/nav/locations.ts | 158 ++++++ apps/readest-app/src/services/nav/lookup.ts | 45 ++ apps/readest-app/src/store/readerStore.ts | 4 +- apps/readest-app/src/types/system.ts | 2 +- apps/readest-app/src/utils/toc.ts | 527 ------------------ 23 files changed, 905 insertions(+), 547 deletions(-) create mode 100644 apps/readest-app/src/__tests__/utils/toc-nav-enrichment.test.ts create mode 100644 apps/readest-app/src/services/nav/enrichment.ts create mode 100644 apps/readest-app/src/services/nav/fragments.ts create mode 100644 apps/readest-app/src/services/nav/grouping.ts create mode 100644 apps/readest-app/src/services/nav/index.ts create mode 100644 apps/readest-app/src/services/nav/locations.ts create mode 100644 apps/readest-app/src/services/nav/lookup.ts delete mode 100644 apps/readest-app/src/utils/toc.ts diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml index bdd9135b..4af19de0 100644 --- a/apps/readest-app/src-tauri/Cargo.toml +++ b/apps/readest-app/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ crate-type = ["staticlib", "cdylib", "lib"] cargo-clippy = [] # Enable WebDriver plugin for E2E testing (use with `tauri build --debug --features webdriver`) webdriver = ["tauri-plugin-webdriver"] +devtools = ["tauri/devtools"] [build-dependencies] tauri-build = { version = "2", features = [] } diff --git a/apps/readest-app/src/__tests__/store/proofread-store.test.ts b/apps/readest-app/src/__tests__/store/proofread-store.test.ts index 0cdea9b6..a0dbaf90 100644 --- a/apps/readest-app/src/__tests__/store/proofread-store.test.ts +++ b/apps/readest-app/src/__tests__/store/proofread-store.test.ts @@ -77,7 +77,7 @@ vi.mock('@/utils/misc', () => ({ })); // Transitive imports needed by readerStore's module -vi.mock('@/utils/toc', () => ({ updateToc: vi.fn() })); +vi.mock('@/services/nav', () => ({ updateToc: vi.fn() })); vi.mock('@/utils/book', () => ({ formatTitle: vi.fn((t: string) => t), getMetadataHash: vi.fn(() => 'hash'), diff --git a/apps/readest-app/src/__tests__/store/reader-store.test.ts b/apps/readest-app/src/__tests__/store/reader-store.test.ts index 48f5d172..20a4b4b2 100644 --- a/apps/readest-app/src/__tests__/store/reader-store.test.ts +++ b/apps/readest-app/src/__tests__/store/reader-store.test.ts @@ -40,7 +40,7 @@ vi.mock('@/utils/misc', () => ({ })); // These are transitive imports needed by readerStore -vi.mock('@/utils/toc', () => ({ updateToc: vi.fn() })); +vi.mock('@/services/nav', () => ({ updateToc: vi.fn() })); vi.mock('@/utils/book', () => ({ formatTitle: vi.fn((t: string) => t), getMetadataHash: vi.fn(() => 'hash'), diff --git a/apps/readest-app/src/__tests__/utils/book-nav-cache.test.ts b/apps/readest-app/src/__tests__/utils/book-nav-cache.test.ts index fbef2cac..51af66d6 100644 --- a/apps/readest-app/src/__tests__/utils/book-nav-cache.test.ts +++ b/apps/readest-app/src/__tests__/utils/book-nav-cache.test.ts @@ -11,7 +11,7 @@ import { BOOK_NAV_VERSION, type BookNav, type SectionFragment, -} from '@/utils/toc'; +} from '@/services/nav'; // Polyfill CSS.escape for jsdom if (typeof globalThis['CSS'] === 'undefined') { diff --git a/apps/readest-app/src/__tests__/utils/toc-cfi-mapping.test.ts b/apps/readest-app/src/__tests__/utils/toc-cfi-mapping.test.ts index 8c64108f..f98ce695 100644 --- a/apps/readest-app/src/__tests__/utils/toc-cfi-mapping.test.ts +++ b/apps/readest-app/src/__tests__/utils/toc-cfi-mapping.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'fs'; import { resolve } from 'path'; import { DocumentLoader, CFI } from '@/libs/document'; import type { BookDoc, TOCItem } from '@/libs/document'; -import { updateToc, findTocItemBS } from '@/utils/toc'; +import { computeBookNav, hydrateBookNav, updateToc, findTocItemBS } from '@/services/nav'; // Simulates an annotation deep inside a section, past the chapter heading. // Section CFIs look like `epubcfi(/6/2)` and TOC item CFIs point at the heading @@ -54,6 +54,11 @@ describe('TOC-to-CFI mapping with fragment hrefs (#3688)', () => { const loader = new DocumentLoader(file); const result = await loader.open(); book = result.book; + // Mirror the production open flow in readerStore: build (or hydrate) the + // BookNav — which bakes section/fragment locations and TOC cfi+location — + // and then apply per-open settings via updateToc. + const nav = await computeBookNav(book); + hydrateBookNav(book, nav); await updateToc(book, false, 'none'); }, 30000); diff --git a/apps/readest-app/src/__tests__/utils/toc-nav-enrichment.test.ts b/apps/readest-app/src/__tests__/utils/toc-nav-enrichment.test.ts new file mode 100644 index 00000000..66582d66 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/toc-nav-enrichment.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { BookDoc, SectionItem, TOCItem } from '@/libs/document'; +import { computeBookNav } from '@/services/nav'; + +// Polyfill CSS.escape for jsdom — matches book-nav-cache.test.ts. +if (typeof globalThis['CSS'] === 'undefined') { + (globalThis as Record)['CSS'] = { + escape: (s: string) => s.replace(/([^\w-])/g, '\\$1'), + }; +} + +// Mirrors foliate-js: section ids are path-resolved manifest item hrefs like +// "OEBPS/text00001.html"; the TOC href uses the same form, optionally suffixed +// with a fragment. +const splitTOCHref = (href: string): Array => { + if (!href) return ['']; + const hashIdx = href.indexOf('#'); + if (hashIdx < 0) return [href]; + return [href.slice(0, hashIdx), href.slice(hashIdx + 1)]; +}; + +interface MakeBookOpts { + sectionCount?: number; + tocHrefs?: string[]; + navSectionId?: string; // section whose loadText returns HTML + navHtml?: string; +} + +const makeBook = (opts: MakeBookOpts): BookDoc => { + const sectionCount = opts.sectionCount ?? 40; + const sections: SectionItem[] = []; + for (let i = 1; i <= sectionCount; i++) { + const id = `OEBPS/text${String(i).padStart(5, '0')}.html`; + const isNavSection = id === opts.navSectionId; + sections.push({ + id, + cfi: `epubcfi(/6/${i * 2}[s${i}]!)`, + size: 1000, + linear: 'yes', + href: id, + loadText: isNavSection && opts.navHtml ? async () => opts.navHtml! : async () => '', + createDocument: async () => { + const html = isNavSection && opts.navHtml ? opts.navHtml : ''; + return new DOMParser().parseFromString(html, 'application/xhtml+xml'); + }, + }); + } + + const tocHrefs = opts.tocHrefs ?? []; + const toc: TOCItem[] = tocHrefs.map((href, i) => ({ + id: i, + label: `Volume ${i + 1}`, + href, + index: 0, + })); + + return { + metadata: { title: 'test', author: '', language: 'en' }, + rendition: { layout: 'reflowable' }, + dir: 'ltr', + toc, + sections, + splitTOCHref, + getCover: async () => null, + }; +}; + +const NAV_WITH_SIX_CHAPTERS = ` + + + + +`; + +// Thresholds: section count > 64 AND section count > 8 × flatTocCount. +const LARGE_SECTION_COUNT = 100; +const SMALL_SECTION_COUNT = 40; + +describe('computeBookNav nav-enrichment fallback', () => { + it('adds embedded