Files
ttrpg-tools/src/data-loader/file-index.ts
T
hyper e8aa7165cb refactor(completions): centralize content scanning
Add a browser-safe ContentRegistry owning path and inline content
stores, then derive completions from it. Remove the old block-processor,
directive-scanner, and link-source modules in favor of scanDoc,
deriveCompletions, and injectSparkDirectives, and register an inline
resolver for the frontend file index.
2026-08-07 12:17:35 +08:00

235 lines
6.4 KiB
TypeScript

/**
* 文件索引管理器
* 支持两种文件索引加载方式:
* 1. CLI / 代理环境:从 /__CONTENT_INDEX.json 加载
* 2. 浏览器环境:用户选择本地文件夹,扫描文件索引
* - 支持 IndexedDB 持久化目录句柄,刷新页面无需重新选择
*
* Dev 工作流:运行 CLI 服务器并在 rsbuild 配置中设置代理
* > npm run tttk serve ./content
* > npm run dev
* rsbuild 会将 /__CONTENT_INDEX.json、/__COMPLETIONS.json、/content/ 代理到 CLI 服务器
*/
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" | "folder" | null = null;
/**
* Optional registry for resolving inline content ids (set by the journal
* completions module). When present, `getIndexedData` resolves ids that
* aren't real files through it.
*/
let inlineResolver: ((path: string) => string | null) | null = null;
/** Register a resolver for inline content ids (see journal/completions). */
export function setInlineResolver(
fn: ((path: string) => string | null) | null,
): void {
inlineResolver = fn;
}
/** Currently active directory handle (if folder source) */
let activeDirHandle: FileSystemDirectoryHandle | null = null;
/**
* 加载文件索引(只加载一次)
* 尝试顺序:CLI JSON → 已持久化的目录句柄
*/
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: 浏览器 — 尝试从 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|svg)$/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 策略
*/
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];
}
// Resolve inline content ids through the registry before fetching.
if (inlineResolver) {
const inline = inlineResolver(path);
if (inline !== null) {
fileIndex = fileIndex || {};
fileIndex[path] = inline;
return inline;
}
}
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;
}