ttrpg-tools/src/data-loader/file-index.ts

229 lines
6.3 KiB
TypeScript
Raw Normal View History

/**
*
*
* 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 {
2026-03-18 11:31:13 +08:00
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;
}