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.
235 lines
6.4 KiB
TypeScript
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;
|
|
}
|