Files
memind/mindspace-oa-drive/lib/fs-tree.mjs
T
john 6d5eb15b1b feat(mindspace): add per-user OA cloud drive with fds-style file manager
Expose authenticated /api/mindspace/v1/oa/drive APIs over each user's oa/ workspace and wire a MindSpace entry at /space/oa/drive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:55:46 +08:00

130 lines
3.8 KiB
JavaScript

import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { isReservedName, metaFile, safeJoin } from './paths.mjs';
const PREVIEWABLE = new Set([
'.html', '.htm', '.css', '.js', '.mjs', '.json', '.png', '.jpg', '.jpeg', '.gif',
'.webp', '.svg', '.pdf', '.txt', '.md', '.csv', '.xml', '.mp4', '.webm', '.mp3', '.wav', '.ogg',
]);
export function isPreviewable(filePath) {
return PREVIEWABLE.has(path.extname(filePath).toLowerCase());
}
export function mimeFor(filePath) {
const ext = path.extname(filePath).toLowerCase();
const map = {
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.ogg': 'audio/ogg',
};
return map[ext] || 'application/octet-stream';
}
export async function walkUploadDir(ctx, dir, base = dir) {
const entries = await fsp.readdir(dir, { withFileTypes: true });
const files = [];
const dirs = [];
for (const entry of entries) {
if (isReservedName(ctx, entry.name)) continue;
const full = path.join(dir, entry.name);
const rel = path.relative(base, full).split(path.sep).join('/');
if (entry.isDirectory()) {
dirs.push(rel);
const nested = await walkUploadDir(ctx, full, base);
files.push(...nested.files);
dirs.push(...nested.dirs);
} else if (entry.isFile()) {
const stat = await fsp.stat(full);
files.push({
name: rel,
size: stat.size,
mtime: stat.mtimeMs,
previewable: isPreviewable(full),
});
}
}
return {
files: files.sort((a, b) => b.mtime - a.mtime),
dirs,
};
}
export async function loadMeta(ctx) {
const file = metaFile(ctx);
try {
const stat = await fsp.stat(file);
const raw = await fsp.readFile(file, 'utf8');
return { mtime: stat.mtimeMs, data: JSON.parse(raw) };
} catch {
return { mtime: Date.now(), data: { files: {} } };
}
}
export async function saveMeta(ctx, data) {
const file = metaFile(ctx);
await fsp.mkdir(path.dirname(file), { recursive: true });
await fsp.writeFile(file, JSON.stringify(data, null, 2));
}
export async function setFileMeta(ctx, relPath, meta) {
const loaded = await loadMeta(ctx);
loaded.data.files[relPath] = {
...(loaded.data.files[relPath] || {}),
...meta,
updatedAt: Date.now(),
};
await saveMeta(ctx, loaded.data);
}
export async function removeFileMeta(ctx, relPath) {
const loaded = await loadMeta(ctx);
delete loaded.data.files[relPath];
await saveMeta(ctx, loaded.data);
}
export async function renameFileMeta(ctx, fromRel, toRel) {
const loaded = await loadMeta(ctx);
if (loaded.data.files[fromRel]) {
loaded.data.files[toRel] = { ...loaded.data.files[fromRel], updatedAt: Date.now() };
delete loaded.data.files[fromRel];
await saveMeta(ctx, loaded.data);
}
}
export async function enrichFiles(ctx, files) {
const loaded = await loadMeta(ctx);
return files.map((f) => ({
...f,
origin: loaded.data.files[f.name] || null,
}));
}
export function statEntry(ctx, rel) {
const full = safeJoin(ctx.uploadDir, rel);
if (!fs.existsSync(full)) return null;
const stat = fs.statSync(full);
return { full, stat, isDirectory: stat.isDirectory() };
}