Files
rednote-tool-studio/lib/storage.ts
T
2026-09-17 22:35:27 +08:00

109 lines
4.4 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { PROJECTS_ROOT, TEMPLATES_ROOT } from "./config";
import type { ProjectMeta } from "./types";
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{2,48}$/;
export function assertProjectId(id: string) {
if (!ID_PATTERN.test(id)) throw new Error("Invalid project id");
}
export function projectRoot(id: string) {
assertProjectId(id);
return path.join(PROJECTS_ROOT, id);
}
export function workspaceRoot(id: string) {
return path.join(projectRoot(id), "workspace");
}
export function safeWorkspacePath(id: string, segments: string[]) {
const root = workspaceRoot(id);
const target = path.resolve(root, ...segments);
if (target !== root && !target.startsWith(`${root}${path.sep}`)) throw new Error("Path escapes workspace");
if (segments.some((part) => part === "node_modules" || part === ".git")) throw new Error("Protected path");
return target;
}
async function readMetaFile(id: string): Promise<ProjectMeta> {
return JSON.parse(await fs.readFile(path.join(projectRoot(id), "project.json"), "utf8"));
}
export async function touchProject(id: string) {
const meta = await readMetaFile(id);
meta.lastAccessAt = new Date().toISOString();
await fs.writeFile(path.join(projectRoot(id), "project.json"), JSON.stringify(meta, null, 2));
return meta;
}
export async function getProject(id: string, touch = true) {
return touch ? touchProject(id) : readMetaFile(id);
}
export async function listProjects() {
await fs.mkdir(PROJECTS_ROOT, { recursive: true });
const entries = await fs.readdir(PROJECTS_ROOT, { withFileTypes: true });
const results = await Promise.all(entries.filter((entry) => entry.isDirectory()).map(async (entry) => {
try { return await readMetaFile(entry.name); } catch { return null; }
}));
return results.filter((item): item is ProjectMeta => Boolean(item)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export async function createProject(input: { name?: string; template?: string }) {
const template = input.template || "basic";
const templateRoot = path.join(/* turbopackIgnore: true */ TEMPLATES_ROOT, template);
await fs.access(templateRoot);
const slug = (input.name || "新小工具").toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-").replace(/^-|-$/g, "").slice(0, 28) || "tool";
const id = `${slug}-${crypto.randomBytes(3).toString("hex")}`;
const root = projectRoot(id);
await fs.mkdir(root, { recursive: true });
await fs.cp(templateRoot, workspaceRoot(id), { recursive: true });
const now = new Date().toISOString();
const meta: ProjectMeta = { id, name: input.name || "新小工具", template, revision: 1, createdAt: now, updatedAt: now, lastAccessAt: now };
await fs.writeFile(path.join(root, "project.json"), JSON.stringify(meta, null, 2));
return meta;
}
export async function listFiles(id: string) {
await touchProject(id);
const root = workspaceRoot(id);
const files: string[] = [];
async function walk(dir: string) {
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
if (["node_modules", ".git", "dist"].includes(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(full);
else files.push(path.relative(root, full).split(path.sep).join("/"));
}
}
await walk(root);
return files.sort();
}
export async function readProjectFile(id: string, segments: string[]) {
await touchProject(id);
return fs.readFile(safeWorkspacePath(id, segments), "utf8");
}
export async function writeProjectFiles(id: string, changes: Array<{ path: string; content: string }>, expectedRevision?: number) {
const meta = await readMetaFile(id);
if (expectedRevision !== undefined && expectedRevision !== meta.revision) {
const error = new Error(`Revision conflict: expected ${expectedRevision}, current ${meta.revision}`);
(error as Error & { status?: number }).status = 409;
throw error;
}
for (const change of changes) {
const target = safeWorkspacePath(id, change.path.split("/"));
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, change.content, "utf8");
}
const now = new Date().toISOString();
meta.revision += 1;
meta.updatedAt = now;
meta.lastAccessAt = now;
await fs.writeFile(path.join(projectRoot(id), "project.json"), JSON.stringify(meta, null, 2));
return meta;
}