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:
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} 个文件已完成)` : ""}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user