style(copy): punctuation rules and site-wide fix with a CI check (QA U11)

- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:09:29 +08:00
1 parent 9b017906cf
commit 52402864d6
143 files changed
+985 -606

No files matched your search

@@ -124,7 +124,7 @@ export function fsErrorToFiles(e: unknown): FilesError | null {
const code = errnoOf(e);
if (typeof code !== "string" || !/^E[A-Z]+$/.test(code)) return null;
const msg = e instanceof Error ? e.message : String(e);
if (OFFLINE_CODES.has(code)) return new FilesError("root_unavailable", `读取失败(${code}),根可能已离线: ${msg}`);
if (OFFLINE_CODES.has(code)) return new FilesError("root_unavailable", `读取失败(${code}),根可能已离线:${msg}`);
if (code === "ENOENT" || code === "ENOTDIR") return new FilesError("not_found", `路径已不存在(${code})`);
if (code === "EACCES" || code === "EPERM") return new FilesError("forbidden", `没有访问权限(${code})`);
return new FilesError("bad_request", `文件系统错误(${code}): ${msg}`);
@@ -189,7 +189,7 @@ export async function loadRootDefs(exec: Exec): Promise<RootDef[]> {
export async function rootDef(exec: Exec, id: string): Promise<RootDef> {
const r = (await loadRootDefs(exec)).find((x) => x.id === id);
if (!r) throw new FilesError("not_found", `根不存在或未启用: ${id}`);
if (!r) throw new FilesError("not_found", `根不存在或未启用:${id}`);
return r;
}
@@ -261,7 +261,7 @@ export interface Resolved {
}
function checkRel(rel: string) {
if (rel.startsWith("/") || rel.includes("\0")) throw new FilesError("path_escape", `非法相对路径: ${rel}`);
if (rel.startsWith("/") || rel.includes("\0")) throw new FilesError("path_escape", `非法相对路径:${rel}`);
const segs = rel.split("/").filter((s) => s.length > 0);
if (segs.some((s) => s === ".." || s === ".")) throw new FilesError("path_escape", `路径不允许包含 . 或 ..: ${rel}`);
return segs.join("/");
@@ -290,9 +290,9 @@ export async function resolveIn(
parentReal = await realpath(dirname(joined));
} catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`);
throw new FilesError("not_found", `父目录不存在: ${dirname(rel) || "/"}`);
throw new FilesError("not_found", `父目录不存在:${dirname(rel) || "/"}`);
}
if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接指向根外: ${rel}`);
if (!within(parentReal, rootReal)) throw new FilesError("path_escape", `路径经符号链接指向根外:${rel}`);
const abs = join(parentReal, basename(joined));
let lst: Awaited<ReturnType<typeof lstat>> | null = null;
try {
@@ -301,12 +301,12 @@ export async function resolveIn(
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `根 ${def.name} 读取失败(${errnoOf(e)})`);
if (errnoOf(e) !== "ENOENT") throw new FilesError("bad_request", `无法访问 ${rel}: ${errnoOf(e)}`);
}
if (!lst && opts.mustExist !== false) throw new FilesError("not_found", `不存在: ${rel}`);
if (!lst && opts.mustExist !== false) throw new FilesError("not_found", `不存在:${rel}`);
const isSymlink = !!lst?.isSymbolicLink();
if (isSymlink) {
const target = await realpath(abs).catch(() => null);
if (!target || !within(target, rootReal))
throw new FilesError("path_escape", `符号链接指向根外或无法解析,不能操作: ${rel}`);
throw new FilesError("path_escape", `符号链接指向根外或无法解析,不能操作:${rel}`);
}
return { def, rootReal, rel: relOf(rootReal, abs), abs, lst, isSymlink };
}
@@ -318,9 +318,9 @@ export async function recheck(abs: string, rootReal: string): Promise<string> {
r = await realpath(abs);
} catch (e) {
if (isOfflineErr(e)) throw new FilesError("root_unavailable", `读取失败(${errnoOf(e)})`);
throw new FilesError("plan_stale", `源已不存在: ${abs}`);
throw new FilesError("plan_stale", `源已不存在:${abs}`);
}
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向根外: ${abs}`);
if (!within(r, rootReal)) throw new FilesError("path_escape", `执行时路径指向根外:${abs}`);
return r;
}
@@ -43,12 +43,12 @@ export async function downloadFile(
): Promise<Response> {
// 路由中间件已按 auth:"session" 拦截;这里同口径复核(纵深防御)
if (!isAdminSession(principal))
throw new FilesError("forbidden", "下载仅限控制台管理员会话,API Key / DEV key 不可用");
throw new FilesError("forbidden", "下载仅限控制台管理员会话,API Key / DEV key 不可用");
const r = await resolveIn(d, q.root, q.path);
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
if (r.rel === "" || r.lst?.isDirectory()) throw new FilesError("bad_request", "只能下载文件,不能下载目录");
const name = basename(r.abs);
const kind = kindOfName(name);
if (!ALLOWED.has(kind)) throw new FilesError("forbidden", `只能下载 NFO、字幕和图片,此文件类型为 ${kind}`);
if (!ALLOWED.has(kind)) throw new FilesError("forbidden", `只能下载 NFO、字幕和图片,此文件类型为 ${kind}`);
const real = await recheck(r.abs, r.rootReal);
const file = await Deno.open(real, { read: true });
let size: number;
@@ -59,7 +59,7 @@ export async function downloadFile(
if (size > d.options.downloadMaxBytes)
throw new FilesError(
"too_large",
`文件 ${size} 字节,超过下载上限 ${d.options.downloadMaxBytes}(FILES_DOWNLOAD_MAX_BYTES)`,
`文件 ${size} 字节,超过下载上限 ${d.options.downloadMaxBytes}(FILES_DOWNLOAD_MAX_BYTES)`,
);
} catch (e) {
file.close();
@@ -16,7 +16,7 @@ const parentRel = (rel: string) => (rel.includes("/") ? dirname(rel) : "");
function assertCleanName(name: string) {
if (sanitizeSegment(name) !== name)
throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(name)}`);
throw new FilesError("bad_request", `文件名含非法字符或首尾有空白/点,可改为:${sanitizeSegment(name)}`);
}
export async function mkdirIn(
@@ -26,15 +26,15 @@ export async function mkdirIn(
): Promise<FilesMkdirResult> {
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 || "/"}`);
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 {
await mkdir(abs);
} catch (e) {
if (errnoOf(e) === "EEXIST") throw new FilesError("exists", `已存在: ${body.name}`);
throw new FilesError("bad_request", `创建目录失败: ${errMessage(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 });
@@ -57,7 +57,7 @@ export async function renameEntry(
): Promise<FilesRenameResult> {
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} 只读,不能重命名`);
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", "新名称与原名称相同");
@@ -68,7 +68,7 @@ export async function renameEntry(
if (hits.length > 0)
throw new FilesError(
"download_active",
`该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理入库再改`,
`该路径属于下载任务 ${hits.map((h) => h.taskId).join(", ")},重命名会让下载器找不到文件。先整理入库再改`,
);
const dirAbs = dirname(r.abs);
@@ -91,7 +91,7 @@ export async function renameEntry(
const st = await 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(", ")}`);
if (taken.length > 0) throw new FilesError("exists", `目标已存在,不覆盖:${taken.join(", ")}`);
const lib = r.def.libraryRootId;
let locCount = 0;
@@ -123,11 +123,11 @@ export async function renameEntry(
await recheck(p.fromAbs, r.rootReal);
const before = await lstat(p.fromAbs);
const res = await moveSafe(p.fromAbs, p.toAbs);
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖: ${p.to}`);
if (res.status === "exists") throw new FilesError("exists", `目标已存在,不覆盖:${p.to}`);
if (res.status !== "moved") throw new FilesError("bad_request", `重命名失败(${res.status}): ${p.from}`);
const after = await lstat(p.toAbs);
if (after.ino !== before.ino || after.dev !== before.dev)
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止: ${p.to}`);
throw new FilesError("bad_request", `重命名后 inode 不一致,已中止:${p.to}`);
done.push(p);
} catch (e) {
await rollback();
@@ -213,10 +213,10 @@ export async function organizeFiles(
for (const it of body.items) {
const r = await resolveIn(d, it.root, it.path);
if (r.rel === "") throw new FilesError("bad_request", "不能整理整个根");
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接: ${r.rel}`);
if (r.isSymlink) throw new FilesError("bad_request", `不整理符号链接:${r.rel}`);
const action = it.operation ?? body.action;
if (action === "move" && !r.def.modifiable)
throw new FilesError("not_writable", `源根 ${r.def.name} 只读,不能移动`);
throw new FilesError("not_writable", `源根 ${r.def.name} 只读,不能移动`);
roots.add(it.root);
const isDir = !!r.lst?.isDirectory();
const entityId = entityOf(it);
@@ -231,7 +231,7 @@ export async function organizeFiles(
if (it.targetDir) {
const t = await resolveIn(d, it.targetDir.root, it.targetDir.path);
if (!t.lst?.isDirectory() || t.isSymlink || !t.def.libraryRootId)
throw new FilesError("bad_request", `目标目录必须是库根下已存在的目录: ${it.targetDir.path || "/"}`);
throw new FilesError("bad_request", `目标目录必须是库根下已存在的目录:${it.targetDir.path || "/"}`);
if (!(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写`);
targetDir = join(t.def.hostPath, t.rel);
@@ -239,13 +239,13 @@ export async function organizeFiles(
}
// 季/集
if (it.season !== undefined || it.episodes !== undefined) {
if (!isTv) warnings.push(`${r.rel}: 电影不适用季/集覆写,已忽略`);
if (!isTv) warnings.push(`${r.rel}:电影不适用季/集覆写,已忽略`);
else {
if (it.season !== undefined) ov.season = it.season;
if (it.episodes?.length) {
const range = episodeRange(it.episodes);
if (!range) warnings.push(`${r.rel}: 集号 ${it.episodes.join(",")} 不连续,未应用(只支持连续区间)`);
else if (isDir) warnings.push(`${r.rel}: 目录项不能指定集号(会套到其下所有视频),未应用`);
if (!range) warnings.push(`${r.rel}:集号 ${it.episodes.join(",")} 不连续,未应用(只支持连续区间)`);
else if (isDir) warnings.push(`${r.rel}:目录项不能指定集号(会套到其下所有视频),未应用`);
else ov.episodes = range;
}
}
@@ -255,7 +255,7 @@ export async function organizeFiles(
if (unit) {
const u = units.get(unit);
if (!u || u.media_entity_id !== entityId) {
warnings.push(`${r.rel}: 内容单元 ${unit} 不属于该作品,未应用`);
warnings.push(`${r.rel}:内容单元 ${unit} 不属于该作品,未应用`);
unit = null;
} else if (u.kind === "episode" || u.kind === "part") ov.contentUnitIds = [u.id];
}
@@ -264,10 +264,10 @@ export async function organizeFiles(
if (slotId) {
const s = slots.get(slotId);
if (!s || s.media_entity_id !== entityId) {
warnings.push(`${r.rel}: 版本目标 ${slotId} 不属于该作品,未应用`);
warnings.push(`${r.rel}:版本目标 ${slotId} 不属于该作品,未应用`);
slotId = null;
} else if (BUSY_SLOT.has(s.status)) {
warnings.push(`${r.rel}: 版本目标正在获取中(${s.status}),不改其状态`);
warnings.push(`${r.rel}:版本目标正在获取中(${s.status}),不改其状态`);
slotId = null;
}
}
@@ -416,12 +416,12 @@ export async function organizeFiles(
const frozen = (await loadPlan<Frozen>(d, planId, "organize", reqHash)).content;
const now = await factsOf(frozen.facts.map((f) => f.path));
const changed = now.find((f, i) => f.size !== frozen.facts[i]!.size || f.mtimeMs !== frozen.facts[i]!.mtimeMs);
if (changed) throw new FilesError("plan_stale", `源文件在预览后发生变化: ${changed.path}`);
if (changed) throw new FilesError("plan_stale", `源文件在预览后发生变化:${changed.path}`);
const again = await planImport(d.ao, importReq(true));
const nowPairs = pairsOf(again).map(({ src, dst }) => ({ src, dst }));
if (hashJson(nowPairs) !== hashJson(frozen.pairs))
throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览");
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
throw new FilesError("plan_stale", "目标目录或绑定已变化,与预览不一致。重新预览");
if (again.status === "failed") throw new FilesError("bad_request", `计划不可执行:${again.reason ?? "blocked"}`);
if (g.action === "move") {
const managed = await managedTasksUnder(db, req.sourcePaths);
@@ -436,7 +436,7 @@ export async function organizeFiles(
if (block)
throw new FilesError(
"download_active",
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`,
`下载任务 ${block.downloadTaskId} 的做种义务${block.hnr === "unknown" ? "未知" : "未满足"},不能移动,也不会自动改为复制。已建待处理事项 ${block.decisionId}`,
);
}
await retirePlan(d, planId);
@@ -551,6 +551,6 @@ async function pickPlan(d: FmDeps, planIds: string[], reqHash: string): Promise<
.where("title", "=", "files_plan")
.execute();
const hit = rows.find((r) => (r.content as { op?: string; requestHash?: string }).requestHash === reqHash);
if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,重新预览");
if (!hit) throw new FilesError("plan_stale", "没有与该组请求一致的 planId,重新预览");
return hit.id;
}
@@ -120,7 +120,7 @@ export async function scrape(
.where("media_entity_id", "=", e.id)
.where("status", "=", "active")
.executeTakeFirst();
if (!registered) throw new FilesError("bad_request", "作品没有已登记资产。先识别并整理入库,再刮削");
if (!registered) throw new FilesError("bad_request", "作品没有已登记资产。先识别并整理入库,再刮削");
const { runId } = await d.ctx.engine.enqueue(
"refreshMetadata",
{ mediaEntityId: e.id },
@@ -166,7 +166,7 @@ export async function scrapeBatch(
for (const id of found) ids.add(id);
}
if (ids.size === 0 && skipped.length === 0) throw new FilesError("bad_request", "需要 items 或 mediaEntityIds");
if (ids.size > 200) throw new FilesError("bad_request", `一次最多刷新 200 个作品(本次 ${ids.size})`);
if (ids.size > 200) throw new FilesError("bad_request", `一次最多刷新 200 个作品(本次 ${ids.size})`);
const list = [...ids];
const ents = list.length
? await db.selectFrom("media_entity").select(["id", "title", "field_locks"]).where("id", "in", list).execute()
@@ -95,20 +95,20 @@ export async function transfer(
const t = await resolveIn(d, body.target.root, body.target.path);
if (!t.lst?.isDirectory() || t.isSymlink) throw new FilesError("bad_request", "目标必须是已存在的目录");
if (!t.def.libraryRootId || !(await isWritableTarget(t.def, t.rootReal)))
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 managed 库根`);
throw new FilesError("not_writable", `目标根 ${t.def.name} 不可写,只能选 managed 库根`);
const sources: Resolved[] = [];
for (const it of body.items) {
const s = await resolveIn(d, it.root, it.path);
if (s.rel === "") throw new FilesError("bad_request", "根目录不能移动或复制");
if (mode === "move" && !s.def.modifiable) throw new FilesError("not_writable", `源根 ${s.def.name} 只读,只能复制`);
if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内: ${s.rel}`);
if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中: ${s.rel}`);
if (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接: ${s.rel}`);
if (mode === "move" && !s.def.modifiable) throw new FilesError("not_writable", `源根 ${s.def.name} 只读,只能复制`);
if (within(t.abs, s.abs)) throw new FilesError("bad_request", `目标在源目录之内:${s.rel}`);
if (dirname(s.abs) === t.abs) throw new FilesError("bad_request", `源已在目标目录中:${s.rel}`);
if (s.isSymlink) throw new FilesError("bad_request", `不能移动符号链接:${s.rel}`);
sources.push(s);
}
const names = sources.map((s) => basename(s.abs));
if (new Set(names).size !== names.length) throw new FilesError("bad_request", "多个源同名,会在目标目录冲突");
if (new Set(names).size !== names.length) throw new FilesError("bad_request", "多个源同名,会在目标目录冲突");
const snaps = await Promise.all(sources.map((s, i) => snapshot(d, s, body.items[i]!.root)));
const downloads = await managedDownloads(d);
@@ -148,7 +148,7 @@ export async function transfer(
const reason = planned.reason ?? "plan_failed";
throw new FilesError(
reason.startsWith("library_root_offline") ? "root_unavailable" : "bad_request",
`无法生成计划:${reason}`,
`无法生成计划:${reason}`,
);
}
const planFiles = planned.plan.organize.files;
@@ -189,9 +189,9 @@ export async function transfer(
const allowCross = (await d.ctx.settings.get("organize")).allowCrossVolumeMove;
const crossBlocked = mode === "move" && !allowCross && pairs.some((p) => p.crossDevice);
const warnings: string[] = [];
if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启");
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
if (truncated) warnings.push("源文件数超过上限,分批操作");
if (crossBlocked) warnings.push("未开启跨卷移动,执行会失败。改用复制,或在 设置 → 整理 中开启");
if (conflictSrc.size > 0) warnings.push(`${conflictSrc.size} 个文件在目标已存在,不覆盖,执行时记为失败`);
if (truncated) warnings.push("源文件数超过上限,分批操作");
const base = { mode, pairs, totalBytes, freeBytes, spaceOk, hnrBlocks, truncated, warnings };
if (body.dryRun) {
@@ -215,22 +215,22 @@ export async function transfer(
for (const [i, s] of snaps.entries()) {
const frozen = plan.content.sources[i];
if (!frozen || frozen.abs !== s.abs || frozen.fingerprint !== s.fingerprint)
throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单): ${s.rel}`);
throw new FilesError("plan_stale", `源在预览后发生变化(大小/修改时间/文件清单):${s.rel}`);
}
if (plan.content.files && hashJson(plan.content.files) !== hashJson(filePairs))
throw new FilesError("plan_stale", "目标已变化,与预览不一致。重新预览");
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,分批操作");
if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`);
throw new FilesError("plan_stale", "目标已变化,与预览不一致。重新预览");
if (truncated) throw new FilesError("bad_request", "源目录文件数超过上限,分批操作");
if (!spaceOk) throw new FilesError("bad_request", `目标卷空间不足:需要 ${needBytes},可用 ${freeBytes}`);
if (crossBlocked)
throw new FilesError(
"bad_request",
"未开启跨卷移动(settings.organize.allowCrossVolumeMove)。改用复制,或开启后重试",
"未开启跨卷移动(settings.organize.allowCrossVolumeMove)。改用复制,或开启后重试",
);
if (mode === "move") {
if (hnrBlocks.length > 0)
throw new FilesError(
"download_active",
`源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理入库,它会按做种义务决定能否移动`,
`源属于下载任务 ${[...new Set(hnrBlocks.map((h) => h.taskId))].join(", ")},直接移动会让下载器丢失文件。改用整理入库,它会按做种义务决定能否移动`,
);
if (!isAdmin(principal)) {
// 每个源按其(目录则为其下)已登记资产求作品集合,逐一核对授权;任一不覆盖 → 整体拒绝并列出未授权项
@@ -272,7 +272,7 @@ export async function transfer(
}
if (!rec.recordId || rec.status === "failed") {
await markTask(db, taskId, "failed", { error: rec.reason ?? rec.status });
throw new FilesError("bad_request", `计划不可执行:${rec.reason ?? rec.status}`);
throw new FilesError("bad_request", `计划不可执行:${rec.reason ?? rec.status}`);
}
const recordId = rec.recordId;
await db.updateTable("organization_record").set({ task_id: taskId }).where("id", "=", recordId).execute();
@@ -316,7 +316,7 @@ export async function transfer(
failed.push({
path: s.rel,
code: err.code === "EEXIST" ? "exists" : (err.code ?? bad.status),
message: `${err.message ?? bad.status}${mine.length > 1 ? `(${mine.filter((f) => f.status === "succeeded").length}/${mine.length} 个文件已完成)` : ""}`,
message: `${err.message ?? bad.status}${mine.length > 1 ? `(${mine.filter((f) => f.status === "succeeded").length}/${mine.length} 个文件已完成)` : ""}`,
});
}
}
+13 -13
View File
@@ -157,8 +157,8 @@ const BLOCK_ERR: Record<
> = {
download_active: ["download_active", "属于未完成或做种义务未满足的下载任务"],
is_root: ["bad_request", "不能删除根目录或只读根下的内容"],
needs_retire: ["needs_retire", "含已登记资产,需勾选“同时退役资产”"],
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选“解除绑定”"],
needs_retire: ["needs_retire", "含已登记资产,需勾选「同时退役资产」"],
needs_unbind: ["needs_unbind", "是作品/季的绑定目录,需勾选「解除绑定」"],
root_unavailable: ["root_unavailable", "根不可用"],
not_found: ["not_found", "不存在"],
escape: ["path_escape", "路径指向根外"],
@@ -276,8 +276,8 @@ export async function deleteFiles(
for (const s of slots)
await upsertDecision(d.ao, trx, {
kind: "other",
title: `版本目标的文件已删除:${s.name}`,
summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取",
title: `版本目标的文件已删除:${s.name}`,
summary: "满足该版本目标的资产已在文件管理中删除,版本目标改为需关注。确认是否重新获取",
subjectType: "acquisition_slot",
subjectId: s.id,
dedupeKey: `files_delete_slot:${s.id}`,
@@ -384,7 +384,7 @@ async function moveToTrash(
try {
await mkdir(dirname(trashPath), { recursive: true });
const r = await moveSafe(abs, trashPath);
if (r.status === "exists") throw new FilesError("exists", `回收站路径已存在: ${trashPath}`);
if (r.status === "exists") throw new FilesError("exists", `回收站路径已存在:${trashPath}`);
moved = r.status === "moved";
} catch (e) {
if (e instanceof FilesError) throw e;
@@ -396,7 +396,7 @@ async function moveToTrash(
if (check.size > d.options.trashCrossMaxBytes)
throw new FilesError(
"bad_request",
`无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes}。改用永久删除`,
`无法同卷移入回收站,且大小 ${check.size} 超过跨卷上限 ${d.options.trashCrossMaxBytes}。改用永久删除`,
);
await rm(join(ctx.rootReal!, TRASH_DIR, stamp, id), { recursive: true, force: true }).catch(() => undefined);
trashPath = join(resolve(d.options.dataDir), "trash", stamp, id, check.path);
@@ -487,10 +487,10 @@ export async function mkdirWithin(rootReal: string, rel: string): Promise<void>
throw e;
});
if (st?.isSymbolicLink())
throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`);
throw new FilesError("path_escape", `还原路径中的 ${relOfRoot(rootReal, cur)} 是符号链接,不能还原`);
if (st && !st.isDirectory()) throw new FilesError("exists", `还原路径中的 ${relOfRoot(rootReal, cur)} 不是目录`);
if (!st) await mkdir(cur);
if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向根外: ${rel}`);
if (!within(await realpath(cur), rootReal)) throw new FilesError("path_escape", `还原路径指向根外:${rel}`);
}
}
const relOfRoot = (root: string, p: string) => (p === root ? "" : p.slice(root.length + 1));
@@ -509,12 +509,12 @@ export async function restoreTrash(
throw new FilesError("bad_request", "还原目标必须是已存在的目录");
// "还原到…"只能进可写的 managed 库根(与 move/copy 的目标规则一致;register_only / 下载根拒绝,验收 07 S-5)
if (target && !(await isWritableTarget(target.def, target.rootReal)))
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 managed 库根`);
throw new FilesError("not_writable", `还原目标根 ${target.def.name} 不可写,只能选 managed 库根`);
for (const id of body.ids) {
try {
const row = await d.ctx.db.selectFrom("trash_item").selectAll().where("id", "=", id).executeTakeFirst();
if (!row) throw new FilesError("not_found", "回收站条目不存在");
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝操作");
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝操作");
let rootId = row.root_id;
let dst: string;
let rootReal: string;
@@ -537,7 +537,7 @@ export async function restoreTrash(
);
dst = parent.abs;
}
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择“还原到…”");
if (await lstat(dst).catch(() => null)) throw new FilesError("exists", "原位置已被占用,可选择「还原到…」");
const r = await moveSafe(row.trash_path, dst);
if (r.status === "exists") throw new FilesError("exists", "原位置已被占用");
if (r.status === "exdev") {
@@ -547,7 +547,7 @@ export async function restoreTrash(
if (bytes > d.options.trashCrossMaxBytes)
throw new FilesError(
"too_large",
`跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes}。在同卷还原,或手工处理 ${row.trash_path}`,
`跨卷还原大小 ${bytes} 超过上限 ${d.options.trashCrossMaxBytes}。在同卷还原,或手工处理 ${row.trash_path}`,
);
await copyTree(d, row.trash_path, dst, files);
await rm(row.trash_path, { recursive: true });
@@ -597,7 +597,7 @@ export async function purgeTrash(
const failed: FilesTrashPurgeResult["failed"] = [];
for (const row of rows) {
try {
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
if (!trashPathSafe(d, row)) throw new FilesError("path_escape", "回收站路径异常,拒绝清除");
const gone = await d.ctx.db.deleteFrom("trash_item").where("id", "=", row.id).returning("id").executeTakeFirst();
if (!gone) continue;
await rm(row.trash_path, { recursive: true, force: true });
+1 -1
View File
@@ -19,7 +19,7 @@ const hiddenName = (n: string) => n.startsWith(".") || isTempName(n);
export async function treeOf(d: FmDeps, q: FilesTreeQuery): Promise<FilesTreeResult> {
const r = await resolveIn(d, q.root, q.path);
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录: ${r.rel || "/"}`);
if (!r.lst?.isDirectory() || r.isSymlink) throw new FilesError("bad_request", `不是目录:${r.rel || "/"}`);
const names = await readdir(r.abs, { withFileTypes: true });
const subdirs = names
.filter((n) => n.isDirectory() && !hiddenName(n.name))