2026-03-13 15:50:50 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 文件索引管理器
|
2026-06-30 19:20:08 +08:00
|
|
|
|
* 支持多种文件索引加载方式:
|
|
|
|
|
|
* 1. CLI 环境:从 /__CONTENT_INDEX.json 加载
|
|
|
|
|
|
* 2. Dev 环境:使用 webpackContext 仅加载 .md 文件
|
|
|
|
|
|
* 3. 浏览器环境:用户选择本地文件夹,扫描文件索引
|
|
|
|
|
|
* - 支持 IndexedDB 持久化目录句柄,刷新页面无需重新选择
|
2026-03-13 15:50:50 +08:00
|
|
|
|
*/
|
|
|
|
|
|
|
2026-06-30 19:20:08 +08:00
|
|
|
|
import {
|
|
|
|
|
|
saveHandle,
|
|
|
|
|
|
loadHandle,
|
|
|
|
|
|
removeHandle,
|
|
|
|
|
|
ensurePermission,
|
|
|
|
|
|
} from "./file-index-db";
|
|
|
|
|
|
|
2026-03-13 15:50:50 +08:00
|
|
|
|
type FileIndex = Record<string, string>;
|
|
|
|
|
|
|
|
|
|
|
|
let fileIndex: FileIndex | null = null;
|
|
|
|
|
|
let indexLoadPromise: Promise<void> | null = null;
|
2026-06-30 19:20:08 +08:00
|
|
|
|
let activeSource: "cli" | "webpack" | "folder" | null = null;
|
|
|
|
|
|
|
|
|
|
|
|
/** Currently active directory handle (if folder source) */
|
|
|
|
|
|
let activeDirHandle: FileSystemDirectoryHandle | null = null;
|
2026-03-13 15:50:50 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 加载文件索引(只加载一次)
|
2026-06-30 19:20:08 +08:00
|
|
|
|
* 尝试顺序:CLI JSON → webpack context → 已持久化的目录句柄
|
2026-03-13 15:50:50 +08:00
|
|
|
|
*/
|
|
|
|
|
|
function ensureIndexLoaded(): Promise<void> {
|
|
|
|
|
|
if (indexLoadPromise) return indexLoadPromise;
|
|
|
|
|
|
|
|
|
|
|
|
indexLoadPromise = (async () => {
|
2026-06-30 19:20:08 +08:00
|
|
|
|
// 策略 1: CLI 环境 — 从 /__CONTENT_INDEX.json 加载
|
2026-03-13 15:50:50 +08:00
|
|
|
|
try {
|
2026-03-18 11:31:13 +08:00
|
|
|
|
const response = await fetch("/__CONTENT_INDEX.json");
|
2026-03-13 15:50:50 +08:00
|
|
|
|
if (response.ok) {
|
|
|
|
|
|
const index = await response.json();
|
|
|
|
|
|
fileIndex = { ...fileIndex, ...index };
|
2026-06-30 19:20:08 +08:00
|
|
|
|
activeSource = "cli";
|
2026-03-13 15:50:50 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
2026-06-30 19:20:08 +08:00
|
|
|
|
// CLI 索引不可用时尝试下一个策略
|
2026-03-13 15:50:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 19:20:08 +08:00
|
|
|
|
// 策略 2: Dev 环境 — webpackContext + raw loader
|
2026-03-13 15:50:50 +08:00
|
|
|
|
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;
|
2026-06-30 19:20:08 +08:00
|
|
|
|
const content =
|
|
|
|
|
|
typeof module === "string" ? module : (module.default ?? "");
|
2026-03-13 15:50:50 +08:00
|
|
|
|
const normalizedPath = "/content" + key.slice(1);
|
|
|
|
|
|
index[normalizedPath] = content;
|
|
|
|
|
|
}
|
|
|
|
|
|
fileIndex = { ...fileIndex, ...index };
|
2026-06-30 19:20:08 +08:00
|
|
|
|
activeSource = "webpack";
|
2026-03-13 15:50:50 +08:00
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// webpackContext 不可用时忽略
|
|
|
|
|
|
}
|
2026-06-30 19:20:08 +08:00
|
|
|
|
|
|
|
|
|
|
// 策略 3: 浏览器 — 尝试从 IndexedDB 恢复保存的目录句柄
|
|
|
|
|
|
if (!activeSource) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const restored = await restoreSavedHandle();
|
|
|
|
|
|
if (restored) {
|
|
|
|
|
|
activeSource = "folder";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
// 无法恢复,继续 — 用户需要手动选择文件夹
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-13 15:50:50 +08:00
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
return indexLoadPromise;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-30 19:20:08 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 扫描文件系统目录,构建文件索引
|
|
|
|
|
|
* 递归扫描,仅索引 .md / .yarn / .csv 文件
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function scanDirectory(
|
|
|
|
|
|
handle: FileSystemDirectoryHandle,
|
|
|
|
|
|
prefix = "",
|
|
|
|
|
|
): Promise<FileIndex> {
|
|
|
|
|
|
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<boolean> {
|
|
|
|
|
|
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<string[] | null> {
|
|
|
|
|
|
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<void> {
|
|
|
|
|
|
await removeHandle();
|
|
|
|
|
|
activeDirHandle = null;
|
|
|
|
|
|
fileIndex = null;
|
|
|
|
|
|
indexLoadPromise = null;
|
|
|
|
|
|
activeSource = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取当前激活的内容源
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function getActiveSource(): string | null {
|
|
|
|
|
|
return activeSource;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 15:50:50 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从索引获取文件内容
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function getIndexedData(path: string): Promise<string> {
|
|
|
|
|
|
await ensureIndexLoaded();
|
|
|
|
|
|
if (fileIndex && fileIndex[path]) {
|
|
|
|
|
|
return fileIndex[path];
|
|
|
|
|
|
}
|
|
|
|
|
|
const res = await fetch(path);
|
|
|
|
|
|
const content = await res.text();
|
|
|
|
|
|
fileIndex = fileIndex || {};
|
|
|
|
|
|
fileIndex[path] = content;
|
2026-06-30 19:20:08 +08:00
|
|
|
|
return content;
|
2026-03-13 15:50:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取指定扩展名的文件路径
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function getPathsByExtension(ext: string): Promise<string[]> {
|
|
|
|
|
|
await ensureIndexLoaded();
|
|
|
|
|
|
if (!fileIndex) return [];
|
|
|
|
|
|
const normalizedExt = ext.startsWith(".") ? ext : `.${ext}`;
|
2026-06-30 19:20:08 +08:00
|
|
|
|
return Object.keys(fileIndex).filter((path) =>
|
|
|
|
|
|
path.toLowerCase().endsWith(normalizedExt.toLowerCase()),
|
2026-03-13 15:50:50 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清除索引(用于测试或重新加载)
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function clearIndex(): void {
|
|
|
|
|
|
fileIndex = null;
|
|
|
|
|
|
indexLoadPromise = null;
|
2026-06-30 19:20:08 +08:00
|
|
|
|
activeSource = null;
|
|
|
|
|
|
activeDirHandle = null;
|
2026-03-13 15:50:50 +08:00
|
|
|
|
}
|