import type { BlockSource } from "./documentTypes"; export type MediaAssetKind = "image" | "svg"; export interface IndexedHtmlPage { id: string; html: string; title: string; pageNumber: number; anchors?: string[]; } export interface MediaAssetItem { id: string; kind: MediaAssetKind; fileName: string; src: string; pageIndex: number; sourceTitle: string; usageCount: number; references: Array<{ pageIndex: number; sourceTitle: string; }>; } export interface ContentSourceItem { id: string; file: string; path: string; kind?: string; chapter?: number; slug?: string; title: string; pageIndexes: number[]; sectionCount: number; } export interface BookmarkItem { id: string; anchorId?: string; title: string; label?: string; pageIndex: number; endPageIndex: number; subs: BookmarkSubItem[]; } export interface BookmarkSubItem { id: string; anchorId?: string; title: string; label?: string; pageIndex: number; endPageIndex: number; subs: BookmarkTopicItem[]; } export interface BookmarkTopicItem { id: string; anchorId?: string; title: string; label?: string; pageIndex: number; endPageIndex: number; } export interface BookmarkGuidePart { anchorId?: string; title: string; label?: string; } export interface BookmarkGuide { chapter: BookmarkGuidePart; section?: BookmarkGuidePart; } export function collectBookmarkIndex(pages: IndexedHtmlPage[]): BookmarkItem[] { const totalPages = pages.length; const chapters: BookmarkItem[] = []; let currentChapter: BookmarkItem | undefined; let currentSub: BookmarkSubItem | undefined; let pendingChapterOpener: { pageIndex: number } | undefined; let tocAdded = false; pages.forEach((page) => { const html = parseHtmlPage(page.html); if (!html) return; const readerPage = html.querySelector(".reader-page"); if (!readerPage) return; const pageIndex = page.pageNumber - 1; if (pageKindOf(readerPage) === "toc") { if (!tocAdded && readerPage.dataset.tocContinuation !== "true") { tocAdded = true; chapters.push({ id: `toc-bookmark-${page.pageNumber}`, title: readerPage.dataset.pageTitle || readerPage.querySelector(".toc-heading, h2")?.textContent?.trim() || "目錄", label: "00", pageIndex, endPageIndex: totalPages - 1, subs: [], }); } return; } if (pageKindOf(readerPage) === "chapter-opener") { pendingChapterOpener = { pageIndex }; return; } if (!isContentPage(readerPage)) return; let pageStartedChapter = false; html.querySelectorAll("h2, h3, h4").forEach((heading, headingIndex) => { const id = bookmarkItemId(page, heading, headingIndex); if (heading.tagName === "H2") { const opener = pendingChapterOpener; pendingChapterOpener = undefined; pageStartedChapter = true; currentChapter = { id, anchorId: heading.id || undefined, title: normalizeChapterTitle(heading.textContent ?? ""), label: heading instanceof HTMLElement ? heading.dataset.chapter : undefined, pageIndex: opener?.pageIndex ?? pageIndex, endPageIndex: totalPages - 1, subs: [], }; chapters.push(currentChapter); currentSub = undefined; return; } if (heading.tagName === "H3" && currentChapter) { currentSub = { id, anchorId: heading.id || undefined, title: normalizeSectionTitle(heading.textContent ?? ""), label: heading instanceof HTMLElement ? heading.dataset.section : undefined, pageIndex, endPageIndex: totalPages - 1, subs: [], }; currentChapter.subs.push(currentSub); return; } if (heading.tagName === "H4" && currentSub) { currentSub.subs.push({ id, anchorId: heading.id || undefined, title: normalizeTopicTitle(heading.textContent ?? ""), label: heading instanceof HTMLElement ? heading.dataset.topic : undefined, pageIndex, endPageIndex: totalPages - 1, }); } }); if (!pageStartedChapter) pendingChapterOpener = undefined; }); // Back-fill endPageIndex: each item ends where the next sibling starts (exclusive) for (let i = 0; i < chapters.length; i++) { const nextChapterStart = chapters[i + 1]?.pageIndex ?? totalPages; chapters[i].endPageIndex = nextChapterStart - 1; const subs = chapters[i].subs; for (let j = 0; j < subs.length; j++) { const nextSubStart = subs[j + 1]?.pageIndex ?? nextChapterStart; subs[j].endPageIndex = Math.max(subs[j].pageIndex, nextSubStart - 1); const topics = subs[j].subs; for (let k = 0; k < topics.length; k++) { const nextTopicStart = topics[k + 1]?.pageIndex ?? nextSubStart; topics[k].endPageIndex = Math.max(topics[k].pageIndex, nextTopicStart - 1); } } } return chapters; } export function bookmarkGuideForPage(items: BookmarkItem[], pageIndex: number): BookmarkGuide | null { const chapter = items.find((item) => pageIndex >= item.pageIndex && pageIndex <= item.endPageIndex); if (!chapter) return null; const section = chapter.subs .filter((item) => pageIndex >= item.pageIndex && pageIndex <= item.endPageIndex) .at(-1); return { chapter: bookmarkGuidePart(chapter), ...(section ? { section: bookmarkGuidePart(section) } : {}), }; } export function resolveBookmarkGuidePage(items: BookmarkItem[], guide: BookmarkGuide | null): number | null { if (!guide) return null; const chapter = matchingBookmark(items, guide.chapter); if (!chapter) return null; if (!guide.section) return chapter.pageIndex; return matchingBookmark(chapter.subs, guide.section)?.pageIndex ?? chapter.pageIndex; } function bookmarkGuidePart(item: BookmarkItem | BookmarkSubItem): BookmarkGuidePart { return { ...(item.anchorId ? { anchorId: item.anchorId } : {}), ...(item.label ? { label: item.label } : {}), title: item.title, }; } function matchingBookmark(items: T[], guide: BookmarkGuidePart): T | undefined { if (guide.anchorId) { const byAnchor = items.find((item) => item.anchorId === guide.anchorId); if (byAnchor) return byAnchor; } if (guide.label) { const byLabel = items.filter((item) => item.label === guide.label); if (byLabel.length === 1) return byLabel[0]; } const title = normalizedBookmarkTitle(guide.title); if (!title) return undefined; const byTitle = items.filter((item) => normalizedBookmarkTitle(item.title) === title); return byTitle.length === 1 ? byTitle[0] : undefined; } function normalizedBookmarkTitle(value: string) { return value.trim().replace(/\s+/g, " ").toLocaleLowerCase(); } function pageKindOf(page: HTMLElement) { return page.dataset.pageKind || ""; } function isContentPage(page: HTMLElement) { return pageKindOf(page) === "content"; } function bookmarkItemId(page: IndexedHtmlPage, heading: Element, headingIndex: number) { const anchor = heading.id || page.anchors?.[0] || page.id; return `${anchor}-bookmark-${page.pageNumber}-${headingIndex + 1}`; } export function collectContentSourceIndex( pages: Array, ): ContentSourceItem[] { const items = new Map(); pages.forEach((page) => { const source = page.source; if (!source?.path) return; const existing = items.get(source.path); if (existing) { existing.pageIndexes.push(page.pageNumber - 1); existing.sectionCount += 1; return; } items.set(source.path, { id: `content-${items.size + 1}`, file: source.file, path: source.path, kind: source.kind, chapter: source.chapter, slug: source.slug, title: page.title, pageIndexes: [page.pageNumber - 1], sectionCount: 1, }); }); return Array.from(items.values()).sort((a, b) => a.path.localeCompare(b.path, "zh-Hant")); } export function collectMediaAssetIndex(pages: IndexedHtmlPage[]): MediaAssetItem[] { const assets = createMediaInventory(); const imagePattern = /<(img|image)\b([^>]*)>/gi; pages.forEach((page) => { let match: RegExpExecArray | null; imagePattern.lastIndex = 0; while ((match = imagePattern.exec(page.html)) !== null) { const src = normalizeMediaAssetSrc(readHtmlAttribute(match[2], "src") ?? readHtmlAttribute(match[2], "href")); if (!src) continue; const existing = assets.get(src); if (existing) { existing.usageCount += 1; existing.pageIndex = existing.usageCount === 1 ? page.pageNumber - 1 : existing.pageIndex; existing.sourceTitle = existing.usageCount === 1 ? page.title : existing.sourceTitle; existing.references.push({ pageIndex: page.pageNumber - 1, sourceTitle: page.title, }); continue; } const fileName = safeDecodeURIComponent(src.split("/").pop() ?? src); assets.set(src, { id: `asset-${assets.size + 1}`, kind: fileName.toLowerCase().endsWith(".svg") ? "svg" : "image", fileName, src, pageIndex: page.pageNumber - 1, sourceTitle: page.title, usageCount: 1, references: [{ pageIndex: page.pageNumber - 1, sourceTitle: page.title, }], }); } }); return Array.from(assets.values()).sort((a, b) => a.fileName.localeCompare(b.fileName, "zh-Hant")); } const workspaceMediaFiles = import.meta.glob("@workspace/media/*", { eager: true, query: "?url", import: "default", }); function createMediaInventory() { const assets = new Map(); Object.keys(workspaceMediaFiles).forEach((path) => { const fileName = safeDecodeURIComponent(path.split("/").pop() ?? path); if (!isMediaFileName(fileName)) return; const src = `/openpress/media/${fileName}`; assets.set(src, { id: `asset-${assets.size + 1}`, kind: fileName.toLowerCase().endsWith(".svg") ? "svg" : "image", fileName, src, pageIndex: -1, sourceTitle: "未引用", usageCount: 0, references: [], }); }); return assets; } function isMediaFileName(fileName: string) { return /\.(png|jpe?g|gif|svg|webp)$/i.test(fileName); } function parseHtmlPage(html: string) { if (typeof DOMParser === "undefined") return undefined; return new DOMParser().parseFromString(html, "text/html"); } function normalizeChapterTitle(value: string) { return value .trim() .replace(/^[一二三四五六七八九十]+、\s*/, ""); } function normalizeSectionTitle(value: string) { return value .trim() .replace(/^[((][一二三四五六七八九十]+[))]、?\s*/, "") .replace(/^\d+\.\d+\s+/, ""); } function normalizeTopicTitle(value: string) { return value .trim() .replace(/^\d+\.\d+\.\d+\s+/, ""); } function readHtmlAttribute(attributes: string, name: string) { const pattern = new RegExp(`\\b${name}=["']([^"']+)["']`, "i"); return attributes.match(pattern)?.[1]; } function normalizeMediaAssetSrc(value?: string) { if (!value) return undefined; if (value.startsWith("/openpress/media/")) return value; if (value.startsWith("media/")) return `/openpress/${value}`; if (value.startsWith("./media/")) return `/openpress/${value.slice(2)}`; return undefined; } function safeDecodeURIComponent(value: string) { try { return decodeURIComponent(value); } catch { return value; } }