Export isSparkTableHeader from content-registry and use it in both CLI and frontend table parsing. Add resolveContentRef to resolve content references consistently. Write processed registry content back to the file index so browser mode renders identically to CLI mode.
246 lines
6.8 KiB
TypeScript
246 lines
6.8 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";
|
|
import { normalizePathKey } from "../cli/content-registry";
|
|
|
|
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 = normalizePathKey(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;
|
|
}
|
|
|
|
/**
|
|
* 写入/覆盖索引中的文件内容。
|
|
* 用于将处理后的内容(如 registry 的 stripped markdown)写回索引,
|
|
* 使浏览器模式与 CLI 模式渲染一致。
|
|
*/
|
|
export function setIndexedData(path: string, content: string): void {
|
|
fileIndex = fileIndex || {};
|
|
fileIndex[normalizePathKey(path)] = 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;
|
|
}
|