229 lines
6.3 KiB
TypeScript
229 lines
6.3 KiB
TypeScript
/**
|
||
* 文件索引管理器
|
||
* 支持多种文件索引加载方式:
|
||
* 1. CLI 环境:从 /__CONTENT_INDEX.json 加载
|
||
* 2. Dev 环境:使用 webpackContext 仅加载 .md 文件
|
||
* 3. 浏览器环境:用户选择本地文件夹,扫描文件索引
|
||
* - 支持 IndexedDB 持久化目录句柄,刷新页面无需重新选择
|
||
*/
|
||
|
||
import {
|
||
saveHandle,
|
||
loadHandle,
|
||
removeHandle,
|
||
ensurePermission,
|
||
} from "./file-index-db";
|
||
|
||
type FileIndex = Record<string, string>;
|
||
|
||
let fileIndex: FileIndex | null = null;
|
||
let indexLoadPromise: Promise<void> | 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<void> {
|
||
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<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;
|
||
}
|
||
|
||
/**
|
||
* 从索引获取文件内容
|
||
*/
|
||
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;
|
||
return content;
|
||
}
|
||
|
||
/**
|
||
* 获取指定扩展名的文件路径
|
||
*/
|
||
export async function getPathsByExtension(ext: string): Promise<string[]> {
|
||
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;
|
||
}
|