adm-zip throws "ADM-ZIP: Invalid filename" both for corrupt entries AND when the archive file simply does not exist, which sent us chasing phantom filename issues. Now the handler checks file existence first and reports "压缩包不存在:<path>" with a recommendation pointing out that File_CompressFile appends .zip to the full source name (a.txt -> a.txt.zip) and that the compress node's output variable should be used to chain the real artifact path.
269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
import { promises as fs } from "node:fs";
|
||
import path from "node:path";
|
||
import AdmZip from "adm-zip";
|
||
import { register } from "../engine/registry";
|
||
import { RuntimeError } from "../engine/types";
|
||
|
||
function pick(args: Record<string, unknown>, keys: string[]): string {
|
||
for (const key of keys) {
|
||
const value = args[key];
|
||
if (value !== undefined && value !== null && value !== "") return String(value);
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function truthy(value: unknown): boolean {
|
||
return value === true || value === "true" || value === "1" || value === "True" || value === "是" || value === "开启";
|
||
}
|
||
|
||
async function readText(filePath: string, encode = "utf-8"): Promise<string> {
|
||
const buffer = await fs.readFile(filePath);
|
||
const encoding = (encode || "utf-8").toLowerCase().replace("_", "-");
|
||
if (encoding === "utf-8" || encoding === "utf8" || encoding === "auto") return buffer.toString("utf8");
|
||
try {
|
||
return new TextDecoder(encoding).decode(buffer);
|
||
} catch {
|
||
return buffer.toString("utf8");
|
||
}
|
||
}
|
||
|
||
async function writeText(filePath: string, content: string, append: boolean, encode = "utf-8"): Promise<number> {
|
||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||
if (append) await fs.appendFile(filePath, content, "utf8");
|
||
else await fs.writeFile(filePath, content, "utf8");
|
||
const stat = await fs.stat(filePath);
|
||
return stat.size;
|
||
}
|
||
|
||
function requirePath(value: string, label: string, nodeId: string, componentId: string): string {
|
||
if (!value.trim()) {
|
||
throw new RuntimeError("validation_error", `${label}为空`, {
|
||
nodeId,
|
||
componentId,
|
||
recommendation: "在属性面板选择文件或目录"
|
||
});
|
||
}
|
||
return path.resolve(value);
|
||
}
|
||
|
||
register("File_Read", async (_ctx, node, args) => {
|
||
const filePath = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId);
|
||
return readText(filePath, pick(args, ["encode"]) || "utf-8");
|
||
});
|
||
|
||
register("File_Write", async (_ctx, node, args) => {
|
||
const filePath = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId);
|
||
const content = String(args.content ?? args.value ?? "");
|
||
const append = truthy(args.append);
|
||
return writeText(filePath, content, append, pick(args, ["encode"]) || "utf-8");
|
||
});
|
||
|
||
register(["File_CreateFile"], async (_ctx, node, args) => {
|
||
// catalog 字段为 folder_path + name(组合成完整路径),兼容旧键 path
|
||
const base = pick(args, ["folder_path", "path", "file", "file_path"]);
|
||
const name = pick(args, ["name"]);
|
||
const filePath = requirePath(name ? path.join(base, name) : base, "文件路径", node.id, node.componentId);
|
||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||
await fs.writeFile(filePath, "", { flag: "a" });
|
||
return filePath;
|
||
});
|
||
|
||
register("File_Copy", async (_ctx, node, args) => {
|
||
const source = requirePath(pick(args, ["pathSrc", "src", "source"]), "源路径", node.id, node.componentId);
|
||
const target = requirePath(pick(args, ["pathDest", "dest", "target"]), "目标路径", node.id, node.componentId);
|
||
const overwrite = truthy(args.cover ?? args.overwrite);
|
||
const stat = await fs.stat(source);
|
||
if (stat.isDirectory()) {
|
||
await fs.cp(source, target, { recursive: true, force: overwrite });
|
||
} else {
|
||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||
await fs.copyFile(source, target);
|
||
}
|
||
return target;
|
||
});
|
||
|
||
register(["File_File_Move", "File_Move"], async (_ctx, node, args) => {
|
||
// File_Move 的 catalog 字段为 file_type + file_path/folder_path + target_path(+name),与 File_File_Move 的 pathSrc/pathDest 兼容
|
||
const isFolder = String(args.file_type ?? "file") === "folder";
|
||
const source = requirePath(pick(args, ["pathSrc", isFolder ? "folder_path" : "file_path", "src", "source"]), "源路径", node.id, node.componentId);
|
||
let target = pick(args, ["pathDest", "dest"]);
|
||
if (!target) {
|
||
const destDir = pick(args, ["target_path"]);
|
||
const newName = truthy(args.rename) ? pick(args, ["name"]) : "";
|
||
target = requirePath(newName ? path.join(destDir, newName) : destDir, "目的地路径", node.id, node.componentId);
|
||
}
|
||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||
try {
|
||
await fs.rename(source, target);
|
||
} catch {
|
||
await fs.cp(source, target, { recursive: true, force: true });
|
||
await fs.rm(source, { recursive: true, force: true });
|
||
}
|
||
return target;
|
||
});
|
||
|
||
register(["File_DeleteFile", "File_Delete"], async (_ctx, node, args) => {
|
||
const type = String(args.file_type ?? "file");
|
||
const target = type === "folder"
|
||
? pick(args, ["folder_path", "path"])
|
||
: pick(args, ["file_path", "path"]);
|
||
const resolved = requirePath(target, "待删除路径", node.id, node.componentId);
|
||
await fs.rm(resolved, { recursive: true, force: true });
|
||
return resolved;
|
||
});
|
||
|
||
register(["File_FileExists", "File_PathExists"], async (_ctx, node, args) => {
|
||
const target = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId);
|
||
try {
|
||
await fs.access(target);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
register("File_GetFileList", async (_ctx, node, args) => {
|
||
const folder = requirePath(pick(args, ["folder_path", "folder", "path"]), "文件夹", node.id, node.componentId);
|
||
const keyword = pick(args, ["key_word"]);
|
||
const ignore = pick(args, ["ignore"]);
|
||
const recursive = truthy(args.recursive);
|
||
const fullPath = truthy(args.is_full_path ?? "1");
|
||
const results: string[] = [];
|
||
async function walk(dir: string): Promise<void> {
|
||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||
for (const entry of entries) {
|
||
const absolute = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
if (recursive) await walk(absolute);
|
||
continue;
|
||
}
|
||
if (keyword && !entry.name.includes(keyword)) continue;
|
||
if (ignore && entry.name.includes(ignore)) continue;
|
||
results.push(fullPath ? absolute : entry.name);
|
||
}
|
||
}
|
||
await walk(folder);
|
||
results.sort((a, b) => a.localeCompare(b, "zh-CN"));
|
||
return results;
|
||
});
|
||
|
||
register(["File_MakeDir", "File_MakeFolder", "File_CreateFolder"], async (_ctx, node, args) => {
|
||
const base = pick(args, ["path", "folder_path", "folder"]);
|
||
const name = pick(args, ["name"]);
|
||
const target = requirePath(name ? path.join(base, name) : base, "文件夹路径", node.id, node.componentId);
|
||
await fs.mkdir(target, { recursive: true });
|
||
return target;
|
||
});
|
||
|
||
register(["File_PathSize", "File_PathSizeNew"], async (_ctx, node, args) => {
|
||
const target = requirePath(pick(args, ["path", "file"]), "路径", node.id, node.componentId);
|
||
const stat = await fs.stat(target);
|
||
return stat.size;
|
||
});
|
||
|
||
register("File_GetFileInfo", async (_ctx, node, args) => {
|
||
// catalog 字段为 file_path,兼容 path/file
|
||
const target = requirePath(pick(args, ["file_path", "path", "file"]), "路径", node.id, node.componentId);
|
||
const stat = await fs.stat(target);
|
||
return {
|
||
path: target,
|
||
name: path.basename(target),
|
||
size: stat.size,
|
||
isDirectory: stat.isDirectory(),
|
||
createdAt: stat.birthtime.toISOString(),
|
||
modifiedAt: stat.mtime.toISOString()
|
||
};
|
||
});
|
||
|
||
register("File_WaitFile", async (ctx, node, args) => {
|
||
// catalog 字段为 file_path,兼容 path/file
|
||
const target = requirePath(pick(args, ["file_path", "path", "file"]), "文件路径", node.id, node.componentId);
|
||
const timeout = Math.max(1, Number(args.time_out ?? args.timeout ?? 30)) * 1000;
|
||
const started = Date.now();
|
||
while (Date.now() - started < timeout) {
|
||
if (ctx.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
|
||
try {
|
||
await fs.access(target);
|
||
return true;
|
||
} catch {
|
||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||
}
|
||
}
|
||
throw new RuntimeError("timeout", `等待文件超时:${target}`, {
|
||
nodeId: node.id,
|
||
componentId: node.componentId,
|
||
recommendation: "检查文件是否生成,或调大超时时间"
|
||
});
|
||
});
|
||
|
||
function directoryOutputPath(outputPath: string, sourcePath: string): string {
|
||
const output = path.resolve(outputPath);
|
||
if (/[\\/]$/.test(outputPath) || path.extname(output).toLowerCase() !== ".zip") {
|
||
return path.join(output, path.basename(sourcePath) + ".zip");
|
||
}
|
||
return output;
|
||
}
|
||
|
||
async function uniqueZipPath(targetPath: string): Promise<string> {
|
||
try {
|
||
await fs.access(targetPath);
|
||
} catch {
|
||
return targetPath;
|
||
}
|
||
const dir = path.dirname(targetPath);
|
||
const ext = path.extname(targetPath);
|
||
const base = path.basename(targetPath, ext);
|
||
for (let index = 1; ; index++) {
|
||
const candidate = path.join(dir, `${base} (${index})${ext}`);
|
||
try {
|
||
await fs.access(candidate);
|
||
} catch {
|
||
return candidate;
|
||
}
|
||
}
|
||
}
|
||
|
||
register("File_CompressFile", async (_ctx, node, args) => {
|
||
// catalog 字段为 file_path(文件模式)/folder_path(文件夹模式)+ out_path,兼容旧键
|
||
const contentType = String(args.file_type ?? "openFile");
|
||
const sourceKeys = contentType === "openDirectory"
|
||
? ["folder_path", "pathSrc", "path"]
|
||
: ["file_path", "pathSrc", "path"];
|
||
const source = requirePath(pick(args, sourceKeys), contentType === "openDirectory" ? "待压缩文件夹路径" : "待压缩文件路径", node.id, node.componentId);
|
||
const saveToSourceDir = String(args.direct_type ?? "1") !== "0";
|
||
const requestedTarget = saveToSourceDir
|
||
? path.join(path.dirname(source), path.basename(source) + ".zip")
|
||
: directoryOutputPath(requirePath(pick(args, ["pathDest", "zip_path", "out_path"]), "压缩包输出位置", node.id, node.componentId), source);
|
||
const target = await uniqueZipPath(requestedTarget);
|
||
const zip = new AdmZip();
|
||
const stat = await fs.stat(source);
|
||
if (stat.isDirectory()) zip.addLocalFolder(source);
|
||
else zip.addLocalFile(source);
|
||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||
zip.writeZip(target);
|
||
return target;
|
||
});
|
||
|
||
register("File_DecompressFile", async (_ctx, node, args) => {
|
||
const source = requirePath(pick(args, ["folder_path", "path", "zip_path"]), "压缩包路径", node.id, node.componentId);
|
||
// adm-zip 对不存在的文件抛 "ADM-ZIP: Invalid filename",极具误导性;先显式检查文件存在性
|
||
await fs.access(source).catch(() => {
|
||
throw new RuntimeError("file_not_found", `压缩包不存在:${source}`, { nodeId: node.id, componentId: node.componentId, recommendation: "确认压缩包实际产物路径(压缩组件默认在原文件名后追加 .zip,如 a.txt 会压成 a.txt.zip),或用压缩节点的输出变量传入" });
|
||
});
|
||
const saveToSourceDir = String(args.direct_type ?? "1") !== "0";
|
||
const target = saveToSourceDir
|
||
? path.dirname(source)
|
||
: requirePath(pick(args, ["out_path", "pathDest", "target"]), "解压目标目录", node.id, node.componentId);
|
||
await fs.mkdir(target, { recursive: true });
|
||
new AdmZip(source).extractAllTo(target, true);
|
||
return target;
|
||
});
|
||
|
||
register("File_Rename", async (_ctx, node, args) => {
|
||
const source = requirePath(pick(args, ["pathSrc", "path"]), "源路径", node.id, node.componentId);
|
||
const newName = pick(args, ["reName", "name"]);
|
||
const target = path.join(path.dirname(source), newName);
|
||
await fs.rename(source, target);
|
||
return target;
|
||
});
|