import { basename, dirname, extname, join } from "node:path"; import type { FilesMkdirRequest, FilesMkdirResult, FilesRenameRequest, FilesRenameResult } from "@umt/contracts"; import { sanitizeSegment } from "@umt/domain"; import type { Principal } from "../../http/auth.ts"; import { lstat, mkdir } from "../../storage/local-fs.ts"; import { isRemotePath, vexists, vlstat, vmkdirp, vrenameNoClobber } from "../../storage/vfs.ts"; import { actorOfP, errMessage, errnoOf, FilesError, type FmDeps, recheck, relOf, resolveIn } from "./common.ts"; import { audit, filesChanged } from "./ledger.ts"; import { downloadsFor, isWritableTarget, kindOfName, managedDownloads, sidecarsOf } from "./list.ts"; import { type RecordFile, relocate, relocatePreview, writeRecord } from "./records.ts"; // mkdir / rename:同目录改名不覆盖(moveSafe = link+unlink / lstat+rename),执行后 inode 比对; // 已登记资产同事务同步 asset_location,目录命中 path_binding 写修订。 const parentRel = (rel: string) => (rel.includes("/") ? dirname(rel) : ""); function assertCleanName(name: string) { if (sanitizeSegment(name) !== name) throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(name)}`); } export async function mkdirIn( d: FmDeps, principal: Principal | undefined, body: FilesMkdirRequest, ): Promise { const r = await resolveIn(d, body.root, body.path); if (!(await isWritableTarget(r.def, r.rootReal))) throw new FilesError("not_writable", `登记目录「${r.def.name}」不可写`); if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`); assertCleanName(body.name); await recheck(r.abs, r.rootReal); const abs = join(r.abs, body.name); try { if (r.def.storageId) { if (await vexists(abs)) throw Object.assign(new Error("exists"), { code: "EEXIST" }); await vmkdirp(abs); } else await mkdir(abs); } catch (e) { if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在:${body.name}`); throw new FilesError("bad_request", `创建目录失败:${errMessage(e)}`); } const rel = relOf(r.rootReal, abs); await audit(d, principal, "mkdir", { root: body.root, path: rel }); filesChanged(d, body.root, [r.rel]); return { root: body.root, path: rel }; } interface Pair { from: string; to: string; fromAbs: string; toAbs: string; role: "main" | "sidecar"; } export async function renameEntry( d: FmDeps, principal: Principal | undefined, body: FilesRenameRequest, ): Promise { const r = await resolveIn(d, body.root, body.path); if (r.rel === "") throw new FilesError("bad_request", "登记目录本身不能重命名"); if (!r.def.modifiable) throw new FilesError("not_writable", `登记目录「${r.def.name}」只读,不能重命名`); assertCleanName(body.newName); const oldName = basename(r.abs); if (body.newName === oldName) throw new FilesError("bad_request", "新名称与原名称相同"); const isDir = !!r.lst?.isDirectory() && !r.isSymlink; // 纳管下载任务的内容(或目录下有):改名会让下载器找不到文件 const hits = downloadsFor(await managedDownloads(d), r.abs); if (hits.length > 0) throw new FilesError( "download_active", `该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。整理后再改名`, ); const dirAbs = dirname(r.abs); const pRel = parentRel(r.rel); const relIn = (n: string) => (pRel ? `${pRel}/${n}` : n); const pairs: Pair[] = [ { from: r.rel, to: relIn(body.newName), fromAbs: r.abs, toAbs: join(dirAbs, body.newName), role: "main" }, ]; if (!isDir && body.withSidecars && kindOfName(oldName) === "video") { const oldStem = oldName.slice(0, oldName.length - extname(oldName).length); const newStem = body.newName.slice(0, body.newName.length - extname(body.newName).length); for (const n of await sidecarsOf(r.abs)) { const nn = `${newStem}${n.slice(oldStem.length)}`; pairs.push({ from: relIn(n), to: relIn(nn), fromAbs: join(dirAbs, n), toAbs: join(dirAbs, nn), role: "sidecar" }); } } // 不覆盖:任一目标已存在 → 409,原文件不动 const taken: string[] = []; for (const p of pairs) { const st = await (isRemotePath(p.toAbs) ? vlstat(p.toAbs) : lstat(p.toAbs)).catch((e) => errnoOf(e) === "ENOENT" ? null : e, ); if (st) taken.push(p.to); } if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖:${taken.join(", ")}`); const lib = r.def.libraryRootId; let locCount = 0; let bindingIds: string[] = []; if (lib) { for (const p of pairs) { const pv = await relocatePreview(d.ctx.db, lib, p.from, isDir && p.role === "main"); locCount += pv.locations; bindingIds = [...bindingIds, ...pv.bindingIds]; } } const renames = pairs.map((p) => ({ from: p.from, to: p.to, role: p.role })); if (body.dryRun) return { dryRun: true, renames, assetLocations: locCount, bindings: bindingIds.map((id) => ({ oldBindingId: id, newBindingId: null })), recordId: null, }; // 执行:逐个 no-clobber 改名;任一失败回滚已改的 const done: Pair[] = []; const rollback = async () => { for (const p of done.reverse()) await vrenameNoClobber(p.toAbs, p.fromAbs).catch(() => undefined); }; for (const p of pairs) { try { await recheck(p.fromAbs, r.rootReal); const remote = isRemotePath(p.fromAbs); const before = remote ? null : await lstat(p.fromAbs); const res = await vrenameNoClobber(p.fromAbs, p.toAbs); if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖:${p.to}`); if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`); // 远端没有 inode:Alist 改名接口成功即算成功(同 MP) const after = remote ? null : await lstat(p.toAbs); if (before && after && (after.ino !== before.ino || after.dev !== before.dev)) throw new FilesError("bad_request", `重命名后 inode 不一致,已中止:${p.to}`); done.push(p); } catch (e) { await rollback(); throw e; } } let recordId: string | null = null; const bindings: FilesRenameResult["bindings"] = []; let synced = 0; if (lib) { try { await d.ctx.db.transaction().execute(async (trx) => { const files: RecordFile[] = []; const entities = new Set(); for (const p of pairs) { const rr = await relocate(trx, { fromLib: lib, fromRel: p.from, toLib: lib, toRel: p.to, isDir: isDir && p.role === "main", actor: actorOfP(principal), opKind: "rename", }); synced += rr.locations.length; bindings.push(...rr.bindings); for (const e of rr.mediaEntityIds) entities.add(e); if (rr.locations.length > 0 || rr.bindings.length > 0) files.push({ src: p.from, dst: p.to, size: 0, status: "succeeded", role: p.role === "main" ? "main" : "other", }); } if (files.length > 0) recordId = await writeRecord(d, trx, { principal, action: "rename", mediaEntityId: entities.size === 1 ? [...entities][0]! : null, sourcePath: r.abs, targetLibraryRootId: lib, targetPath: pairs[0]!.to, plan: { op: "rename", renames, bindings }, files, }); }); } catch (e) { // 数据库同步失败:文件改回原名,保持"路径变更与登记同事务" await rollback(); throw e; } } await audit(d, principal, "rename", { root: body.root, renames, assetLocations: synced, bindings, recordId, }); filesChanged(d, body.root, [pRel]); return { dryRun: false, renames, assetLocations: synced, bindings, recordId }; }