/** * 文件索引管理器 * 支持多种文件索引加载方式: * 1. CLI 环境:从 /__CONTENT_INDEX.json 加载 * 2. Dev 环境:使用 webpackContext 仅加载 .md 文件 * 3. 浏览器环境:用户选择本地文件夹,扫描文件索引 * - 支持 IndexedDB 持久化目录句柄,刷新页面无需重新选择 */ import { saveHandle, loadHandle, removeHandle, ensurePermission, } from "./file-index-db"; type FileIndex = Record; let fileIndex: FileIndex | null = null; let indexLoadPromise: Promise | null = null; let activeSource: "cli" | "webpack" | "folder" | null = null; /** Currently active directory handle (if folder source) */ let activeDirHandle: FileSystemDirectoryHandle | null = null; /** * 加载文件索引(只加载一次) * 尝试顺序:CLI JSON → webpack context → 已持久化的目录句柄 */ function ensureIndexLoaded(): Promise { if (indexLoadPromise) return indexLoadPromise; indexLoadPromise = (async () => { // 策略 1: CLI 环境 — 从 /__CONTENT_INDEX.json 加载 try { const response = await fetch("/__CONTENT_INDEX.json"); if (response.ok) { const index = await response.json(); fileIndex = { ...fileIndex, ...index }; activeSource = "cli"; return; } } catch (e) { // CLI 索引不可用时尝试下一个策略 } // 策略 2: Dev 环境 — webpackContext + raw loader try { const context = import.meta.webpackContext("../../content", { recursive: true, regExp: /\.md|\.yarn|\.csv$/i, }); const keys = context.keys(); const index: FileIndex = {}; for (const key of keys) { const module = context(key) as { default?: string } | string; const content = typeof module === "string" ? module : (module.default ?? ""); const normalizedPath = "/content" + key.slice(1); index[normalizedPath] = content; } fileIndex = { ...fileIndex, ...index }; activeSource = "webpack"; } catch (e) { // webpackContext 不可用时忽略 } // 策略 3: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄 if (!activeSource) { try { const restored = await restoreSavedHandle(); if (restored) { activeSource = "folder"; return; } } catch (e) { // 无法恢复,继续 — 用户需要手动选择文件夹 } } })(); return indexLoadPromise; } /** * 扫描文件系统目录,构建文件索引 * 递归扫描,仅索引 .md / .yarn / .csv 文件 */ async function scanDirectory( handle: FileSystemDirectoryHandle, prefix = "", ): Promise { const index: FileIndex = {}; const acceptedExt = /\.(md|yarn|csv)$/i; for await (const [name, entry] of (handle as any).entries()) { if (entry.kind === "directory") { const sub = await scanDirectory( entry as FileSystemDirectoryHandle, prefix + name + "/", ); Object.assign(index, sub); } else if (entry.kind === "file" && acceptedExt.test(name)) { const file = await (entry as FileSystemFileHandle).getFile(); const path = prefix + name; index[path] = await file.text(); } } return index; } /** * 尝试从 IndexedDB 恢复之前保存的目录句柄并加载内容 */ async function restoreSavedHandle(): Promise { if (typeof indexedDB === "undefined") return false; const handle = await loadHandle(); if (!handle) return false; const permitted = await ensurePermission(handle); if (!permitted) return false; try { const index = await scanDirectory(handle); fileIndex = { ...fileIndex, ...index }; activeDirHandle = handle; // Refresh the promise so future calls use new index indexLoadPromise = Promise.resolve(); return true; } catch { // handle is stale (folder moved/deleted), clear it await removeHandle(); return false; } } /** * 让用户选择本地文件夹并加载文件索引 (Browser only) * 会持久化目录句柄到 IndexedDB,刷新后自动恢复。 * * @returns 索引中的文件路径数组,失败返回 null */ export async function loadFromUserFolder(): Promise { if (!("showDirectoryPicker" in window)) { console.warn("showDirectoryPicker not supported in this browser"); return null; } try { const handle = await window.showDirectoryPicker({ mode: "read" }); const index = await scanDirectory(handle); // Replace the existing index entirely with the user's folder content fileIndex = index; activeDirHandle = handle; activeSource = "folder"; // Reset the load promise so future ensureIndexLoaded() calls are no-ops indexLoadPromise = Promise.resolve(); await saveHandle(handle); return Object.keys(index); } catch (err) { // User cancelled or error if ((err as DOMException)?.name !== "AbortError") { console.error("Failed to load folder:", err); } return null; } } /** * 移除已保存的目录句柄,并清除索引 * 之后确保索引加载时回退到 CLI/webpack 策略 */ export async function switchToBuiltInSource(): Promise { await removeHandle(); activeDirHandle = null; fileIndex = null; indexLoadPromise = null; activeSource = null; } /** * 获取当前激活的内容源 */ export function getActiveSource(): string | null { return activeSource; } /** * 从索引获取文件内容 */ export async function getIndexedData(path: string): Promise { await ensureIndexLoaded(); if (fileIndex && fileIndex[path]) { return fileIndex[path]; } const res = await fetch(path); const content = await res.text(); fileIndex = fileIndex || {}; fileIndex[path] = content; return content; } /** * 获取指定扩展名的文件路径 */ export async function getPathsByExtension(ext: string): Promise { await ensureIndexLoaded(); if (!fileIndex) return []; const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`; return Object.keys(fileIndex).filter((path) => path.toLowerCase().endsWith(normalizedExt.toLowerCase()), ); } /** * 清除索引(用于测试或重新加载) */ export function clearIndex(): void { fileIndex = null; indexLoadPromise = null; activeSource = null; activeDirHandle = null; }