277 lines
14 KiB
TypeScript
277 lines
14 KiB
TypeScript
import ExcelJS from "exceljs";
|
||
import { promises as fs } from "node:fs";
|
||
import path from "node:path";
|
||
import { PDFDocument } from "pdf-lib";
|
||
import { register } from "../engine/registry";
|
||
import { RuntimeError, type CapabilityHandler } from "../engine/types";
|
||
import { acquireWorkbook } from "./excel-store";
|
||
|
||
type WorkbookSource = { workbook: ExcelJS.Workbook; filePath: string };
|
||
|
||
function value(args: Record<string, unknown>, keys: string[], fallback: unknown = ""): unknown {
|
||
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return args[key];
|
||
return fallback;
|
||
}
|
||
|
||
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
|
||
return String(value(args, keys, fallback));
|
||
}
|
||
|
||
function truthy(candidate: unknown): boolean {
|
||
return [true, 1, "1", "true", "True", "yes", "on"].includes(candidate as never);
|
||
}
|
||
|
||
function requiredPath(candidate: unknown, label: string, nodeId: string, componentId: string): string {
|
||
const raw = typeof candidate === "object" && candidate !== null ? (candidate as { path?: unknown }).path : candidate;
|
||
const filePath = String(raw ?? "").trim();
|
||
if (!filePath) throw new RuntimeError("validation_error", `${label} is empty`, { nodeId, componentId, recommendation: `Configure ${label}` });
|
||
return path.resolve(filePath);
|
||
}
|
||
|
||
async function loadWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<WorkbookSource> {
|
||
const filePath = requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), "Excel path", nodeId, componentId);
|
||
const workbook = await acquireWorkbook(filePath);
|
||
return { workbook, filePath };
|
||
}
|
||
|
||
function worksheetOf(workbook: ExcelJS.Workbook, args: Record<string, unknown>): ExcelJS.Worksheet {
|
||
const name = text(args, ["sheet", "sheetName"]);
|
||
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
|
||
if (!sheet) throw new Error(`Sheet not found: ${name || "first sheet"}`);
|
||
return sheet;
|
||
}
|
||
|
||
async function saveWhenRequested(source: WorkbookSource, args: Record<string, unknown>): Promise<void> {
|
||
if (truthy(value(args, ["save", "isSave"]))) await source.workbook.xlsx.writeFile(source.filePath);
|
||
}
|
||
|
||
function columnNumber(candidate: unknown): number {
|
||
const raw = String(candidate ?? "A").trim().toUpperCase();
|
||
if (/^\d+$/.test(raw)) return Math.max(1, Number(raw));
|
||
if (!/^[A-Z]+$/.test(raw)) throw new Error(`Invalid column: ${raw}`);
|
||
let result = 0;
|
||
for (const character of raw) result = result * 26 + character.charCodeAt(0) - 64;
|
||
return result;
|
||
}
|
||
|
||
function columnLetter(index: number): string {
|
||
if (!Number.isInteger(index) || index < 1) throw new Error(`Invalid column number: ${index}`);
|
||
let current = index;
|
||
let result = "";
|
||
while (current > 0) {
|
||
current -= 1;
|
||
result = String.fromCharCode(65 + current % 26) + result;
|
||
current = Math.floor(current / 26);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function cellValue(candidate: ExcelJS.CellValue): unknown {
|
||
if (candidate && typeof candidate === "object" && "result" in candidate) return candidate.result ?? "";
|
||
if (candidate && typeof candidate === "object" && "text" in candidate) return candidate.text;
|
||
return candidate ?? "";
|
||
}
|
||
|
||
function parseList(candidate: unknown, converter: (item: string) => number): number[] {
|
||
if (Array.isArray(candidate)) return candidate.flatMap((item) => parseList(item, converter));
|
||
const raw = String(candidate ?? "").trim();
|
||
if (!raw) return [];
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (Array.isArray(parsed)) return parsed.flatMap((item) => parseList(item, converter));
|
||
} catch { /* parse as a delimited value */ }
|
||
return raw.split(/[,;\s]+/).filter(Boolean).flatMap((part) => {
|
||
const match = part.match(/^([A-Za-z]+|\d+)[-:]([A-Za-z]+|\d+)$/);
|
||
if (!match) return [converter(part)];
|
||
const start = converter(match[1]);
|
||
const end = converter(match[2]);
|
||
return Array.from({ length: Math.abs(end - start) + 1 }, (_, index) => Math.min(start, end) + index);
|
||
});
|
||
}
|
||
|
||
const readRowCount: CapabilityHandler = async (_ctx, node, args) => {
|
||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||
const sheet = worksheetOf(source.workbook, args);
|
||
const byColumn = text(args, ["range"]).includes("列");
|
||
const column = columnNumber(value(args, ["column"], "A"));
|
||
let count = 0;
|
||
sheet.eachRow({ includeEmpty: false }, (row) => {
|
||
if (truthy(args.ignoreHidden) && row.hidden) return;
|
||
if (!byColumn || cellValue(row.getCell(column).value) !== "") count += 1;
|
||
});
|
||
return count;
|
||
};
|
||
register("Excel_Cs_ReadRowCount", readRowCount);
|
||
|
||
register("Excel_Cs_ReadCellFormula", async (_ctx, node, args) => {
|
||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||
const sheet = worksheetOf(source.workbook, args);
|
||
const address = `${columnLetter(columnNumber(value(args, ["column"], "A")))}${Math.max(1, Number(value(args, ["row"], 1)))}`;
|
||
const candidate = sheet.getCell(address).value;
|
||
return candidate && typeof candidate === "object" && "formula" in candidate ? candidate.formula : "";
|
||
});
|
||
|
||
const getExcelPath: CapabilityHandler = (_ctx, node, args) =>
|
||
requiredPath(value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]), "Excel path", node.id, node.componentId);
|
||
register(["Excel_Cs_GetPath", "Excel_GetPath"], getExcelPath);
|
||
|
||
register("Excel_Cs_ConvertColumn", (_ctx, _node, args) => {
|
||
const input = value(args, ["convertInput", "column"], 1);
|
||
return text(args, ["convertType"], "0") === "0" ? columnLetter(Number(input)) : columnNumber(input);
|
||
});
|
||
|
||
register("Excel_Cs_GetEmptyCell", async (_ctx, node, args) => {
|
||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||
const sheet = worksheetOf(source.workbook, args);
|
||
const column = columnNumber(value(args, ["column"], "A"));
|
||
let row = Math.max(1, Number(value(args, ["row"], 1)));
|
||
while (cellValue(sheet.getCell(row, column).value) !== "") row += 1;
|
||
return `${columnLetter(column)}${row}`;
|
||
});
|
||
|
||
const readColumn: CapabilityHandler = async (_ctx, node, args) => {
|
||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||
const sheet = worksheetOf(source.workbook, args);
|
||
const column = columnNumber(value(args, ["columnName", "column", "cellPos"], "A"));
|
||
const startRow = Math.max(1, Number(value(args, ["row", "startRow"], 1)));
|
||
const values = Array.from({ length: Math.max(0, sheet.rowCount - startRow + 1) }, (_, index) => cellValue(sheet.getCell(startRow + index, column).value));
|
||
// 触碰过尾部空行(如定位空单元格)会撑大 rowCount,裁掉尾部空值避免误报
|
||
while (values.length > 0 && (values[values.length - 1] === "" || values[values.length - 1] === null || values[values.length - 1] === undefined)) values.pop();
|
||
return values;
|
||
};
|
||
register(["Excel_Cs_ReadColumn", "Excel_GetColDataNew"], readColumn);register("Excel_Cs_DeleteRow", async (_ctx, node, args) => {
|
||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||
const sheet = worksheetOf(source.workbook, args);
|
||
const mode = text(args, ["mode"]);
|
||
const input = mode.includes("多") ? args.rows : mode.includes("范围") ? `${value(args, ["startRow"], 1)}-${value(args, ["lastRow"], 1)}` : value(args, ["row"], 1);
|
||
const rows = parseList(input, Number);
|
||
for (const row of [...new Set(rows)].sort((a, b) => b - a)) sheet.spliceRows(row, 1);
|
||
await saveWhenRequested(source, args);
|
||
return rows.length;
|
||
});
|
||
|
||
register("Excel_Cs_DeleteColumn", async (_ctx, node, args) => {
|
||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||
const sheet = worksheetOf(source.workbook, args);
|
||
const mode = text(args, ["mode"]);
|
||
const input = mode.includes("多") ? args.columns : mode.includes("范围") ? `${value(args, ["startColumn"], "A")}-${value(args, ["lastColumn"], "A")}` : value(args, ["column"], "A");
|
||
const columns = parseList(input, columnNumber);
|
||
for (const column of [...new Set(columns)].sort((a, b) => b - a)) sheet.spliceColumns(column, 1);
|
||
await saveWhenRequested(source, args);
|
||
return columns.length;
|
||
});
|
||
|
||
register("Excel_Cs_ClearRangeV4", async (_ctx, node, args) => {
|
||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||
const sheet = worksheetOf(source.workbook, args);
|
||
// 兼容各别名版本的键:V4=excelRng、V2/V3=range、Legacy Excel_ClearRange=cellPos、V1=startRow/startColumn/lastRow/lastColumn
|
||
let range = text(args, ["excelRng", "range", "cellPos"]);
|
||
if (!range) {
|
||
const startRow = text(args, ["startRow"]);
|
||
const startColumn = text(args, ["startColumn"]);
|
||
if (startRow && startColumn) {
|
||
const lastRow = text(args, ["lastRow"], startRow);
|
||
const lastColumn = text(args, ["lastColumn"], startColumn);
|
||
range = `${startColumn}${startRow}:${lastColumn}${lastRow}`;
|
||
}
|
||
}
|
||
if (!range) throw new RuntimeError("validation_error", "Range is empty", { nodeId: node.id, componentId: node.componentId });
|
||
const [start, end = start] = range.split(":");
|
||
const first = sheet.getCell(start);
|
||
const last = sheet.getCell(end);
|
||
// 清除模式:全部=内容+格式,格式=仅格式,其余(内容/默认)=仅内容;Legacy isFormat=True 同样清格式
|
||
const mode = text(args, ["clearType", "mode"], "内容");
|
||
const clearFormat = mode.includes("全部") || mode.includes("格式") || truthy(value(args, ["isFormat"]));
|
||
const clearValue = !mode.includes("格式");
|
||
for (let row = first.row; row <= last.row; row += 1) for (let column = first.col; column <= last.col; column += 1) {
|
||
const cell = sheet.getCell(row, column);
|
||
if (clearValue) cell.value = null;
|
||
if (clearFormat) cell.style = {};
|
||
}
|
||
await saveWhenRequested(source, args);
|
||
return range;
|
||
});
|
||
|
||
register("Excel_Cs_MergeCells", async (_ctx, node, args) => {
|
||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||
const sheet = worksheetOf(source.workbook, args);
|
||
const range = text(args, ["range", "excelRng"]);
|
||
if (!range) throw new RuntimeError("validation_error", "Range is empty", { nodeId: node.id, componentId: node.componentId });
|
||
if (text(args, ["merge"], "0") === "0") sheet.mergeCells(range); else sheet.unMergeCells(range);
|
||
await saveWhenRequested(source, args);
|
||
return range;
|
||
});
|
||
|
||
register("Excel_Cs_SetHeight", async (_ctx, node, args) => {
|
||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||
const sheet = worksheetOf(source.workbook, args);
|
||
const rows = parseList(value(args, ["rows", "row"], 1), Number);
|
||
for (const row of rows) sheet.getRow(row).height = Number(value(args, ["height"], 14.5));
|
||
await saveWhenRequested(source, args);
|
||
return rows.length;
|
||
});
|
||
|
||
register("Excel_Cs_SetWidth", async (_ctx, node, args) => {
|
||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||
const sheet = worksheetOf(source.workbook, args);
|
||
const columns = parseList(value(args, ["columns", "column"], "A"), columnNumber);
|
||
for (const column of columns) sheet.getColumn(column).width = Number(value(args, ["width"], 8.38));
|
||
await saveWhenRequested(source, args);
|
||
return columns.length;
|
||
});
|
||
|
||
register("Excel_Cs_CopyV2", async (_ctx, node, args) => {
|
||
const source = await loadWorkbook(args, node.id, node.componentId);
|
||
const sheet = worksheetOf(source.workbook, args);
|
||
const name = text(args, ["newSheet", "name"], `${sheet.name}_copy`);
|
||
const existing = source.workbook.getWorksheet(name);
|
||
if (existing) {
|
||
if (!truthy(args.override)) throw new RuntimeError("validation_error", `Sheet already exists: ${name}`, { nodeId: node.id, componentId: node.componentId });
|
||
source.workbook.removeWorksheet(existing.id);
|
||
}
|
||
const copy = source.workbook.addWorksheet(name);
|
||
sheet.eachRow({ includeEmpty: true }, (row, rowNumber) => {
|
||
const target = copy.getRow(rowNumber);
|
||
target.values = row.values;
|
||
target.height = row.height;
|
||
row.eachCell({ includeEmpty: true }, (cell, column) => { target.getCell(column).style = { ...cell.style }; });
|
||
});
|
||
sheet.columns.forEach((column, index) => { copy.getColumn(index + 1).width = column.width; });
|
||
await saveWhenRequested(source, args);
|
||
return name;
|
||
});
|
||
|
||
function parsePdfPages(raw: string, pageCount: number): number[] {
|
||
// 页码留空时返回空集:由调用方抛出「No valid PDF pages」,避免静默复制全本
|
||
if (!raw.trim()) return [];
|
||
const pages = new Set<number>();
|
||
for (const value of parseList(raw, Number)) if (value >= 1 && value <= pageCount) pages.add(value - 1);
|
||
return [...pages];
|
||
}
|
||
|
||
register("PDF_CreateNewPdf", async (_ctx, node, args) => {
|
||
const sourcePath = requiredPath(value(args, ["fileInPath", "file", "path"]), "PDF path", node.id, node.componentId);
|
||
const destination = requiredPath(value(args, ["newPdfPath", "output", "outPath"]), "output path", node.id, node.componentId);
|
||
const outputPath = path.extname(destination).toLowerCase() === ".pdf" ? destination : path.join(destination, `${path.basename(sourcePath, path.extname(sourcePath))}-new.pdf`);
|
||
const source = await PDFDocument.load(await fs.readFile(sourcePath), { ignoreEncryption: true });
|
||
const indices = text(args, ["pdfRange"], "all") === "all" ? source.getPageIndices() : parsePdfPages(text(args, ["pdfNum"]), source.getPageCount());
|
||
if (!indices.length) throw new RuntimeError("validation_error", "No valid PDF pages", { nodeId: node.id, componentId: node.componentId });
|
||
const target = await PDFDocument.create();
|
||
for (const page of await target.copyPages(source, indices)) target.addPage(page);
|
||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||
await fs.writeFile(outputPath, await target.save());
|
||
return outputPath;
|
||
});
|
||
|
||
register("PDF_GetPdfInformation", async (_ctx, node, args) => {
|
||
const filePath = requiredPath(value(args, ["fileInPath", "file", "path"]), "PDF path", node.id, node.componentId);
|
||
const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true, updateMetadata: false });
|
||
const stat = await fs.stat(filePath);
|
||
return {
|
||
path: filePath, fileName: path.basename(filePath), pageCount: document.getPageCount(), size: stat.size,
|
||
title: document.getTitle() ?? "", author: document.getAuthor() ?? "", subject: document.getSubject() ?? "",
|
||
creator: document.getCreator() ?? "", producer: document.getProducer() ?? "",
|
||
creationDate: document.getCreationDate()?.toISOString() ?? "", modificationDate: document.getModificationDate()?.toISOString() ?? ""
|
||
};
|
||
});
|