mirror of
https://github.com/readest/readest
synced 2026-04-21 13:37:44 +00:00
refactor(nav): refactor book nav service with TOC enrichment (#3874)
This commit is contained in:
parent
b0cc5461af
commit
3e292af990
23 changed files with 905 additions and 547 deletions
|
|
@ -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 = [] }
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
167
apps/readest-app/src/__tests__/utils/toc-nav-enrichment.test.ts
Normal file
167
apps/readest-app/src/__tests__/utils/toc-nav-enrichment.test.ts
Normal file
|
|
@ -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<string, unknown>)['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<string | number> => {
|
||||
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 <nav>...</nav> 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 : '<html><body/></html>';
|
||||
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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<body>
|
||||
<nav id="toc">
|
||||
<h1>目录</h1>
|
||||
<ol>
|
||||
<li><a href="text00003.html">第1回</a></li>
|
||||
<li><a href="text00004.html">第2回</a></li>
|
||||
<li><a href="text00005.html">第3回</a></li>
|
||||
<li><a href="text00006.html">第4回</a></li>
|
||||
<li><a href="text00007.html">第5回</a></li>
|
||||
<li><a href="text00008.html">第6回</a></li>
|
||||
</ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// 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 <nav> items when sections >> flat TOC', async () => {
|
||||
const book = makeBook({
|
||||
sectionCount: LARGE_SECTION_COUNT,
|
||||
tocHrefs: ['OEBPS/text00001.html'], // sparse: 1 TOC entry, 100 sections
|
||||
navSectionId: 'OEBPS/text00002.html',
|
||||
navHtml: NAV_WITH_SIX_CHAPTERS,
|
||||
});
|
||||
const originalTocLen = (book.toc ?? []).length;
|
||||
|
||||
const nav = await computeBookNav(book);
|
||||
|
||||
// 6 new items merged in; each label and href from the embedded <nav>
|
||||
expect(nav.toc.length).toBe(originalTocLen + 6);
|
||||
const newItems = nav.toc.slice(originalTocLen);
|
||||
expect(newItems.map((i) => i.label)).toEqual([
|
||||
'第1回',
|
||||
'第2回',
|
||||
'第3回',
|
||||
'第4回',
|
||||
'第5回',
|
||||
'第6回',
|
||||
]);
|
||||
expect(newItems[0]!.href).toBe('OEBPS/text00003.html');
|
||||
expect(newItems[5]!.href).toBe('OEBPS/text00008.html');
|
||||
});
|
||||
|
||||
it('skips enrichment when sections are below the min-sections threshold', async () => {
|
||||
const book = makeBook({
|
||||
sectionCount: SMALL_SECTION_COUNT, // below min-sections threshold
|
||||
tocHrefs: ['OEBPS/text00001.html'],
|
||||
navSectionId: 'OEBPS/text00002.html',
|
||||
navHtml: NAV_WITH_SIX_CHAPTERS,
|
||||
});
|
||||
const loadTextSpies = (book.sections ?? []).map((s) =>
|
||||
typeof s.loadText === 'function' ? vi.spyOn(s, 'loadText') : null,
|
||||
);
|
||||
|
||||
const nav = await computeBookNav(book);
|
||||
|
||||
expect(nav.toc.length).toBe(1); // no enrichment
|
||||
for (const spy of loadTextSpies) {
|
||||
if (spy) expect(spy).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips enrichment when the TOC is not sparse enough vs sections', async () => {
|
||||
// 100 sections with 30 existing TOC items — ratio too low to trigger fallback.
|
||||
const tocHrefs: string[] = [];
|
||||
for (let i = 1; i <= 30; i++) tocHrefs.push(`OEBPS/text${String(i).padStart(5, '0')}.html`);
|
||||
const book = makeBook({
|
||||
sectionCount: LARGE_SECTION_COUNT,
|
||||
tocHrefs,
|
||||
navSectionId: 'OEBPS/text00002.html',
|
||||
navHtml: NAV_WITH_SIX_CHAPTERS,
|
||||
});
|
||||
|
||||
const nav = await computeBookNav(book);
|
||||
|
||||
expect(nav.toc.length).toBe(30); // unchanged
|
||||
});
|
||||
|
||||
it('does not duplicate items already in the existing TOC', async () => {
|
||||
// TOC already has text00003.html — the nav's first item should be dropped.
|
||||
const book = makeBook({
|
||||
sectionCount: LARGE_SECTION_COUNT,
|
||||
tocHrefs: ['OEBPS/text00001.html', 'OEBPS/text00003.html'],
|
||||
navSectionId: 'OEBPS/text00002.html',
|
||||
navHtml: NAV_WITH_SIX_CHAPTERS,
|
||||
});
|
||||
const originalTocLen = (book.toc ?? []).length;
|
||||
|
||||
const nav = await computeBookNav(book);
|
||||
|
||||
expect(nav.toc.length).toBe(originalTocLen + 5); // 6 nav items minus the 1 dup
|
||||
const hrefs = nav.toc.map((i) => i.href);
|
||||
expect(hrefs.filter((h) => h === 'OEBPS/text00003.html').length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -24,7 +24,7 @@ import { useTextSelector } from '../../hooks/useTextSelector';
|
|||
import { Point, Position, TextSelection } from '@/utils/sel';
|
||||
import { getPopupPosition, getPosition, getTextFromRange } from '@/utils/sel';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { findTocItemBS } from '@/utils/toc';
|
||||
import { findTocItemBS } from '@/services/nav';
|
||||
import { throttle } from '@/utils/throttle';
|
||||
import { runSimpleCC } from '@/utils/simplecc';
|
||||
import { getWordCount } from '@/utils/word';
|
||||
|
|
|
|||
|
|
@ -195,7 +195,10 @@ const ExportMarkdownDialog: React.FC<ExportMarkdownDialogProps> = ({
|
|||
}
|
||||
if (pageStr || timestampStr) {
|
||||
lines.push('');
|
||||
const infoStr = pageStr ? `${pageStr} · ${timestampStr}`.trim() : timestampStr;
|
||||
const infoStr =
|
||||
pageStr && timestampStr
|
||||
? `${pageStr} · ${timestampStr}`.trim()
|
||||
: pageStr || timestampStr;
|
||||
lines.push(`*${infoStr}*`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { eventDispatcher } from '@/utils/event';
|
|||
import { FIXED_LAYOUT_FORMATS } from '@/types/book';
|
||||
import { DOWNLOAD_READEST_URL } from '@/services/constants';
|
||||
import { navigateToLogin } from '@/utils/nav';
|
||||
import { saveSysSettings } from '@/helpers/settings';
|
||||
import { saveSysSettings, saveViewSettings } from '@/helpers/settings';
|
||||
import { setKOSyncSettingsWindowVisible } from '@/app/reader/components/KOSyncSettings';
|
||||
import { setReadwiseSettingsWindowVisible } from '@/app/reader/components/ReadwiseSettings';
|
||||
import { setHardcoverSettingsWindowVisible } from '@/app/reader/components/HardcoverSettings';
|
||||
|
|
@ -39,7 +39,7 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
|||
const { user } = useAuth();
|
||||
const { settings } = useSettingsStore();
|
||||
const { getConfig, setConfig, saveConfig } = useBookDataStore();
|
||||
const { bookKeys, recreateViewer, getViewSettings, setViewSettings } = useReaderStore();
|
||||
const { bookKeys, recreateViewer, getViewSettings } = useReaderStore();
|
||||
const { getVisibleLibrary } = useLibraryStore();
|
||||
const { openParallelView } = useBooksManager();
|
||||
const { sideBarBookKey } = useSidebarStore();
|
||||
|
|
@ -75,10 +75,11 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
|
|||
setIsSortedTOC((prev) => !prev);
|
||||
setIsDropdownOpen?.(false);
|
||||
if (sideBarBookKey) {
|
||||
const viewSettings = getViewSettings(sideBarBookKey)!;
|
||||
viewSettings.sortedTOC = !isSortedTOC;
|
||||
setViewSettings(sideBarBookKey, viewSettings);
|
||||
recreateViewer(envConfig, sideBarBookKey);
|
||||
saveViewSettings(envConfig, sideBarBookKey, 'sortedTOC', !isSortedTOC, true, false).then(
|
||||
() => {
|
||||
recreateViewer(envConfig, sideBarBookKey);
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
const handleSetParallel = () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as CFI from 'foliate-js/epubcfi.js';
|
|||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { findTocItemBS } from '@/utils/toc';
|
||||
import { findTocItemBS } from '@/services/nav';
|
||||
import { findNearestCfi } from '@/utils/cfi';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
import { BooknoteGroup, BookNoteType } from '@/types/book';
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import 'overlayscrollbars/overlayscrollbars.css';
|
|||
import { TOCItem } from '@/libs/document';
|
||||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useSidebarStore } from '@/store/sidebarStore';
|
||||
import { findParentPath } from '@/utils/toc';
|
||||
import { findParentPath } from '@/services/nav';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { useTextTranslation } from '../../hooks/useTextTranslation';
|
||||
import { FlatTOCItem, StaticListRow } from './TOCItem';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useSidebarStore } from '@/store/sidebarStore';
|
|||
import { useReaderStore } from '@/store/readerStore';
|
||||
import { useBookDataStore } from '@/store/bookDataStore';
|
||||
import { isCfiInLocation } from '@/utils/cfi';
|
||||
import { findTocItemBS } from '@/utils/toc';
|
||||
import { findTocItemBS } from '@/services/nav';
|
||||
import { BookNoteType } from '@/types/book';
|
||||
import { TOCItem } from '@/libs/document';
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +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 type { BookNav } from '@/services/nav';
|
||||
import { getLibraryFilename, getLibraryBackupFilename } from '@/utils/book';
|
||||
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
getPrimaryLanguage,
|
||||
getMetadataHash,
|
||||
} from '@/utils/book';
|
||||
import type { BookNav } from '@/utils/toc';
|
||||
import type { BookNav } from '@/services/nav';
|
||||
import { partialMD5, md5 } from '@/utils/md5';
|
||||
import { getBaseFilename, getFilename } from '@/utils/path';
|
||||
import { BookDoc, DocumentLoader, EXTS } from '@/libs/document';
|
||||
|
|
|
|||
147
apps/readest-app/src/services/nav/enrichment.ts
Normal file
147
apps/readest-app/src/services/nav/enrichment.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// Embedded-nav fallback
|
||||
//
|
||||
// Some EPUBs ship a sparse toc.ncx (top-level volume/part entries only) while
|
||||
// the real chapter-level TOC lives inside a content HTML as a <nav> block.
|
||||
// foliate-js only parses <nav> from files flagged with properties="nav" in the
|
||||
// OPF, so those embedded navs are invisible to bookDoc.toc. When the spine has
|
||||
// far more sections than the TOC references, scan section HTMLs for <nav>
|
||||
// elements and merge their links as ordinary top-level TOC items.
|
||||
|
||||
import { BookDoc, TOCItem } from '@/libs/document';
|
||||
import { collectAllTocItems } from './grouping';
|
||||
|
||||
const NAV_ENRICH_MIN_SECTIONS = 64;
|
||||
const NAV_ENRICH_TOC_RATIO = 8;
|
||||
const EPUB_NS = 'http://www.idpf.org/2007/ops';
|
||||
|
||||
interface ExtractedNavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
subitems?: ExtractedNavItem[];
|
||||
}
|
||||
|
||||
const resolveNavHref = (href: string, sectionId: string): string => {
|
||||
if (!href) return '';
|
||||
try {
|
||||
const base = `epub:///${encodeURI(sectionId)}`;
|
||||
const url = new URL(href, base);
|
||||
if (url.protocol !== 'epub:') return '';
|
||||
const path = decodeURIComponent(url.pathname.replace(/^\//, ''));
|
||||
return path + url.hash;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const firstChildByLocalName = (parent: Element, names: readonly string[]): Element | null => {
|
||||
for (const child of Array.from(parent.children)) {
|
||||
if (names.includes(child.localName)) return child;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const childrenByLocalName = (parent: Element, name: string): Element[] =>
|
||||
Array.from(parent.children).filter((c) => c.localName === name);
|
||||
|
||||
const normalizeWhitespace = (s: string): string => s.replace(/\s+/g, ' ').trim();
|
||||
|
||||
const parseNavList = (list: Element, sectionId: string): ExtractedNavItem[] => {
|
||||
const items: ExtractedNavItem[] = [];
|
||||
for (const li of childrenByLocalName(list, 'li')) {
|
||||
const anchor = firstChildByLocalName(li, ['a', 'span']);
|
||||
const sublist = firstChildByLocalName(li, ['ol', 'ul']);
|
||||
const rawHref = anchor?.getAttribute('href') ?? '';
|
||||
const href = resolveNavHref(rawHref, sectionId);
|
||||
const label =
|
||||
normalizeWhitespace(anchor?.textContent ?? '') || (anchor?.getAttribute('title') ?? '');
|
||||
const subitems = sublist ? parseNavList(sublist, sectionId) : undefined;
|
||||
if (!href && !subitems?.length) continue;
|
||||
items.push({
|
||||
label,
|
||||
href,
|
||||
subitems: subitems?.length ? subitems : undefined,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const extractTocFromNav = (nav: Element, sectionId: string): ExtractedNavItem[] => {
|
||||
const types = (nav.getAttributeNS(EPUB_NS, 'type') ?? '').split(/\s+/).filter(Boolean);
|
||||
if (types.includes('page-list') || types.includes('landmarks')) return [];
|
||||
const list = firstChildByLocalName(nav, ['ol', 'ul']);
|
||||
return list ? parseNavList(list, sectionId) : [];
|
||||
};
|
||||
|
||||
const toTocItems = (items: ExtractedNavItem[]): TOCItem[] =>
|
||||
items.map((item) => ({
|
||||
id: 0,
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
index: 0,
|
||||
subitems: item.subitems?.length ? toTocItems(item.subitems) : undefined,
|
||||
}));
|
||||
|
||||
const hrefSection = (href: string): string => {
|
||||
const idx = href.indexOf('#');
|
||||
return idx < 0 ? href : href.slice(0, idx);
|
||||
};
|
||||
|
||||
export const enrichTocFromNavElements = async (
|
||||
bookDoc: BookDoc,
|
||||
tocClone: TOCItem[],
|
||||
): Promise<boolean> => {
|
||||
const sections = bookDoc.sections ?? [];
|
||||
if (sections.length <= NAV_ENRICH_MIN_SECTIONS) return false;
|
||||
const flatCount = collectAllTocItems(tocClone).length;
|
||||
if (flatCount > 0 && sections.length <= NAV_ENRICH_TOC_RATIO * flatCount) return false;
|
||||
|
||||
const existingHrefs = new Set<string>();
|
||||
for (const item of collectAllTocItems(tocClone)) {
|
||||
if (!item.href) continue;
|
||||
const [rawSection] = bookDoc.splitTOCHref(item.href) as [string | undefined];
|
||||
existingHrefs.add(rawSection ?? hrefSection(item.href));
|
||||
}
|
||||
|
||||
const sectionIdSet = new Set(sections.map((s) => s.id));
|
||||
|
||||
const collected: ExtractedNavItem[] = [];
|
||||
const seenHref = new Set<string>();
|
||||
|
||||
for (const section of sections) {
|
||||
if (!section.loadText) continue;
|
||||
let content: string | null = null;
|
||||
try {
|
||||
content = await section.loadText();
|
||||
} catch {
|
||||
content = null;
|
||||
}
|
||||
if (!content || !content.includes('<nav')) continue;
|
||||
|
||||
let doc: Document;
|
||||
try {
|
||||
doc = await section.createDocument();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const navs = Array.from(doc.getElementsByTagName('nav'));
|
||||
if (navs.length === 0) continue;
|
||||
|
||||
for (const navEl of navs) {
|
||||
const extracted = extractTocFromNav(navEl, section.id);
|
||||
for (const item of extracted) {
|
||||
const sectionPart = hrefSection(item.href);
|
||||
if (!sectionPart || !sectionIdSet.has(sectionPart)) continue;
|
||||
if (existingHrefs.has(sectionPart)) continue;
|
||||
if (seenHref.has(item.href)) continue;
|
||||
seenHref.add(item.href);
|
||||
collected.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!collected.length) return false;
|
||||
|
||||
tocClone.push(...toTocItems(collected));
|
||||
return true;
|
||||
};
|
||||
78
apps/readest-app/src/services/nav/fragments.ts
Normal file
78
apps/readest-app/src/services/nav/fragments.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { CFI, SectionFragment, SectionItem, TOCItem } from '@/libs/document';
|
||||
|
||||
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 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);
|
||||
};
|
||||
|
||||
export 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;
|
||||
};
|
||||
117
apps/readest-app/src/services/nav/grouping.ts
Normal file
117
apps/readest-app/src/services/nav/grouping.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { BookDoc, SectionFragment, TOCItem } from '@/libs/document';
|
||||
|
||||
export const cloneTocItems = (items: TOCItem[]): TOCItem[] =>
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
subitems: item.subitems ? cloneTocItems(item.subitems) : undefined,
|
||||
}));
|
||||
|
||||
export 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,
|
||||
}));
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export interface SectionGroup {
|
||||
base: TOCItem | null;
|
||||
fragments: TOCItem[];
|
||||
}
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
// Ported from foliate-js: restructure TOC so that fragment-linked subitems under
|
||||
// the same section are regrouped under a natural parent when one exists.
|
||||
export 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;
|
||||
}
|
||||
};
|
||||
163
apps/readest-app/src/services/nav/index.ts
Normal file
163
apps/readest-app/src/services/nav/index.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { ConvertChineseVariant } from '@/types/book';
|
||||
import { BookDoc, SectionFragment, TOCItem } from '@/libs/document';
|
||||
import { initSimpleCC, runSimpleCC } from '@/utils/simplecc';
|
||||
import {
|
||||
cloneSectionFragments,
|
||||
cloneTocItems,
|
||||
collectAllTocItems,
|
||||
groupItemsBySection,
|
||||
groupTocSubitems,
|
||||
} from './grouping';
|
||||
import { bakeLocationsAndCfis, sortTocItems } from './locations';
|
||||
import { buildSectionFragments } from './fragments';
|
||||
import { enrichTocFromNavElements } from './enrichment';
|
||||
|
||||
export { findParentPath, findTocItemBS } from './lookup';
|
||||
export type { SectionFragment };
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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).
|
||||
// v3: nav-enrichment fallback — when toc.ncx is sparse, scan section HTMLs for
|
||||
// embedded <nav> elements and merge their links as top-level TOC items.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const BOOK_NAV_VERSION = 3;
|
||||
|
||||
export interface BookNavSection {
|
||||
id: string;
|
||||
fragments: SectionFragment[];
|
||||
}
|
||||
|
||||
export interface BookNav {
|
||||
version: number;
|
||||
toc: TOCItem[];
|
||||
sections: Record<string, BookNavSection>;
|
||||
}
|
||||
|
||||
const convertTocLabels = (items: TOCItem[], convertChineseVariant: ConvertChineseVariant) => {
|
||||
items.forEach((item) => {
|
||||
if (item.label) {
|
||||
item.label = runSimpleCC(item.label, convertChineseVariant);
|
||||
}
|
||||
if (item.subitems) {
|
||||
convertTocLabels(item.subitems, convertChineseVariant);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Per-open transformations: label conversion (user setting) and optional sort.
|
||||
// Structural location/cfi baking lives in computeBookNav/hydrateBookNav so
|
||||
// the cached BookNav carries the expensive-to-derive fields across opens.
|
||||
export const updateToc = async (
|
||||
bookDoc: BookDoc,
|
||||
sortedTOC: boolean,
|
||||
convertChineseVariant: ConvertChineseVariant,
|
||||
) => {
|
||||
if (bookDoc.rendition?.layout === 'pre-paginated') return;
|
||||
|
||||
const items = bookDoc?.toc || [];
|
||||
if (!items.length) return;
|
||||
|
||||
if (convertChineseVariant && convertChineseVariant !== 'none') {
|
||||
await initSimpleCC();
|
||||
convertTocLabels(items, convertChineseVariant);
|
||||
}
|
||||
|
||||
if (sortedTOC) sortTocItems(items);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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> = {};
|
||||
const enrichedNav = await enrichTocFromNavElements(bookDoc, tocClone);
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// Attach the freshly computed fragments to live sections so the bake can see
|
||||
// them via createSectionsMap. hydrateBookNav on the same bookDoc (called
|
||||
// right after by readerStore) will re-clone these, so the shared-ref write
|
||||
// here is a temporary staging step.
|
||||
for (const section of bookSections) {
|
||||
section.fragments = sections[section.id]?.fragments;
|
||||
}
|
||||
bakeLocationsAndCfis(tocClone, bookSections, splitHref);
|
||||
|
||||
if (enrichedNav) {
|
||||
sortTocItems(tocClone);
|
||||
}
|
||||
|
||||
return { version: BOOK_NAV_VERSION, toc: tocClone, sections };
|
||||
};
|
||||
|
||||
/**
|
||||
* 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, then (re)derive
|
||||
* section/fragment locations and TOC item cfi+location. Pure; 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;
|
||||
}
|
||||
}
|
||||
bakeLocationsAndCfis(bookDoc.toc ?? [], bookSections, (h) => bookDoc.splitTOCHref(h));
|
||||
};
|
||||
158
apps/readest-app/src/services/nav/locations.ts
Normal file
158
apps/readest-app/src/services/nav/locations.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { SectionFragment, SectionItem, TOCItem } from '@/libs/document';
|
||||
import { SIZE_PER_LOC } from '@/services/constants';
|
||||
|
||||
export type SplitTOCHref = (href: string) => Array<string | number>;
|
||||
|
||||
const calculateCumulativeSizes = (sections: SectionItem[]): number[] => {
|
||||
const sizes = sections.map((s) => (s.linear !== 'no' && s.size > 0 ? s.size : 0));
|
||||
let cumulative = 0;
|
||||
return sizes.reduce((acc: number[], size) => {
|
||||
acc.push(cumulative);
|
||||
cumulative += size;
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const processFragmentLocations = (
|
||||
fragments: SectionFragment[],
|
||||
parentByteOffset: number,
|
||||
parentLocation: { current: number; next: number; total: number },
|
||||
totalLocations: number,
|
||||
) => {
|
||||
let currentByteOffset = parentByteOffset;
|
||||
|
||||
fragments.forEach((fragment, index) => {
|
||||
const nextFragment = index < fragments.length - 1 ? fragments[index + 1] : null;
|
||||
|
||||
currentByteOffset += fragment.size || 0;
|
||||
const nextByteOffset = nextFragment
|
||||
? currentByteOffset + (nextFragment.size || 0)
|
||||
: parentLocation.next * SIZE_PER_LOC;
|
||||
|
||||
fragment.location = {
|
||||
current: Math.floor(currentByteOffset / SIZE_PER_LOC),
|
||||
next: Math.floor(nextByteOffset / SIZE_PER_LOC),
|
||||
total: totalLocations,
|
||||
};
|
||||
|
||||
if (fragment.fragments?.length) {
|
||||
processFragmentLocations(
|
||||
fragment.fragments,
|
||||
currentByteOffset,
|
||||
fragment.location,
|
||||
totalLocations,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateSectionLocations = (
|
||||
sections: SectionItem[],
|
||||
cumulativeSizes: number[],
|
||||
sizes: number[],
|
||||
totalLocations: number,
|
||||
) => {
|
||||
sections.forEach((section, index) => {
|
||||
const baseOffset = cumulativeSizes[index]!;
|
||||
const sectionSize = sizes[index]!;
|
||||
|
||||
section.location = {
|
||||
current: Math.floor(baseOffset / SIZE_PER_LOC),
|
||||
next: Math.floor((baseOffset + sectionSize) / SIZE_PER_LOC),
|
||||
total: totalLocations,
|
||||
};
|
||||
|
||||
if (section.fragments?.length) {
|
||||
processFragmentLocations(section.fragments, baseOffset, section.location, totalLocations);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 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'];
|
||||
}
|
||||
|
||||
type SectionsMap = Record<string, LocatedEntry>;
|
||||
|
||||
const addFragmentsToMap = (fragments: SectionFragment[], map: SectionsMap) => {
|
||||
for (const fragment of fragments) {
|
||||
if (fragment.href) map[fragment.href] = fragment;
|
||||
if (fragment.fragments?.length) addFragmentsToMap(fragment.fragments, map);
|
||||
}
|
||||
};
|
||||
|
||||
const createSectionsMap = (sections: SectionItem[]): SectionsMap => {
|
||||
const map: SectionsMap = {};
|
||||
for (const section of sections) {
|
||||
map[section.id] = section;
|
||||
if (section.fragments?.length) addFragmentsToMap(section.fragments, map);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
const updateTocLocation = (
|
||||
splitTOCHref: SplitTOCHref,
|
||||
items: TOCItem[],
|
||||
sections: SectionItem[],
|
||||
sectionsMap: SectionsMap,
|
||||
index = 0,
|
||||
): number => {
|
||||
items.forEach((item) => {
|
||||
item.id ??= index++;
|
||||
if (item.href) {
|
||||
const id = splitTOCHref(item.href)[0]!;
|
||||
const exactMatch = sectionsMap[item.href];
|
||||
const baseMatch = sectionsMap[id];
|
||||
const section = (exactMatch?.cfi ? exactMatch : null) || baseMatch || exactMatch;
|
||||
if (section) {
|
||||
item.cfi = section.cfi;
|
||||
if (
|
||||
id === item.href ||
|
||||
items.length <= sections.length ||
|
||||
item.href === section.href ||
|
||||
item.href === section.id
|
||||
) {
|
||||
item.location = section.location;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.subitems) {
|
||||
index = updateTocLocation(splitTOCHref, item.subitems, sections, sectionsMap, index);
|
||||
}
|
||||
});
|
||||
return index;
|
||||
};
|
||||
|
||||
// Pure: compute + write section/fragment locations and TOC item cfi+location.
|
||||
// Callers are responsible for passing sections that already have any applicable
|
||||
// fragments attached (hydrateBookNav does this before calling). No I/O.
|
||||
export const bakeLocationsAndCfis = (
|
||||
items: TOCItem[],
|
||||
sections: SectionItem[],
|
||||
splitTOCHref: SplitTOCHref,
|
||||
) => {
|
||||
if (!items.length || !sections.length) return;
|
||||
|
||||
const sizes = sections.map((s) => (s.linear !== 'no' && s.size > 0 ? s.size : 0));
|
||||
const cumulativeSizes = calculateCumulativeSizes(sections);
|
||||
const totalSize = cumulativeSizes[cumulativeSizes.length - 1]! + sizes[sizes.length - 1]!;
|
||||
const totalLocations = Math.floor(totalSize / SIZE_PER_LOC);
|
||||
|
||||
updateSectionLocations(sections, cumulativeSizes, sizes, totalLocations);
|
||||
const sectionsMap = createSectionsMap(sections);
|
||||
updateTocLocation(splitTOCHref, items, sections, sectionsMap);
|
||||
};
|
||||
|
||||
export const sortTocItems = (items: TOCItem[]): void => {
|
||||
items.sort((a, b) => {
|
||||
if (a.location && b.location) {
|
||||
return a.location.current - b.location.current;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
45
apps/readest-app/src/services/nav/lookup.ts
Normal file
45
apps/readest-app/src/services/nav/lookup.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { CFI, TOCItem } from '@/libs/document';
|
||||
|
||||
export const findParentPath = (toc: TOCItem[], href: string): TOCItem[] => {
|
||||
for (const item of toc) {
|
||||
if (item.href === href) {
|
||||
return [item];
|
||||
}
|
||||
if (item.subitems) {
|
||||
const path = findParentPath(item.subitems, href);
|
||||
if (path.length) {
|
||||
return [item, ...path];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const findInSubitems = (item: TOCItem, cfi: string): TOCItem | null => {
|
||||
if (!item.subitems?.length) return null;
|
||||
return findTocItemBS(item.subitems, cfi);
|
||||
};
|
||||
|
||||
export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
|
||||
if (!cfi) return null;
|
||||
let left = 0;
|
||||
let right = toc.length - 1;
|
||||
let result: TOCItem | null = null;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const item = toc[mid]!;
|
||||
const currentCfi = toc[mid]!.cfi || '';
|
||||
const comparison = CFI.compare(currentCfi, cfi);
|
||||
if (comparison === 0) {
|
||||
return findInSubitems(item, cfi) ?? item;
|
||||
} else if (comparison < 0) {
|
||||
result = findInSubitems(item, cfi) ?? item;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
|
@ -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 { BOOK_NAV_VERSION, computeBookNav, hydrateBookNav, updateToc } from '@/utils/toc';
|
||||
import { BOOK_NAV_VERSION, computeBookNav, hydrateBookNav, updateToc } from '@/services/nav';
|
||||
import { formatTitle, getMetadataHash, getPrimaryLanguage } from '@/utils/book';
|
||||
import { getBaseFilename } from '@/utils/path';
|
||||
import { SUPPORTED_LANGNAMES } from '@/services/constants';
|
||||
|
|
@ -184,7 +184,7 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
|
|||
// 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) {
|
||||
if (cachedNav?.version === BOOK_NAV_VERSION && process.env.NODE_ENV === 'production') {
|
||||
hydrateBookNav(bookDoc, cachedNav);
|
||||
} else {
|
||||
const freshNav = await computeBookNav(bookDoc);
|
||||
|
|
|
|||
|
|
@ -1,7 +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 type { BookNav } from '@/services/nav';
|
||||
import { ProgressHandler } from '@/utils/transfer';
|
||||
import { CustomFont, CustomFontInfo } from '@/styles/fonts';
|
||||
import { CustomTextureInfo } from '@/styles/textures';
|
||||
|
|
|
|||
|
|
@ -1,527 +0,0 @@
|
|||
import { ConvertChineseVariant } from '@/types/book';
|
||||
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) {
|
||||
return [item];
|
||||
}
|
||||
if (item.subitems) {
|
||||
const path = findParentPath(item.subitems, href);
|
||||
if (path.length) {
|
||||
return [item, ...path];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
export const findTocItemBS = (toc: TOCItem[], cfi: string): TOCItem | null => {
|
||||
if (!cfi) return null;
|
||||
let left = 0;
|
||||
let right = toc.length - 1;
|
||||
let result: TOCItem | null = null;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
const item = toc[mid]!;
|
||||
const currentCfi = toc[mid]!.cfi || '';
|
||||
const comparison = CFI.compare(currentCfi, cfi);
|
||||
if (comparison === 0) {
|
||||
return findInSubitems(item, cfi) ?? item;
|
||||
} else if (comparison < 0) {
|
||||
result = findInSubitems(item, cfi) ?? item;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const findInSubitems = (item: TOCItem, cfi: string): TOCItem | null => {
|
||||
if (!item.subitems?.length) return null;
|
||||
return findTocItemBS(item.subitems, cfi);
|
||||
};
|
||||
|
||||
// Helper: Calculate cumulative sizes for sections
|
||||
const calculateCumulativeSizes = (sections: SectionItem[]): number[] => {
|
||||
const sizes = sections.map((s) => (s.linear !== 'no' && s.size > 0 ? s.size : 0));
|
||||
let cumulative = 0;
|
||||
return sizes.reduce((acc: number[], size) => {
|
||||
acc.push(cumulative);
|
||||
cumulative += size;
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
fragments.forEach((fragment, index) => {
|
||||
const nextFragment = index < fragments.length - 1 ? fragments[index + 1] : null;
|
||||
|
||||
currentByteOffset += fragment.size || 0;
|
||||
const nextByteOffset = nextFragment
|
||||
? currentByteOffset + (nextFragment.size || 0)
|
||||
: parentLocation.next * SIZE_PER_LOC;
|
||||
|
||||
fragment.location = {
|
||||
current: Math.floor(currentByteOffset / SIZE_PER_LOC),
|
||||
next: Math.floor(nextByteOffset / SIZE_PER_LOC),
|
||||
total: totalLocations,
|
||||
};
|
||||
|
||||
if (fragment.fragments?.length) {
|
||||
processFragmentLocations(
|
||||
fragment.fragments,
|
||||
currentByteOffset,
|
||||
fragment.location,
|
||||
totalLocations,
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateSectionLocations = (
|
||||
sections: SectionItem[],
|
||||
cumulativeSizes: number[],
|
||||
sizes: number[],
|
||||
totalLocations: number,
|
||||
) => {
|
||||
sections.forEach((section, index) => {
|
||||
const baseOffset = cumulativeSizes[index]!;
|
||||
const sectionSize = sizes[index]!;
|
||||
|
||||
section.location = {
|
||||
current: Math.floor(baseOffset / SIZE_PER_LOC),
|
||||
next: Math.floor((baseOffset + sectionSize) / SIZE_PER_LOC),
|
||||
total: totalLocations,
|
||||
};
|
||||
|
||||
if (section.fragments?.length) {
|
||||
processFragmentLocations(section.fragments, baseOffset, section.location, totalLocations);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 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 fragments
|
||||
type Href = string;
|
||||
type SectionsMap = Record<Href, LocatedEntry>;
|
||||
const createSectionsMap = (sections: SectionItem[]) => {
|
||||
const map: SectionsMap = {};
|
||||
|
||||
for (const section of sections) {
|
||||
map[section.id] = section;
|
||||
if (section.fragments?.length) addFragmentsToMap(section.fragments, map);
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
// Main: Update TOC with section locations and metadata
|
||||
export const updateToc = async (
|
||||
bookDoc: BookDoc,
|
||||
sortedTOC: boolean,
|
||||
convertChineseVariant: ConvertChineseVariant,
|
||||
) => {
|
||||
if (bookDoc.rendition?.layout === 'pre-paginated') return;
|
||||
|
||||
const items = bookDoc?.toc || [];
|
||||
const sections = bookDoc?.sections || [];
|
||||
if (!items.length || !sections.length) return;
|
||||
|
||||
// Step 1: Apply Chinese variant conversion if needed
|
||||
if (convertChineseVariant && convertChineseVariant !== 'none') {
|
||||
await initSimpleCC();
|
||||
convertTocLabels(items, convertChineseVariant);
|
||||
}
|
||||
|
||||
// Step 2: Calculate section sizes and locations
|
||||
const sizes = sections.map((s) => (s.linear !== 'no' && s.size > 0 ? s.size : 0));
|
||||
const cumulativeSizes = calculateCumulativeSizes(sections);
|
||||
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 fragments
|
||||
updateSectionLocations(sections, cumulativeSizes, sizes, totalLocations);
|
||||
|
||||
// Step 4: Create sections map and update TOC locations
|
||||
const sectionsMap = createSectionsMap(sections);
|
||||
updateTocLocation(bookDoc, items, sections, sectionsMap);
|
||||
|
||||
// Step 5: Sort TOC if requested
|
||||
if (sortedTOC) sortTocItems(items);
|
||||
};
|
||||
|
||||
const convertTocLabels = (items: TOCItem[], convertChineseVariant: ConvertChineseVariant) => {
|
||||
items.forEach((item) => {
|
||||
if (item.label) {
|
||||
item.label = runSimpleCC(item.label, convertChineseVariant);
|
||||
}
|
||||
if (item.subitems) {
|
||||
convertTocLabels(item.subitems, convertChineseVariant);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateTocLocation = (
|
||||
bookDoc: BookDoc,
|
||||
items: TOCItem[],
|
||||
sections: SectionItem[],
|
||||
sectionsMap: SectionsMap,
|
||||
index = 0,
|
||||
): number => {
|
||||
items.forEach((item) => {
|
||||
item.id ??= index++;
|
||||
if (item.href) {
|
||||
const id = bookDoc.splitTOCHref(item.href)[0]!;
|
||||
const exactMatch = sectionsMap[item.href];
|
||||
const baseMatch = sectionsMap[id];
|
||||
const section = (exactMatch?.cfi ? exactMatch : null) || baseMatch || exactMatch;
|
||||
if (section) {
|
||||
item.cfi = section.cfi;
|
||||
if (
|
||||
id === item.href ||
|
||||
items.length <= sections.length ||
|
||||
item.href === section.href ||
|
||||
item.href === section.id
|
||||
) {
|
||||
item.location = section.location;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.subitems) {
|
||||
index = updateTocLocation(bookDoc, item.subitems, sections, sectionsMap, index);
|
||||
}
|
||||
});
|
||||
return index;
|
||||
};
|
||||
|
||||
const sortTocItems = (items: TOCItem[]): void => {
|
||||
items.sort((a, b) => {
|
||||
if (a.location && b.location) {
|
||||
return a.location.current - b.location.current;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
Reference in a new issue