From adf5f3f7cf3baec1b1119ade80524b0df50cd91b Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 15:15:57 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20CV=20=E8=A7=86=E8=A7=89=E5=9B=9B?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=EF=BC=88=E6=88=AA=E5=9B=BEOCR=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D=E7=82=B9=E5=87=BB/=E6=82=AC=E5=81=9C/=E8=AF=BB?= =?UTF-8?q?=E6=96=87=E6=9C=AC/=E8=BE=93=E5=85=A5=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20path=20import=20=E4=B8=8E=20getOcr=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/capabilities/cv-vision.ts | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/main/capabilities/cv-vision.ts diff --git a/src/main/capabilities/cv-vision.ts b/src/main/capabilities/cv-vision.ts new file mode 100644 index 0000000..bfdab8e --- /dev/null +++ b/src/main/capabilities/cv-vision.ts @@ -0,0 +1,60 @@ +import { register } from "../engine/registry"; +import { RuntimeError } from "../engine/types"; +import { powershell } from "./desktop"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +interface CvTarget { text: string; cx: number; cy: number } +let paddleOcr: { detect: (image: string) => Promise<{ texts: Array<{ text: string; box?: number[][]; mean?: number }> }> } | null = null; + +async function getOcr() { + if (paddleOcr) return paddleOcr; + const mod = await import("@gutenye/ocr-node"); + paddleOcr = await mod.default.create(); + return paddleOcr as { detect: (image: string) => Promise<{ texts: Array<{ text: string; box?: number[][]; mean?: number }> }> }; +} + +function cvTarget(args: Record, nodeId: string): string { + for (const key of ["cvSelector", "target", "text"]) { + const v = String(args[key] ?? "").trim(); + if (v) return v.replace(/^text:/, "").replace(/^图片:/, ""); + } + throw new RuntimeError("validation_error", "CV 目标为空", { nodeId, componentId: nodeId, recommendation: "填写要在屏幕上查找的目标文本" }); +} + +async function grabScreen(imagePath: string): Promise { + fs.mkdirSync(path.dirname(imagePath), { recursive: true }); + const psScript = [ + "Add-Type -AssemblyName System.Drawing | Out-Null;", + "$b = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds;", + "$bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height);", + "$g = [System.Drawing.Graphics]::FromImage($bmp);", + "$g.CopyFromScreen($b.X, $b.Y, 0, 0, $b.Size);", + "$g.Dispose();", + "$bmp.Save(" + JSON.stringify(imagePath) + ");", + "$bmp.Dispose();" + ].join(" "); + await powershell(psScript, 30000); +} + +interface OcrLine { text: string; box?: number[][]; mean?: number } + +async function locateTextOnScreen(target: string, nodeId: string): Promise<{ target: string; text: string; cx: number; cy: number }> { + const shotPath = path.join(os.tmpdir(), "rpa-cv-screen-" + Date.now() + ".png"); + await grabScreen(shotPath); + const ocr = await getOcr(); + const result = await ocr.detect(shotPath); + const texts = result.texts ?? []; + const keyword = target.trim(); + const hit = texts.find(t => t.text && t.text.includes(keyword)); + try { fs.unlinkSync(shotPath); } catch {} + if (!hit || !hit.box || hit.box.length < 4) { + throw new RuntimeError("cv_not_found", "屏幕上未找到目标文本:" + keyword, { nodeId, componentId: nodeId, recommendation: "确认目标文本在屏幕上可见且清晰" }); + } + const xs = hit.box.map(p => p[0]); + const ys = hit.box.map(p => p[1]); + const cx = (Math.min(...xs) + Math.max(...xs)) / 2; + const cy = (Math.min(...ys) + Math.max(...ys)) / 2; + return { target: keyword, text: hit.text, cx, cy }; +} \ No newline at end of file From 32269a5760de48e3c0e445510b2f4218dafa2fa9 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 16:02:21 +0800 Subject: [PATCH 2/5] feat: desktop ground variants and desktop children --- src/main/capabilities/desktop-uia.test.ts | 15 +- src/main/capabilities/desktop-uia.ts | 241 ++++++++++++++++++++++ 2 files changed, 255 insertions(+), 1 deletion(-) diff --git a/src/main/capabilities/desktop-uia.test.ts b/src/main/capabilities/desktop-uia.test.ts index bc0031b..8812f28 100644 --- a/src/main/capabilities/desktop-uia.test.ts +++ b/src/main/capabilities/desktop-uia.test.ts @@ -8,7 +8,20 @@ describe("desktop UIA ground capabilities", () => { "Element_SetValueGround", "Element_ExistsGround", "Element_GetValueGround", - "Element_WaitElementGround" + "Element_WaitElementGround", + "Element_GetCheckGround", + "Element_SetCheckGround", + "Element_RegionGround", + "Element_GetSelectGround", + "Element_SetSelectGround", + "Element_GetAttributeGround", + "Element_SetAttributeGround", + "Element_GetButtonEnableGround", + "Element_ShotGround", + "Element_MouseToGround", + "Element_ScrollIntoViewGround", + "Element_ChildrenGround", + "Element_DesktopChildren" ])("registers %s", (id) => { expect(getHandler(id), id).toBeTruthy(); }); diff --git a/src/main/capabilities/desktop-uia.ts b/src/main/capabilities/desktop-uia.ts index 7626b41..806b67e 100644 --- a/src/main/capabilities/desktop-uia.ts +++ b/src/main/capabilities/desktop-uia.ts @@ -1,4 +1,6 @@ import { register } from "../engine/registry"; +import fs from "node:fs"; +import path from "node:path"; import { RuntimeError, type CapabilityHandler } from "../engine/types"; import { powershell, psString } from "./desktop"; @@ -393,3 +395,242 @@ register("Element_WaitElementGround", waitElementGround); + +function outputPath(args: Record, extension = "png"): string { + const raw = args.filePath ?? args.folder ?? args.path; + const folder = path.resolve(String(raw && String(raw).trim() ? raw : process.cwd())); + const rawName = String(args.filename ?? args.fileName ?? args.name ?? `element-${Date.now()}.${extension}`) + .replace(/\$\{randomString\}/g, Math.random().toString(36).slice(2, 10)); + const name = /\.[A-Za-z0-9]{2,8}$/.test(rawName) ? rawName : `${rawName}.${extension}`; + return path.join(folder, name); +} + +function propertyValue(scriptLines: string[], t: UiaTarget): string { + const s = t.selector; + return `${UIA_USING} +${FIND_HELPER} +$el = $null +try { + $parent = Find-RpaWindow ${psString(s.window ?? "")} + $el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index} +} catch { $el = $null } +if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 } +${scriptLines.join("\n")}`; +} + +const getCheckGround: CapabilityHandler = async (_ctx, node, args) => { + const t = target(args, node); + const result = await runWithImplicitWait(t, "getCheck", propertyValue([ + "$checked = $false", + "try { $checked = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern).Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On } catch {}", + "Write-Output (@{ value = [bool]$checked; name = $el.Current.Name; enabled = [bool]$el.Current.IsEnabled } | ConvertTo-Json -Compress)" + ], t), runScript, args); + return result.value === true; +}; + +const setCheckGround: CapabilityHandler = async (_ctx, node, args) => { + const t = target(args, node); + const desired = ["1", "true", "yes", "on"].includes(String(args.isChecked ?? "1").toLowerCase()); + return runWithImplicitWait(t, "setCheck", propertyValue([ + `$desired = ${desired ? "$true" : "$false"}`, + "$current = $false; $hasToggle = $false", + "try { $pattern = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern); $current = $pattern.Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On; $hasToggle = $true } catch {}", + "$method = if ($hasToggle) { 'toggle_pattern' } else { 'coordinate_click' }", + "if (-not $hasToggle -or $current -ne $desired) {", + " if ($hasToggle) { $pattern.Toggle() | Out-Null }", + " else {", + " $rect = $el.Current.BoundingRectangle; Add-RpaUser32", + " [Win32Rpa.RpaUser32Click]::SetCursorPos([int]($rect.X + $rect.Width / 2), [int]($rect.Y + $rect.Height / 2)) | Out-Null", + " Start-Sleep -Milliseconds 100; [Win32Rpa.RpaUser32Click]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)", + " Start-Sleep -Milliseconds 50; [Win32Rpa.RpaUser32Click]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)", + " }", + "}", + "$after = $false; try { $after = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern).Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On } catch {}", + "Write-Output (@{ set = ($after -eq $desired); checked = [bool]$after; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)" + ], t), runScript, args); +}; + +const regionGround: CapabilityHandler = async (_ctx, node, args) => { + const t = target(args, node); + const relative = String(args.position ?? "3") === "2"; + const result = await runWithImplicitWait(t, "getRegion", propertyValue([ + "$rect = $el.Current.BoundingRectangle; $ox = 0; $oy = 0", + relative ? [ + "try {", + " Add-RpaUser32", + ' Add-Type -MemberDefinition \'[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }\' -Name RpaWindowRect -Namespace Win32RpaRect | Out-Null', + " $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker; $ancestor = $el", + " while ($null -ne $ancestor) {", + " $hwnd = $ancestor.Current.NativeWindowHandle", + " if ($hwnd -ne 0) { $wr = New-Object Win32RpaRect.RpaWindowRect+RECT; [void][Win32RpaRect.RpaWindowRect]::GetWindowRect([IntPtr]$hwnd, [ref]$wr); $ox = $wr.Left; $oy = $wr.Top; break }", + " $ancestor = $walker.GetParent($ancestor)", + " }", + "} catch {}", + ].join("\n") : "", + "Write-Output (@{ x = [int]($rect.X - $ox); y = [int]($rect.Y - $oy); width = [int]$rect.Width; height = [int]$rect.Height } | ConvertTo-Json -Compress)" + ], t), runScript, args); + return result; +}; + +function getSelectScript(t: UiaTarget): string { + const byText = String(t.args.choose ?? "1") !== "2"; + return propertyValue([ + "$result = @(); $method = 'selection_pattern'", + "try { $sel = $el.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern); foreach ($item in $sel.Current.GetSelection()) { $value = $item.Current.Name; if (-not " + (byText ? "$true" : "$false") + ") { $value = $item.Current.AutomationId }; $result += @{ text = $item.Current.Name; value = $item.Current.AutomationId; selected = $true } } } catch {}", + "if ($result.Count -eq 0) { try { $value = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Value } catch { $value = $el.Current.Name }; $result += @{ text = [string]$value; value = [string]$value; selected = $true }; $method = 'value_pattern' }", + "Write-Output (@{ result = $result; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress -Depth 3)" + ], t); +} + +const getSelectGround: CapabilityHandler = async (_ctx, node, args) => { + const t = target(args, node); + const result = await runWithImplicitWait(t, "getSelect", getSelectScript(t), runScript, args); + const items = Array.isArray(result.result) ? result.result : []; + return items.length === 1 ? items[0] : items; +}; + +function setSelectScript(t: UiaTarget, values: string[]): string { + const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64"); + const choose = String(t.args.choose ?? "1"); + return propertyValue([ + `$desired = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${encoded}')) | ConvertFrom-Json`, + "$items = @($el.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition) | Where-Object { $_.Current.ControlType.ProgrammaticName -match 'ListItem|ComboBoxItem' })", + "if ($items.Count -eq 0) { try { $el.Expand() } catch {}; Start-Sleep -Milliseconds 250; $items = @($el.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition) | Where-Object { $_.Current.ControlType.ProgrammaticName -match 'ListItem|ComboBoxItem' }) }", + "$targets = @(); $method = 'selection_item_pattern'", + "foreach ($wanted in @($desired)) {", + ` if ('${choose}' -eq '0') { $ix = [int]$wanted - 1; if ($ix -ge 0 -and $ix -lt $items.Count) { $targets += $items[$ix] } }`, + ` elseif ('${choose}' -eq '2') { $targets += @($items | Where-Object { $_.Current.AutomationId -eq [string]$wanted }) }`, + " else { $targets += @($items | Where-Object { $_.Current.Name -eq [string]$wanted }) }", + "}", + "$ok = $targets.Count -gt 0", + "foreach ($item in $targets) { try { $item.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() } catch { try { $item.Invoke() } catch { $ok = $false } } }", + "try { $el.Collapse() } catch {}", + "Write-Output (@{ set = [bool]$ok; matched = $targets.Count; available = $items.Count; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)" + ], t); +} + +const setSelectGround: CapabilityHandler = async (_ctx, node, args) => { + const t = target(args, node); + const raw = args.itemsToSelect ?? args.value ?? args.text; + let values: string[] = []; + if (Array.isArray(raw)) values = raw.map((value) => String(value)); + else if (raw !== undefined && raw !== null && String(raw).trim() !== "") { + try { const parsed = JSON.parse(String(raw)); values = Array.isArray(parsed) ? parsed.map((value) => String(value)) : [String(parsed)]; } + catch { values = [String(raw)]; } + } + if (!values.length) throw new RuntimeError("validation_error", "下拉框选择项为空", { nodeId: node.id, componentId: node.componentId }); + return runWithImplicitWait(t, "setSelect", setSelectScript(t, values), runScript, args); +}; + +function attrScript(t: UiaTarget, attrName: string, attrValue = ""): string { + const name = attrName.toLowerCase(); + if (name === "value" || name === "text") { + return propertyValue([ + "try { $value = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Value } catch { $value = $el.Current.Name }", + "Write-Output (@{ value = [string]$value; name = $el.Current.Name } | ConvertTo-Json -Compress)" + ], t); + } + if (!attrValue) { + return propertyValue([ + "$p = @{ name = $el.Current.Name; id = $el.Current.AutomationId; class = $el.Current.ClassName; type = $el.Current.ControlType.ProgrammaticName; enabled = $el.Current.IsEnabled; help = $el.Current.HelpText; value = $el.Current.Name }", + `$key = ${psString(name || "name")}`, + "$result = if ($p.Contains($key)) { $p[$key] } else { $p.name }", + "Write-Output (@{ value = [string]$result; properties = $p; name = $el.Current.Name } | ConvertTo-Json -Compress)" + ], t); + } + if (name !== "value" && name !== "text") throw new RuntimeError("validation_error", `桌面 UIA 不支持设置属性:${attrName};可设置 value/text`, { nodeId: t.node.id, componentId: t.node.componentId }); + return propertyValue([ + "$ok = $false; $method = 'value_pattern'", + `try { $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).SetValue(${psString(attrValue)}); $ok = $true } catch {}`, + "if (-not $ok) { $method = 'keyboard'; Add-Type -AssemblyName System.Windows.Forms; $el.SetFocus() | Out-Null; Start-Sleep -Milliseconds 100; [System.Windows.Forms.SendKeys]::SendWait('^a'); [System.Windows.Forms.SendKeys]::SendWait('^v'); Start-Sleep -Milliseconds 100; $ok = $true }", + "Write-Output (@{ set = $ok; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)" + ], t); +} + +const getAttributeGround: CapabilityHandler = async (_ctx, node, args) => { + const t = target(args, node); + const attrName = String(args.attrName ?? args.attribute ?? "name"); + const result = await runWithImplicitWait(t, "getAttribute", attrScript(t, attrName), runScript, args); + return result.value ?? ""; +}; + +const setAttributeGround: CapabilityHandler = async (_ctx, node, args) => { + const t = target(args, node); + const attrName = String(args.attrName ?? args.attribute ?? "value"); + const attrValue = String(args.attrValue ?? args.value ?? ""); + return runWithImplicitWait(t, "setAttribute", attrScript(t, attrName, attrValue), runScript, args); +}; + +const getButtonEnableGround: CapabilityHandler = async (_ctx, node, args) => { + const t = target(args, node); + const result = await runWithImplicitWait(t, "getButtonEnable", propertyValue(["Write-Output (@{ value = [bool]$el.Current.IsEnabled; name = $el.Current.Name } | ConvertTo-Json -Compress)"], t), runScript, args); + return result.value === true; +}; + +const shotGround: CapabilityHandler = async (_ctx, node, args) => { + const t = target(args, node); + const output = outputPath(args); + fs.mkdirSync(path.dirname(output), { recursive: true }); + const result = await runWithImplicitWait(t, "shot", propertyValue([ + "$rect = $el.Current.BoundingRectangle; if ($rect.Width -le 0 -or $rect.Height -le 0) { throw 'element_not_visible' }", + "Add-Type -AssemblyName System.Drawing | Out-Null", + "$bitmap = New-Object System.Drawing.Bitmap([int]$rect.Width, [int]$rect.Height)", + "$graphics = [System.Drawing.Graphics]::FromImage($bitmap)", + "$graphics.CopyFromScreen([int]$rect.X, [int]$rect.Y, 0, 0, $bitmap.Size); $graphics.Dispose()", + `$bitmap.Save(${psString(output)}, [System.Drawing.Imaging.ImageFormat]::Png); $bitmap.Dispose()`, + `Write-Output (@{ value = ${psString(output)}; x = [int]$rect.X; y = [int]$rect.Y; width = [int]$rect.Width; height = [int]$rect.Height } | ConvertTo-Json -Compress)` + ], t), runScript, args); + return result.value ?? output; +}; + +const mouseToGround: CapabilityHandler = async (_ctx, node, args) => { + const t = target(args, node); + return runWithImplicitWait(t, "mouseTo", propertyValue([ + "$rect = $el.Current.BoundingRectangle; Add-RpaUser32", + "$x = [int]($rect.X + $rect.Width / 2); $y = [int]($rect.Y + $rect.Height / 2)", + "[Win32Rpa.RpaUser32Click]::SetCursorPos($x, $y) | Out-Null", + "Write-Output (@{ x = $x; y = $y; name = $el.Current.Name } | ConvertTo-Json -Compress)" + ], t), runScript, args); +}; + +const scrollIntoViewGround: CapabilityHandler = async (_ctx, node, args) => { + const t = target(args, node); + return runWithImplicitWait(t, "scrollIntoView", propertyValue([ + "$ok = $false; $method = 'scroll_item_pattern'", + "try { $el.GetCurrentPattern([System.Windows.Automation.ScrollItemPattern]::Pattern).ScrollIntoView(); $ok = $true } catch {}", + "if (-not $ok) { $method = 'focus'; try { $el.SetFocus() | Out-Null; $ok = $true } catch {} }", + "Write-Output (@{ scrolled = $ok; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)" + ], t), runScript, args); +}; + +function childrenGroundHandler(single: boolean): CapabilityHandler { + return async (_ctx, node, args) => { + const t = target(args, node); + const num = Math.max(0, Math.floor(Number(args.num ?? 0) || 0)); + const result = await runWithImplicitWait(t, "getChildren", propertyValue([ + "$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker; $child = $walker.GetFirstChild($el); $children = @()", + "while ($null -ne $child) {", + " $r = $child.Current.BoundingRectangle", + " $children += @{ name = $child.Current.Name; id = $child.Current.AutomationId; type = $child.Current.ControlType.ProgrammaticName; className = $child.Current.ClassName; enabled = [bool]$child.Current.IsEnabled; x = [int]$r.X; y = [int]$r.Y; width = [int]$r.Width; height = [int]$r.Height }", + " $child = $walker.GetNextSibling($child)", + "}", + single ? `$n = ${num}; if ($n -ge 1 -and $n -le $children.Count) { $children = @($children[$n - 1]) }` : "", + "Write-Output (@{ result = $children; count = $children.Count } | ConvertTo-Json -Compress -Depth 4)" + ], t), runScript, args); + return result.result ?? []; + }; +} + +register("Element_GetCheckGround", getCheckGround); +register("Element_SetCheckGround", setCheckGround); +register("Element_RegionGround", regionGround); +register("Element_GetSelectGround", getSelectGround); +register("Element_SetSelectGround", setSelectGround); +register("Element_GetAttributeGround", getAttributeGround); +register("Element_SetAttributeGround", setAttributeGround); +register("Element_GetButtonEnableGround", getButtonEnableGround); +register("Element_ShotGround", shotGround); +register("Element_MouseToGround", mouseToGround); +register("Element_ScrollIntoViewGround", scrollIntoViewGround); +register("Element_ChildrenGround", childrenGroundHandler(false)); +register("Element_DesktopChildren", childrenGroundHandler(true)); From 23d292fdff145b9f337dbf302717683083320a63 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 16:25:44 +0800 Subject: [PATCH 3/5] feat: word selection cursor and table com --- src/main/capabilities/word-extended.test.ts | 25 +++- src/main/capabilities/word-extended.ts | 140 ++++++++++++++++++++ 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/src/main/capabilities/word-extended.test.ts b/src/main/capabilities/word-extended.test.ts index f3b8046..348fa9c 100644 --- a/src/main/capabilities/word-extended.test.ts +++ b/src/main/capabilities/word-extended.test.ts @@ -15,7 +15,7 @@ const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, async function run(id: string, args: Record = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); } afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))); -const ids = ["Word_New_V1", "Word_GetPath", "Word_ReadAll", "Word_Replace", "Word_Enter", "Word_InsertPage", "Word_SetFontName", "Word_SetFontSize", "Word_SetFontStyle", "Word_SetFontColor", "Word_SetAlign", "Word_InsertPicture", "Word_WriteTable", "Word_Close"]; +const ids = ["Word_New_V1", "Word_GetPath", "Word_ReadAll", "Word_Replace", "Word_Enter", "Word_InsertPage", "Word_SetFontName", "Word_SetFontSize", "Word_SetFontStyle", "Word_SetFontColor", "Word_SetAlign", "Word_InsertPicture", "Word_WriteTable", "Word_Close", "Word_ReadSelect", "Word_CutSelect", "Word_CopySelect", "Word_PasteSelect", "Word_DeleteSelect", "Word_SelectAll", "Word_SelectRow", "Word_SetPosition", "Word_MovePosition", "Word_PositionToSelect", "Word_ReadTable"]; describe("Word extended capabilities", () => { it("registers automated Word operations", () => { for (const id of ids) expect(getHandler(id), id).toBeTruthy(); }); @@ -33,4 +33,27 @@ describe("Word extended capabilities", () => { await run("Word_Enter", { objWord: handle }); expect((await readFile(path.join(directory, "extended.docx"))).byteLength).toBeGreaterThan(1000); }, 120000); + + it("uses a persistent selection and reads a Word table through COM", async () => { + if (process.platform !== "win32") return; + const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-selection-")); directories.push(directory); + let handle = await run("Word_New_V1", { filePath: directory, name: "selection.docx" }); + await run("Word_Write", { objWord: handle, text: "alpha beta gamma" }); + handle = await run("Word_PositionToSelect", { objWord: handle, text: "beta", pos_index: 1, direction: "select" }); + await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toBe("beta"); + handle = await run("Word_CopySelect", { objWord: handle }); + handle = await run("Word_MovePosition", { objWord: handle, length: 1, unit: "word", direction: "right", isSelect: false }); + await run("Word_PasteSelect", { objWord: handle }); + handle = await run("Word_PositionToSelect", { objWord: handle, text: "gamma", direction: "select" }); + handle = await run("Word_CutSelect", { objWord: handle }); + await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toBe(""); + await run("Word_DeleteSelect", { objWord: handle }); + handle = await run("Word_SetPosition", { objWord: handle, length: -2, unit: "word" }); + handle = await run("Word_SelectRow", { objWord: handle, st: 1, ed: 1 }); + await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toContain("alpha"); + handle = await run("Word_SelectAll", { objWord: handle }); + await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toContain("alpha beta beta"); + await run("Word_WriteTable", { objWord: handle, data: [["name", "age"], ["Alice", "30"]] }); + await expect(run("Word_ReadTable", { objWord: handle, find_type: "content", table_content: "Alice" })).resolves.toEqual([["name", "age"], ["Alice", "30"]]); + }, 180000); }); diff --git a/src/main/capabilities/word-extended.ts b/src/main/capabilities/word-extended.ts index 8fb454e..6c5f1c3 100644 --- a/src/main/capabilities/word-extended.ts +++ b/src/main/capabilities/word-extended.ts @@ -122,3 +122,143 @@ register("Word_WriteTable", async (_ctx, node, args) => { }); register("Word_Close", (_ctx, node, args) => ({ path: handlePath(args, node.id, node.componentId), closed: true })); + +interface WordSelection { start: number; end: number; } + +function selectionOf(args: Record): WordSelection { + const handle = args.objWord; + const raw = handle && typeof handle === "object" ? (handle as { selection?: unknown }).selection : null; + const record = raw && typeof raw === "object" ? raw as Record : {}; + let start = Math.max(0, Math.floor(Number(record.start ?? 0) || 0)); + let end = Math.max(0, Math.floor(Number(record.end ?? record.active ?? start) || 0)); + if (end < start) [start, end] = [end, start]; + return { start, end }; +} + +function selectionScript(filePath: string, selection: WordSelection, body = "", withText = true): string { + return `$word=$null;$doc=$null;try{` + + `$word=New-Object -ComObject Word.Application;$word.Visible=$false;$word.DisplayAlerts=0;$doc=$word.Documents.Open(${psString(filePath)});` + + `$sel=$doc.Range(${selection.start},${selection.end});$sel.Select()|Out-Null;${body};if($null -eq $sel){$sel=$doc.Content}` + + `$text="";if(${withText ? "$true" : "$false"}){$text=[string]$sel.Text};$out=@{start=[int]$sel.Start;end=[int]$sel.End;text=$text}|ConvertTo-Json -Compress;` + + `[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($out))|Write-Output;$doc.Save()` + + `}finally{if($doc -ne $null){try{$doc.Close()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc)}catch{}};if($word -ne $null){try{$word.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($word)}catch{}};[GC]::Collect();[GC]::WaitForPendingFinalizers()}`; +} + +async function runSelection(filePath: string, selection: WordSelection, body = "", withText = true): Promise { + const output = await powershell(selectionScript(filePath, selection, body, withText)); + if (output.includes("__WORD_NOT_FOUND__")) throw new RuntimeError("word_text_not_found", "Word 查找文本不存在", { detail: `path=${filePath}` }); + const parsed = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8")) as { start: number; end: number; text: string }; + return { start: parsed.start, end: parsed.end, text: parsed.text }; +} + +function selectionResult(filePath: string, selection: WordSelection): { path: string; selection: WordSelection } { + return { path: filePath, selection }; +} + +const selectionText = async (_ctx: unknown, node: { id: string; componentId: string }, args: Record): Promise => + (await runSelection(handlePath(args, node.id, node.componentId), selectionOf(args))).text; + +const selectionMutation = (body: string): CapabilityHandlerLike => + async (_ctx, node, args) => selectionResult(handlePath(args, node.id, node.componentId), await runSelection(handlePath(args, node.id, node.componentId), selectionOf(args), body)); + +type CapabilityHandlerLike = Parameters[1]; + +register("Word_ReadSelect", selectionText); +register("Word_CopySelect", selectionMutation("$sel.Copy()|Out-Null")); +register("Word_CutSelect", selectionMutation("$sel.Cut()|Out-Null")); +register("Word_DeleteSelect", selectionMutation("[void]$sel.Delete()")); +register("Word_PasteSelect", selectionMutation("$sel.Paste()|Out-Null")); +register("Word_SelectAll", selectionMutation("$sel=$doc.Content;$sel.Select()|Out-Null")); + +register("Word_SelectRow", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + const startRow = Math.max(1, Math.floor(Number(args.st ?? 1) || 1)); + const endRow = Math.max(startRow, Math.floor(Number(args.ed ?? args.start ?? startRow) || startRow)); + const body = [ + `$first=$doc.GoTo(3,1,${startRow}).Bookmarks("\\Line").Range`, + `$last=$doc.GoTo(3,1,${endRow}).Bookmarks("\\Line").Range`, + "$sel=$doc.Range($first.Start,$last.End);$sel.Select()|Out-Null" + ].join(";"); + return selectionResult(filePath, await runSelection(filePath, { start: 0, end: 0 }, body)); +}); + +const wordUnit = (value: unknown): number => { + const unit = String(value ?? "word").toLowerCase(); + return unit === "row" || unit === "line" ? 5 : unit === "section" || unit === "paragraph" ? 4 : 2; +}; + +register("Word_SetPosition", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + const count = Math.floor(Number(args.length ?? 1) || 0); + const unit = wordUnit(args.unit); + const body = `$anchor=if(${selectionOf(args).end > selectionOf(args).start ? "$true" : "$false"}){$sel.End}else{$sel.Start};$sel=$doc.Range($anchor,$anchor);[void]$sel.Move(${unit},${count})`; + return selectionResult(filePath, await runSelection(filePath, selectionOf(args), body)); +}); + +register("Word_MovePosition", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + const count = Math.floor(Number(args.length ?? 1) || 0); + const unit = wordUnit(args.unit); + const left = String(args.direction ?? "right").toLowerCase().includes("left"); + const countValue = left ? -Math.abs(count) : Math.abs(count); + const extend = bool(args.isSelect ?? args.shift ?? false); + const selection = selectionOf(args); + const anchor = left ? selection.start : selection.end; + const body = extend + ? `$sel=$doc.Range(${anchor},${anchor});if(${left ? "$true" : "$false"}){[void]$sel.MoveStart(${unit},${countValue})}else{[void]$sel.MoveEnd(${unit},${countValue})}` + : `$sel=$doc.Range(${anchor},${anchor});[void]$sel.Move(${unit},${countValue})`; + return selectionResult(filePath, await runSelection(filePath, selection, body)); +}); + +register("Word_PositionToSelect", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + const needle = text(args, ["text", "find", "value"]); + if (!needle) throw new RuntimeError("validation_error", "查找文本为空", { nodeId: node.id, componentId: node.componentId }); + const encodedNeedle = Buffer.from(needle, "utf8").toString("base64"); + const requested = Math.floor(Number(args.pos_index ?? 1) || 1); + const mode = String(args.direction ?? "left").toLowerCase(); + const body = [ + `$needle=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedNeedle}'))`, + "$search=$doc.Content;$all=@();while($true){", + " $candidate=$search.Duplicate;$found=$candidate.Find.Execute($needle)", + " if(-not $found){break}", + " $all+=$candidate.Duplicate", + " if($candidate.End -ge $search.End){break}", + " $search.SetRange($candidate.End,$search.End)", + "}", + "if($all.Count -eq 0){Write-Output '__WORD_NOT_FOUND__';exit 0}", + `$ix=${requested};if($ix -le 0){$ix=$all.Count+$ix+1}`, + "if($ix -lt 1){$ix=1};if($ix -gt $all.Count){$ix=$all.Count};$match=$all[$ix-1]", + mode === "select" + ? "$sel=$match.Duplicate;$sel.Select()|Out-Null" + : `$p=$match.${mode.includes("right") ? "End" : "Start"};$sel=$doc.Range($p,$p);$sel.Select()|Out-Null` + ].join(";"); + return selectionResult(filePath, await runSelection(filePath, { start: 0, end: 0 }, body)); +}); + +register("Word_ReadTable", async (_ctx, node, args) => { + const filePath = handlePath(args, node.id, node.componentId); + const byContent = String(args.find_type ?? "index").toLowerCase().includes("content"); + const tableIndex = Math.max(1, Math.floor(Number(args.table_index ?? 1) || 1)); + const content = String(args.table_content ?? ""); + const encodedContent = Buffer.from(content, "utf8").toString("base64"); + const selector = byContent + ? `$needle=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedContent}'));$tableIndex=0;for($i=1;$i -le $doc.Tables.Count;$i++){if($doc.Tables.Item($i).Range.Text.Contains($needle)){$tableIndex=$i;break}}` + : `$tableIndex=${tableIndex}`; + const body = [ + selector, + "if($tableIndex -lt 1 -or $tableIndex -gt $doc.Tables.Count){Write-Output '__WORD_TABLE_NOT_FOUND__';exit 0}", + "$table=$doc.Tables.Item($tableIndex);$rows=@()", + "for($r=1;$r -le $table.Rows.Count;$r++){ $row=@(); for($c=1;$c -le $table.Columns.Count;$c++){ try { $text=$table.Cell($r,$c).Range.Text } catch { $text='' }; $clean=''; foreach($ch in $text.ToCharArray()){ $code=[int]$ch; if($code -ne 13 -and $code -ne 7){$clean += $ch} }; $row += $clean }; $rows += ,$row }", + "$json=$rows|ConvertTo-Json -Compress -Depth 5", + "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))|Write-Output" + ].join(";"); + const output = await powershell(`$word=$null;$doc=$null;try{$word=New-Object -ComObject Word.Application;$word.Visible=$false;$word.DisplayAlerts=0;$doc=$word.Documents.Open(${psString(filePath)});${body}}finally{if($doc -ne $null){try{$doc.Close()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc)}catch{}};if($word -ne $null){try{$word.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($word)}catch{}};[GC]::Collect();[GC]::WaitForPendingFinalizers()}`); + if (output.includes("__WORD_TABLE_NOT_FOUND__")) throw new RuntimeError("word_table_not_found", "Word 表格不存在", { detail: `path=${filePath};tableIndex=${tableIndex};content=${content}` }); + const parsed = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8")); + const rows = Array.isArray(parsed) ? parsed : [parsed]; + return rows.map((row) => { + const value = row && typeof row === "object" && Array.isArray((row as { value?: unknown }).value) ? (row as { value: unknown[] }).value : row; + return Array.isArray(value) ? value : [value]; + }); +}); From 84d73f1b8689e393b354248503ce5de1132c608d Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 17:26:09 +0800 Subject: [PATCH 4/5] feat: pdf encryption image extraction rendering and table reading --- electron-builder.config.mjs | 3 +- package-lock.json | 382 ++++++++++++++++++++++--- package.json | 2 + src/main/capabilities/pdf-high.test.ts | 54 ++++ src/main/capabilities/pdf.ts | 204 ++++++++++++- 5 files changed, 598 insertions(+), 47 deletions(-) create mode 100644 src/main/capabilities/pdf-high.test.ts diff --git a/electron-builder.config.mjs b/electron-builder.config.mjs index 45f9e2f..ffdaa52 100644 --- a/electron-builder.config.mjs +++ b/electron-builder.config.mjs @@ -28,7 +28,8 @@ export default { asarUnpack: [ "node_modules/onnxruntime-node/**", "node_modules/sharp/**", - "node_modules/@gutenye/**" + "node_modules/@gutenye/**", + "node_modules/@napi-rs/**" ], compression: "maximum", npmRebuild: false, diff --git a/package-lock.json b/package-lock.json index c39f455..fa46cc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,10 @@ "name": "rpa-studio", "version": "0.1.0", "dependencies": { + "@cantoo/pdf-lib": "^2.11.1", "@gutenye/ocr-models": "^1.4.2", "@gutenye/ocr-node": "^1.4.9", + "@napi-rs/canvas": "^1.1.0", "@xyflow/react": "^12.12.0", "adm-zip": "^0.5.16", "basic-ftp": "^6.2.3", @@ -360,6 +362,27 @@ "node": ">=6.9.0" } }, + "node_modules/@cantoo/pdf-lib": { + "version": "2.11.1", + "resolved": "https://registry.npmmirror.com/@cantoo/pdf-lib/-/pdf-lib-2.11.1.tgz", + "integrity": "sha512-lYuRJFfXgoP8WVE8HYSeoLfDcnSXmjMLmdZS7YvmYHngbcZfiqDOruHrZBqjYizkrNa4ad1yW4WCrPYyb26T3Q==", + "license": "MIT", + "dependencies": { + "culori": ">=4", + "fflate": "^0.8.2", + "node-html-better-parser": ">=1.5.9", + "tslib": ">=2" + }, + "peerDependencies": { + "html-entities": ">=2.3.2" + } + }, + "node_modules/@cantoo/pdf-lib/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@electron/asar": { "version": "3.4.1", "resolved": "https://registry.npmmirror.com/@electron/asar/-/asar-3.4.1.tgz", @@ -1745,11 +1768,10 @@ } }, "node_modules/@napi-rs/canvas": { - "version": "0.1.100", - "resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz", - "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-1.1.0.tgz", + "integrity": "sha512-k+ph/D8H9GS8ZZ48okvHrKeJynkqAA1kMUZRuzRWNlQUDHXngq7oRaWMZBdmSfd9ZhVy9bwNKei/7wMboI/uMg==", "license": "MIT", - "optional": true, "workspaces": [ "e2e/*" ], @@ -1761,23 +1783,23 @@ "url": "https://github.com/sponsors/Brooooooklyn" }, "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.100", - "@napi-rs/canvas-darwin-arm64": "0.1.100", - "@napi-rs/canvas-darwin-x64": "0.1.100", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", - "@napi-rs/canvas-linux-arm64-musl": "0.1.100", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", - "@napi-rs/canvas-linux-x64-gnu": "0.1.100", - "@napi-rs/canvas-linux-x64-musl": "0.1.100", - "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", - "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + "@napi-rs/canvas-android-arm64": "1.1.0", + "@napi-rs/canvas-darwin-arm64": "1.1.0", + "@napi-rs/canvas-darwin-x64": "1.1.0", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.1.0", + "@napi-rs/canvas-linux-arm64-gnu": "1.1.0", + "@napi-rs/canvas-linux-arm64-musl": "1.1.0", + "@napi-rs/canvas-linux-riscv64-gnu": "1.1.0", + "@napi-rs/canvas-linux-x64-gnu": "1.1.0", + "@napi-rs/canvas-linux-x64-musl": "1.1.0", + "@napi-rs/canvas-win32-arm64-msvc": "1.1.0", + "@napi-rs/canvas-win32-x64-msvc": "1.1.0" } }, "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.100", - "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", - "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.1.0.tgz", + "integrity": "sha512-mKrSmRyhWK4q699Q1xthbZIZi7JBvLtsRNnOZAeFaLPOFT1yo8y82aRC2nNZbXTGJXU7fVDONgGQPr0vo3kerw==", "cpu": [ "arm64" ], @@ -1795,9 +1817,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.100", - "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", - "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-iqwST4g0oH+x8VCpBIYh+MYokv/mVvkM+v9oJGB0d9A3P2qBh42ogJlVyWq9SpPCrAjS7tplMb1D3OIfxl+utw==", "cpu": [ "arm64" ], @@ -1815,9 +1837,9 @@ } }, "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.100", - "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", - "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.1.0.tgz", + "integrity": "sha512-jkeA8eVTxDum03LTvc32YJ28F6KB1U0gYwnWMfOAUQZvWWEh/kvONby4iWGJx3+Tw45UqMGMXjtw2GKNVZDhVg==", "cpu": [ "x64" ], @@ -1835,9 +1857,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.100", - "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", - "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.1.0.tgz", + "integrity": "sha512-Y4YkKG1ijoqkgUdTEEBNG4k+2BK5bR5jGL6S56YxCXYPwGfUd5nED8/74xyc7z+bpixPasCjSYoP3xYPOIGNxw==", "cpu": [ "arm" ], @@ -1855,9 +1877,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.100", - "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", - "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-dE/XthqYkiWcBVtKFlZbUXwAYfDuQGqDWixznipJNmevLGpprNnTVQZyftm1RZJudkIOn2EkzYZDMMETM08Row==", "cpu": [ "arm64" ], @@ -1875,9 +1897,9 @@ } }, "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.100", - "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", - "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-DqZ/CMcHlhU56pPjv9AP0h3U7MFQ5LzC1HzMyRMotL+PFsJmZJA/ziApPYVyyz78DaVLhI8t3So3ztqtijbYpg==", "cpu": [ "arm64" ], @@ -1915,9 +1937,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.100", - "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", - "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-uQ9JuJlRhQdHyttcMWOiwLkfIqkqaB0YFUVe835LUe12THt2b51HXkTItq0fxWT1jdhUPX2ylWZ1walkJyD6GQ==", "cpu": [ "x64" ], @@ -1935,9 +1957,9 @@ } }, "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.100", - "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", - "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-has4CPPAkkCac/LZ13VAw5p+MJLdaKVoyrhumN/A7uJd6960xYkJNDZgUebyxiha82AFzXx6RV+QJg5qGG079g==", "cpu": [ "x64" ], @@ -1955,9 +1977,9 @@ } }, "node_modules/@napi-rs/canvas-win32-arm64-msvc": { - "version": "0.1.100", - "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", - "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-MJnktLKNijmXqdjTughHDZ9TDLZ9uUm2hvFXqaOqn0+w7lphXFnft6bpSgQAc9U1exc20UGIBlzGu9+kiCj9Ag==", "cpu": [ "arm64" ], @@ -1975,9 +1997,9 @@ } }, "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.100", - "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", - "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-08575bFk7iiadxap4h+JxgGut9xDCHAGum6wh0eInN53zEMImkmEHi5MushX2FOmNO7qRNX7BPiDC5ZYBwHeEg==", "cpu": [ "x64" ], @@ -4461,6 +4483,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/culori": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/culori/-/culori-4.0.2.tgz", + "integrity": "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz", @@ -5570,6 +5601,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -6021,6 +6058,22 @@ "node": ">= 0.4" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/http-cache-semantics": { "version": "4.3.0", "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", @@ -7153,6 +7206,15 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/node-html-better-parser": { + "version": "1.5.9", + "resolved": "https://registry.npmmirror.com/node-html-better-parser/-/node-html-better-parser-1.5.9.tgz", + "integrity": "sha512-z1I5UINMezJXYL9cH3h0a9KBth2G978gSLlfkpQ+CQzzVHVQy9gpARgm9eDsz1O4gn1HtgUqjdAIYxKFZm6uHQ==", + "license": "MIT", + "dependencies": { + "html-entities": "^2.3.2" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -7475,6 +7537,236 @@ "@napi-rs/canvas": "^0.1.65" } }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/pdfjs-dist/node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", diff --git a/package.json b/package.json index 67323bf..3bbdfaf 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,10 @@ "test:e2e": "node scripts/demo-e2e.mjs" }, "dependencies": { + "@cantoo/pdf-lib": "^2.11.1", "@gutenye/ocr-models": "^1.4.2", "@gutenye/ocr-node": "^1.4.9", + "@napi-rs/canvas": "^1.1.0", "@xyflow/react": "^12.12.0", "adm-zip": "^0.5.16", "basic-ftp": "^6.2.3", diff --git a/src/main/capabilities/pdf-high.test.ts b/src/main/capabilities/pdf-high.test.ts new file mode 100644 index 0000000..9e9343f --- /dev/null +++ b/src/main/capabilities/pdf-high.test.ts @@ -0,0 +1,54 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { PDFDocument, StandardFonts } from "pdf-lib"; +import { PNG } from "pngjs"; +import { getHandler } from "../engine/registry"; +import type { CatalogComponent, FlowNode } from "../../shared/types"; +import type { RunContext } from "../engine/types"; +import "./pdf"; + +const directories: string[] = []; +const context = {} as RunContext; +const component = {} as CatalogComponent; +const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} }); +async function run(id: string, args: Record) { + const handler = getHandler(id); + expect(handler, id).toBeTruthy(); + return handler!(context, node(id), args, component); +} +afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))); + +describe("PDF high-level capabilities", () => { + it("creates a pdf fixture", async () => { + const directory = await mkdtempSafe(); + const pdf = await PDFDocument.create(); + const page = pdf.addPage([220, 120]); + const font = await pdf.embedFont(StandardFonts.Helvetica); + const png = new PNG({ width: 8, height: 8 }); + png.data.fill(255); png.data[0] = 20; png.data[1] = 90; png.data[2] = 200; png.data[3] = 255; + page.drawImage(await pdf.embedPng(PNG.sync.write(png)), { x: 140, y: 70, width: 40, height: 40 }); + for (const [value, x, y] of [["Name", 24, 90], ["Age", 120, 90], ["Alice", 24, 72], ["30", 120, 72]] as Array<[string, number, number]>) page.drawText(value, { x, y, size: 9, font }); + await writeFile(path.join(directory, "source.pdf"), await pdf.save()); + const filePath = path.join(directory, "source.pdf"); + expect((await readFile(path.join(directory, "source.pdf"))).byteLength).toBeGreaterThan(0); + const images = await run("PDF_GetPdfImg", { fileInPath: filePath, pdfRange: "all", pdfNum: "1", imgSavePath: directory, imageType: "png" }) as string[]; + expect(images.length).toBeGreaterThan(0); + expect((await readFile(images[0])).subarray(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + await expect(run("PDF_GetPdfTable", { fileInPath: filePath, pdfRange: "all", pdfNum: "1" })).resolves.toEqual([["Name", "Age"], ["Alice", "30"]]); + + const encryptedPath = path.join(directory, "encrypted.pdf"); + await writeFile(encryptedPath, await readFile(filePath)); + await expect(run("PDF_PdfEncryption", { fileInPath: encryptedPath, passWord: "secret" })).resolves.toBe(encryptedPath); + const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); + const opened = await pdfjs.getDocument({ data: new Uint8Array(await readFile(encryptedPath)), password: "secret" }).promise; + expect(opened.numPages).toBe(1); + }); +}); +async function mkdtempSafe(): Promise { + const directory = await (await import("node:fs/promises")).mkdtemp(path.join(os.tmpdir(), "rpa-pdf-high-")); + directories.push(directory); + return directory; +} diff --git a/src/main/capabilities/pdf.ts b/src/main/capabilities/pdf.ts index 08d123f..11af737 100644 --- a/src/main/capabilities/pdf.ts +++ b/src/main/capabilities/pdf.ts @@ -1,6 +1,8 @@ import { promises as fs } from "node:fs"; -import path from "node:path"; +import fsSync from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { PDFDocument } from "pdf-lib"; +import path from "node:path"; import { register } from "../engine/registry"; import { RuntimeError, type CapabilityHandler } from "../engine/types"; @@ -60,3 +62,203 @@ register("PDF_GetPdfText", async (_ctx, node, args) => { } return chunks.join("\n"); }); + +type PdfJsDocument = Awaited["promise"]>; + +function standardFontUrl(): string | undefined { + const candidates = [ + process.env.RPA_PDFJS_STANDARD_FONTS, + path.join(process.cwd(), "node_modules", "pdfjs-dist", "standard_fonts"), + path.join(process.execPath, "resources", "app.asar", "node_modules", "pdfjs-dist", "standard_fonts"), + path.join(process.execPath, "..", "resources", "app.asar", "node_modules", "pdfjs-dist", "standard_fonts") + ].filter(Boolean) as string[]; + const found = candidates.find((candidate) => fsSync.existsSync(candidate)); + return found ? pathToFileURL(path.join(found, path.sep)).href : undefined; +} + +class LocalStandardFontFactory { + baseUrl: string; + constructor(options: { baseUrl: string }) { this.baseUrl = options.baseUrl; } + async fetch({ filename }: { filename: string }): Promise { + return new Uint8Array(await fs.readFile(path.join(fileURLToPath(this.baseUrl), filename))); + } +} + +async function loadPdf(filePath: string, password: string) { + const canvasModule = await import("@napi-rs/canvas"); + const globals = globalThis as unknown as Record; + globals.Path2D = canvasModule.Path2D; + globals.DOMMatrix = canvasModule.DOMMatrix; + globals.ImageData = canvasModule.ImageData; + const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs"); + return { pdfjs, document: await pdfjs.getDocument({ data: new Uint8Array(await fs.readFile(filePath)), password: password || undefined, isEvalSupported: false, useSystemFonts: false, standardFontDataUrl: standardFontUrl(), StandardFontDataFactory: LocalStandardFontFactory }).promise }; +} + +function pageIndices(document: PdfJsDocument, raw: unknown): number[] { + const range = String(raw ?? "all").trim().toLowerCase(); + if (!range || range === "all") return Array.from({ length: document.numPages }, (_, index) => index + 1); + const result = new Set(); + for (const part of range.split(/[,,;;\s]+/).filter(Boolean)) { + const match = part.match(/^(\d+)-(\d+)$/); + if (match) { + const start = Math.max(1, Number(match[1])); const end = Math.min(document.numPages, Number(match[2])); + for (let index = start; index <= end; index += 1) result.add(index); + } else { + const index = Number(part); + if (index >= 1 && index <= document.numPages) result.add(Math.floor(index)); + } + } + const values = [...result].sort((left, right) => left - right); + if (!values.length) throw new Error(`PDF 页码范围无效:${range}`); + return values; +} + +async function renderPage(page: Awaited>, scale = 2): Promise { + const canvasModule = await import("@napi-rs/canvas"); + const viewport = page.getViewport({ scale }); + const canvas = canvasModule.createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height)); + const context = canvas.getContext("2d"); + context.fillStyle = "white"; context.fillRect(0, 0, canvas.width, canvas.height); + await page.render({ canvasContext: context as unknown as CanvasRenderingContext2D, viewport }).promise; + return canvas.toBuffer("image/png"); +} + +function saveImage(folder: string, prefix: string, suffix: string, data: Buffer, type: string): string { + const ext = /jpe?g/i.test(type) ? "jpg" : "png"; + const output = path.join(path.resolve(folder), `${prefix}_${suffix}_${Date.now()}.${ext}`); + fsSync.mkdirSync(path.dirname(output), { recursive: true }); + fsSync.writeFileSync(output, data); + return output; +} + +async function rawImageToBuffer(image: Record, type: string): Promise { + const width = Number(image.width || 0); const height = Number(image.height || 0); + const data = image.data; + if (!width || !height || !(data instanceof Uint8Array) || data.length === 0) throw new Error("PDF 图片数据无效"); + if (data[0] === 0xff && data[1] === 0xd8) return Buffer.from(data); + const canvasModule = await import("@napi-rs/canvas"); + const canvas = canvasModule.createCanvas(width, height); const context = canvas.getContext("2d"); + const target = context.createImageData(width, height); const kind = Number(image.kind || 0); + if (kind === 1) { + const bytesPerRow = Math.ceil(width / 8); + for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) { + const bit = (data[y * bytesPerRow + (x >> 3)] >> (7 - (x & 7))) & 1; const output = (y * width + x) * 4; const color = bit ? 255 : 0; + target.data[output] = color; target.data[output + 1] = color; target.data[output + 2] = color; target.data[output + 3] = 255; + } + } else if (kind === 3) { target.data.set(data.subarray(0, Math.min(data.length, target.data.length))); } + else { + for (let source = 0, targetIndex = 0; source < data.length && targetIndex < target.data.length; source += 3, targetIndex += 4) { + target.data[targetIndex] = data[source]; target.data[targetIndex + 1] = data[source + 1] ?? 0; target.data[targetIndex + 2] = data[source + 2] ?? 0; target.data[targetIndex + 3] = 255; + } + } + context.putImageData(target, 0, 0); + return /jpe?g/i.test(type) ? canvas.toBuffer("image/jpeg") : canvas.toBuffer("image/png"); +} + +async function objectImage(page: Awaited>, objId: unknown): Promise | null> { + if (typeof objId !== "string") return null; + const objects = objId.startsWith("g_") ? page.commonObjs : page.objs; + try { if (objects.has(objId)) return objects.get(objId) as Record; } catch {} + return await new Promise((resolve) => { try { objects.get(objId, (value: unknown) => resolve(value as Record)); } catch { resolve(null); } }); +} + +const encryption: CapabilityHandler = async (_ctx, node, args) => { + const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId); + const password = String(args.passWord ?? args.password ?? ""); + if (!password) throw new RuntimeError("validation_error", "PDF密码为空", { nodeId: node.id, componentId: node.componentId }); + const { PDFDocument } = await import("@cantoo/pdf-lib"); + const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true }); + document.encrypt({ userPassword: password, ownerPassword: password, permissions: { printing: "highResolution", copying: true, modifying: false } }); + const temporary = `${filePath}.${Date.now()}.enc`; + await fs.writeFile(temporary, await document.save()); + await fs.rename(temporary, filePath); + return filePath; +}; + +const extractImages: CapabilityHandler = async (_ctx, node, args) => { + const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId); + const folder = required(text(args, ["imgSavePath", "pdfSavePath", "folder", "path"]), "图片保存路径", node.id, node.componentId); + const type = text(args, ["imageType", "type"], "png"); + const prefix = text(args, ["imageName", "name"], "pdf-image"); + const { pdfjs, document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? "")); + const outputs: string[] = []; + for (const pageNumber of pageIndices(document, args.pdfNum)) { + const page = await document.getPage(pageNumber); + const opList = await page.getOperatorList(); + let sequence = 0; + for (let index = 0; index < opList.fnArray.length; index += 1) { + const op = opList.fnArray[index]; const commandArgs = opList.argsArray[index] as unknown[]; + let image: Record | null = null; + if (op === pdfjs.OPS.paintImageXObject || op === pdfjs.OPS.paintImageXObjectRepeat) image = await objectImage(page, commandArgs[0]); + else if (op === pdfjs.OPS.paintInlineImageXObject) image = commandArgs[0] as Record; + else if (op === pdfjs.OPS.paintInlineImageXObjectGroup) image = commandArgs[0] as Record; + if (!image) continue; + sequence += 1; + outputs.push(saveImage(folder, `${prefix}-p${pageNumber}`, String(sequence), await rawImageToBuffer(image, type), type)); + } + } + return outputs; +}; + +const pdfToImages: CapabilityHandler = async (_ctx, node, args) => { + const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId); + const folder = required(text(args, ["pdfSavePath", "imgSavePath", "folder", "path"]), "图片保存路径", node.id, node.componentId); + const type = text(args, ["imageType", "type"], "png"); const prefix = text(args, ["imageName", "name"], "pdf-page"); + const { document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? "")); + const outputs: string[] = []; + for (const pageNumber of pageIndices(document, args.pdfNum)) { + const buffer = await renderPage(await document.getPage(pageNumber)); + outputs.push(saveImage(folder, prefix, String(pageNumber), buffer, type)); + } + return outputs; +}; + +interface TextToken { x: number; y: number; value: string; } + +async function extractTableRows(page: Awaited>): Promise { + const content = await page.getTextContent(); + const tokens = content.items.flatMap((item) => { + if (!("str" in item) || !item.str.trim()) return []; + const transform = (item as { transform?: number[] }).transform ?? [1,0,0,1,0,0]; + return [{ x: Number(transform[4] || 0), y: Number(transform[5] || 0), value: item.str.trim() }]; + }); + if (tokens.length < 2) return []; + const lines = new Map(); + for (const token of [...tokens].sort((left, right) => right.y - left.y || left.x - right.x)) { + const lineKey = [...lines.keys()].find((key) => Math.abs(key - token.y) <= 3.5) ?? token.y; + if (!lines.has(lineKey)) lines.set(lineKey, []); + lines.get(lineKey)!.push(token); + } + const lineList = [...lines.entries()].map(([y, values]) => ({ y, values: values.sort((left, right) => left.x - right.x) })).sort((left, right) => right.y - left.y); + const counts = new Map(); + for (const line of lineList) for (const token of line.values) { + const key = Math.round(token.x / 5) * 5; + counts.set(key, (counts.get(key) || 0) + 1); + } + const columns = [...counts.entries()].filter(([, count]) => count >= Math.min(2, lineList.length)).sort((left, right) => left[0] - right[0]).map(([key]) => key); + if (columns.length < 2) return []; + const rows: string[][] = []; + for (const line of lineList) { + const cells = columns.map(() => ""); + for (const token of line.values) { + let nearest = 0; + for (let index = 1; index < columns.length; index += 1) if (Math.abs(token.x - columns[index]) < Math.abs(token.x - columns[nearest])) nearest = index; + cells[nearest] = cells[nearest] ? `${cells[nearest]} ${token.value}` : token.value; + } + if (cells.filter(Boolean).length >= 2) rows.push(cells); + } + return rows.length >= 2 ? rows : []; +} + +const readTables: CapabilityHandler = async (_ctx, node, args) => { + const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId); + const { document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? "")); + const rows: string[][] = []; + for (const pageNumber of pageIndices(document, args.pdfNum)) rows.push(...await extractTableRows(await document.getPage(pageNumber))); + return rows; +}; + +register("PDF_PdfEncryption", encryption); +register("PDF_GetPdfImg", extractImages); +register("PDF_PdfToImg", pdfToImages); +register("PDF_GetPdfTable", readTables); From e390b1b1eb50a4e6c48aece834109f65188b0d74 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 17:43:07 +0800 Subject: [PATCH 5/5] feat: mail find reply and outlook message retrieval --- package-lock.json | 544 +++++++++++++++++++++ package.json | 2 + src/main/capabilities/message-recv.test.ts | 67 +++ src/main/capabilities/message.ts | 130 +++++ 4 files changed, 743 insertions(+) create mode 100644 src/main/capabilities/message-recv.test.ts diff --git a/package-lock.json b/package-lock.json index fa46cc1..72bf83d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "adm-zip": "^0.5.16", "basic-ftp": "^6.2.3", "exceljs": "^4.4.0", + "imapflow": "^2.10.0", "lucide-react": "^0.468.0", + "mailparser": "^3.14.1", "nodemailer": "^6.9.16", "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.10.38", @@ -2157,6 +2159,12 @@ "dev": true, "license": "0BSD" }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.15", "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", @@ -2769,6 +2777,22 @@ "win32" ] }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "license": "MIT", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz", @@ -3255,6 +3279,20 @@ "d3-zoom": "^3.0.0" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.29", + "resolved": "https://registry.npmmirror.com/@zone-eu/mailsplit/-/mailsplit-5.4.29.tgz", + "integrity": "sha512-CCUNycmUjrV5M0wNf23waWAt9eOHczhjLbUQS7k0FNJvR0py9VSqHFt+tSl/D0+XI73VLG0U+PwYtq9R0zsm9Q==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.4.1", + "libmime": "5.6.3", + "libqp": "3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/abbrev": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", @@ -3819,6 +3857,15 @@ "node": ">= 4.0.0" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4711,6 +4758,25 @@ "node": ">=6" } }, + "node_modules/deepmerge-ts": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz", + "integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -4821,6 +4887,61 @@ "node": ">=12" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -5212,6 +5333,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encoding-japanese": { + "version": "2.4.0", + "resolved": "https://registry.npmmirror.com/encoding-japanese/-/encoding-japanese-2.4.0.tgz", + "integrity": "sha512-PE0KjlsDy9JSsoU4C2Q8NXb1kC4c+bZRac9ZvHXUE4q1Ht63gScKMdEy9jst9+Fi3M4eDkqzkEhBZvix4blP2A==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -5221,6 +5351,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", @@ -6058,6 +6200,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmmirror.com/html-entities/-/html-entities-2.6.0.tgz", @@ -6074,6 +6225,56 @@ ], "license": "MIT" }, + "node_modules/html-to-text": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/html-to-text/-/html-to-text-10.0.1.tgz", + "integrity": "sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^8.0.1", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-cache-semantics": { "version": "4.3.0", "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", @@ -6123,6 +6324,22 @@ "node": ">= 14" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/idb-keyval": { "version": "6.3.0", "resolved": "https://registry.npmmirror.com/idb-keyval/-/idb-keyval-6.3.0.tgz", @@ -6149,6 +6366,25 @@ ], "license": "BSD-3-Clause" }, + "node_modules/imapflow": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/imapflow/-/imapflow-2.10.0.tgz", + "integrity": "sha512-J4YH1eHwemE00xaCT2vn30s+81iJi+C4OHQEoO6/hptA/z75nGdP1isU3oDfMaiheHD240LIhMMJcMMCJ5RDYA==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.29", + "encoding-japanese": "2.4.0", + "iconv-lite": "0.7.3", + "libbase64": "1.4.1", + "libmime": "5.6.3", + "libqp": "3.0.1", + "pino": "10.3.1", + "socks": "2.8.10" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", @@ -6186,6 +6422,15 @@ "node": ">= 0.4" } }, + "node_modules/ip-address": { + "version": "10.7.5", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6802,6 +7047,48 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmmirror.com/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/libbase64": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/libbase64/-/libbase64-1.4.1.tgz", + "integrity": "sha512-Nyh3H90B3gRtrcxIe56Ayy7635J0jmh6AbxbQgQae8wrSPQMH5wFqJChiMTB13yKG512L3YspbA5DzumhFTITA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/libmime": { + "version": "5.6.3", + "resolved": "https://registry.npmmirror.com/libmime/-/libmime-5.6.3.tgz", + "integrity": "sha512-cuTUW/Jybvh5O4/U9p61gyutHOjDtZyGQYuY99+bfnaFwqcDf2Nb+R6hMHY6DDen2H9wLKQSMpofWUgn2TI5JQ==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.4.0", + "iconv-lite": "0.7.3", + "libbase64": "1.4.1", + "libqp": "3.0.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/libqp": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/libqp/-/libqp-3.0.1.tgz", + "integrity": "sha512-7QZxpPX25pga1WiMueduWxmrHMVBFrZPH6ZUaozGYa/H98JH6NVBSET3qVw+4NxzoraIW7rJ81YxSU9IalvCpg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", @@ -6811,6 +7098,25 @@ "immediate": "~3.0.5" } }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/listenercount": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/listenercount/-/listenercount-1.0.1.tgz", @@ -6961,6 +7267,34 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/mailparser": { + "version": "3.14.1", + "resolved": "https://registry.npmmirror.com/mailparser/-/mailparser-3.14.1.tgz", + "integrity": "sha512-Z0LLk7F0yhSCx3xjyL8BMm6dXO3vuWrSAeVhm1F18wVGVBbLAKOsVsCjPyOhJy+KuyDW9PRyDuyiPb7yJCHwVA==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.29", + "he": "1.2.0", + "html-to-text": "10.0.1", + "libmime": "5.6.3", + "linkify-it": "5.0.2", + "nodemailer": "10.3.1", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/mailparser/node_modules/nodemailer": { + "version": "10.3.1", + "resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-10.3.1.tgz", + "integrity": "sha512-uF0AFsJC07Y1vdW7p580DKAnh7rAO+4UPSSB3mDCPNDufoiaOebV6JmrzpxB14OjEEEiBsNdYoM2Y+fN8M7BBA==", + "license": "MIT-0", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz", @@ -7320,6 +7654,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", @@ -7477,6 +7820,19 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -7767,6 +8123,15 @@ "url": "https://github.com/sponsors/Brooooooklyn" } }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmmirror.com/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", @@ -7794,6 +8159,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkijs": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.3.tgz", @@ -7943,6 +8345,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", @@ -7990,6 +8408,15 @@ "once": "^1.3.1" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", @@ -8017,6 +8444,12 @@ "node": ">=16.0.0" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", @@ -8122,6 +8555,15 @@ "node": ">=10" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -8411,6 +8853,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sanitize-filename": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", @@ -8452,6 +8909,18 @@ "loose-envify": "^1.1.0" } }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "license": "MIT", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", @@ -8707,6 +9176,39 @@ "node": ">=10" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8738,6 +9240,15 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -9026,6 +9537,24 @@ "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", "license": "Apache-2.0" }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tiny-async-pool": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", @@ -9113,6 +9642,15 @@ "node": ">=14.0.0" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmmirror.com/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.7.tgz", @@ -9296,6 +9834,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/package.json b/package.json index 3bbdfaf..f7ec164 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "adm-zip": "^0.5.16", "basic-ftp": "^6.2.3", "exceljs": "^4.4.0", + "imapflow": "^2.10.0", "lucide-react": "^0.468.0", + "mailparser": "^3.14.1", "nodemailer": "^6.9.16", "pdf-lib": "^1.17.1", "pdfjs-dist": "^4.10.38", diff --git a/src/main/capabilities/message-recv.test.ts b/src/main/capabilities/message-recv.test.ts new file mode 100644 index 0000000..b996ae3 --- /dev/null +++ b/src/main/capabilities/message-recv.test.ts @@ -0,0 +1,67 @@ +import net from "node:net"; +import { afterEach, describe, expect, it } from "vitest"; +import type { CatalogComponent, FlowNode } from "../../shared/types"; +import type { RunContext } from "../engine/types"; +import { getHandler, hasCapability } from "../engine/registry"; +import "./message"; + +const context = {} as RunContext; +const component = {} as CatalogComponent; +const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} }); +const ids = [ + "Mail_FindMail", "Mail_FindMail_v1", "Mail_FindMail_v2", + "Mail_ReplyMail", "Mail_ReplyMail_v1", "Mail_ReplyMail_v2", + "Mail_DoGetMailMessage", "Mail_DoGetMailMessage_v1", "Mail_DoGetMailMessage_v2" +]; + +afterEach(async () => {}); + +describe("mail receive and reply", () => { + it("registers find, reply and Outlook message capabilities", () => { + for (const id of ids) expect(hasCapability(id), id).toBe(true); + }); + + it("replies through SMTP with the selected mail sender", async () => { + let received = ""; + const server = net.createServer((socket) => { + socket.setEncoding("utf8"); + socket.write("220 localhost ESMTP\r\n"); + let buffer = ""; + let dataMode = false; + socket.on("data", (chunk) => { + buffer += chunk; + const lines = buffer.split("\r\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (dataMode) { + if (line === ".") { + dataMode = false; + socket.write("250 queued\r\n"); + } else received += line + "\n"; + } else if (/^EHLO/i.test(line)) socket.write("250-localhost\r\n250 OK\r\n"); + else if (/^MAIL FROM/i.test(line)) socket.write("250 ok\r\n"); + else if (/^RCPT TO/i.test(line)) socket.write("250 ok\r\n"); + else if (/^DATA/i.test(line)) { dataMode = true; socket.write("354 end with dot\r\n"); } + else if (/^QUIT/i.test(line)) { socket.write("221 bye\r\n"); socket.end(); } + else socket.write("250 OK\r\n"); + } + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as net.AddressInfo).port; + try { + const handler = getHandler("Mail_ReplyMail_v2"); + expect(handler).toBeTruthy(); + const result = await handler!(context, node("Mail_ReplyMail_v2"), { + account_obj: { host: "127.0.0.1", port, account: "robot@example.com", secure: "False" }, + mail_obj: { from: "owner@example.com", subject: "status", messageId: "" }, + content: "reply delivered" + }, component); + expect(result).toMatchObject({ accepted: ["owner@example.com"] }); + expect(received).toContain("reply delivered"); + expect(received).toContain("Re: status"); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }, 30000); +}); diff --git a/src/main/capabilities/message.ts b/src/main/capabilities/message.ts index 3d32714..b083d6a 100644 --- a/src/main/capabilities/message.ts +++ b/src/main/capabilities/message.ts @@ -1,5 +1,7 @@ import nodemailer from "nodemailer"; import { register } from "../engine/registry"; +import { RuntimeError } from "../engine/types"; +import { powershell } from "./desktop"; function text(args: Record, keys: string[], fallback = ""): string { for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]); @@ -42,3 +44,131 @@ register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], a transporter.close(); } }); + +export interface NormalizedMail { + uid: string; from: string; to: string[]; cc: string[]; subject: string; + text: string; html: string; date: string; messageId: string; hasAttachment: boolean; + attachmentNames: string[]; +} + +export function normalizeAccount(args: Record): Record { + return accountObject(args.account_obj ?? args.accountObj ?? args.account); +} + +function addressList(value: unknown): string[] { + if (!value) return []; + if (Array.isArray(value)) return value.map((item) => String(typeof item === "object" && item && "text" in (item as Record) ? (item as { text: unknown }).text : item)).filter(Boolean); + if (typeof value === "object") { const record = value as { text?: unknown; value?: unknown }; return addressList(record.text ?? record.value); } + return String(value).split(/[;,,;\s]+/).filter(Boolean); +} + +export function normalizeMail(input: Record, uid = ""): NormalizedMail { + const attachments = Array.isArray(input.attachments) ? input.attachments as Array> : []; + return { + uid: String(input.uid ?? uid), + from: String(input.from ?? input.sender ?? ""), + to: addressList(input.to), cc: addressList(input.cc), + subject: String(input.subject ?? ""), + text: String(input.text ?? input.body ?? ""), + html: String(input.html ?? ""), + date: input.date instanceof Date ? input.date.toISOString() : String(input.date ?? input.receivedTime ?? ""), + messageId: String(input.messageId ?? ""), + hasAttachment: Boolean(input.hasAttachment ?? attachments.length), + attachmentNames: attachments.map((item) => String(item.filename ?? item.name ?? "")).filter(Boolean) + }; +} + +function matches(mail: NormalizedMail, args: Record): boolean { + const contains = (needle: unknown, haystack: string): boolean => !needle || haystack.toLowerCase().includes(String(needle).toLowerCase()); + return contains(args.From_data, mail.from) + && contains(args.To_data, mail.to.join(" ")) + && contains(args.Subject_data, mail.subject) + && contains(args.Body_data, mail.text + "\n" + mail.html) + && (args.attachment === undefined || String(args.attachment) === "2" || (String(args.attachment) === "1") === mail.hasAttachment); +} + +async function findImapMail(args: Record, node: { id: string; componentId: string }): Promise { + const configured = normalizeAccount(args); + const merged = { ...args, ...configured }; + const account = text(merged, ["account", "username", "user", "from"]); + const password = text(merged, ["password", "pass", "token"]); + const host = text(merged, ["imap", "imapHost", "imap_host", "server", "host"]); + if (!host || !account) throw new RuntimeError("validation_error", "IMAP 配置不完整:需要 host 和 account", { nodeId: node.id, componentId: node.componentId }); + const port = Number(text(merged, ["imapPort", "imap_port", "recvPort", "port"], "993")); + const secure = ["true", "1", "是", "True"].includes(text(merged, ["imap_ssl", "imapSsl", "is_ssl", "secure"], "True")); + const folder = text(args, ["folder_name", "email_folder", "folder"], "INBOX"); + const { ImapFlow } = await import("imapflow"); + const client = new ImapFlow({ host, port, secure, auth: { user: account, pass: password }, logger: false }); + const mails: NormalizedMail[] = []; + try { + await client.connect(); + const lock = await client.getMailboxLock(folder); + try { + const range = String(args.search_range ?? "ALL").toUpperCase(); + const criteria: Record = range === "UNSEEN" ? { seen: false } : range === "SEEN" ? { seen: true } : range === "FLAGGED" ? { flagged: true } : range === "ANSWERED" ? { answered: true } : range === "DELETED" ? { deleted: true } : range === "DRAFT" ? { draft: true } : { all: true }; + if (args.From_data) criteria.from = String(args.From_data); + if (args.To_data) criteria.to = String(args.To_data); + if (args.Subject_data) criteria.subject = String(args.Subject_data); + const uids = await client.search(criteria as never, { uid: true }) || []; + const selected = Math.max(1, Number(args.email_num ?? 1) || 1); + for (let index = uids.length - 1; index >= 0 && mails.length < Math.max(20, selected); index -= 1) { + const download = await client.fetchOne(String(uids[index]), { uid: true, envelope: true, flags: true, bodyStructure: true, source: true }, { uid: true }); + if (!download || !download.source) continue; + const { simpleParser } = await import("mailparser"); + const parsed = await simpleParser(download.source); + const mail = normalizeMail(parsed as unknown as Record, String(download.uid)); + if (matches(mail, args)) mails.push(mail); + } + } finally { lock.release(); } + } finally { client.close(); } + const selected = Math.max(1, Number(args.email_num ?? 1) || 1); + return mails.slice(0, selected); +} + +register(["Mail_FindMail", "Mail_FindMail_v1", "Mail_FindMail_v2"], async (_ctx, node, args) => findImapMail(args, node)); + +register(["Mail_ReplyMail", "Mail_ReplyMail_v1", "Mail_ReplyMail_v2"], async (_ctx, node, args) => { + const configured = accountObject(args.mail_obj ?? args.email_obj ?? args.account_obj); + const mail = configured && Object.keys(configured).length ? configured as unknown as NormalizedMail : args.mail as unknown as NormalizedMail; + const merged = { ...args, ...normalizeAccount(args) }; + const account = text(merged, ["account", "username", "user", "from"]); + const password = text(merged, ["password", "pass"]); + const host = text(merged, ["smtp", "smtpHost", "smtp_host", "server", "host"]); + if (!host || !account) throw new RuntimeError("validation_error", "SMTP 配置不完整:需要 host 和 account", { nodeId: node.id, componentId: node.componentId }); + const port = Number(text(merged, ["smtpPort", "smtp_port", "sendPort", "port"], "465")); + const secure = ["true", "1", "是", "True"].includes(text(merged, ["smtp_ssl", "smtpSsl", "is_ssl", "secure"], "True")); + const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined }); + try { + const result = await transporter.sendMail({ + from: account, + to: mail?.from ? [mail.from] : list(text(args, ["to", "receiver"])), + subject: text(args, ["subject"], mail?.subject ? "Re: " + mail.subject : "Re:"), + text: text(args, ["content", "body"]), + inReplyTo: mail?.messageId, references: mail?.messageId, + attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined + }); + return { messageId: result.messageId, accepted: result.accepted }; + } finally { transporter.close(); } +}); + +function outlookScript(args: Record): string { + const criteria = Buffer.from(JSON.stringify({ to: String(args.To_data ?? ""), from: String(args.From_data ?? ""), subject: String(args.Subject_data ?? ""), body: String(args.Body_data ?? "") }), "utf8").toString("base64"); + return [ + "$outlook=New-Object -ComObject Outlook.Application;$ns=$outlook.GetNamespace('MAPI')", + `$folderPath=${JSON.stringify(String(args.folder_name || args.email_folder || "INBOX"))}`, + "$folder=$ns.GetDefaultFolder(6);foreach($part in ($folderPath -split '/')){ if($part -and $part -ne 'INBOX'){ $folder=$folder.Folders.Item($part) } }", + "$folder.Items.Sort('[ReceivedTime]',$true)|Out-Null;$rows=@();$count=[Math]::Min(500,$folder.Items.Count)", + `$criteria=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${criteria}'))|ConvertFrom-Json`, + "for($i=1;$i -le $count;$i++){ $item=$folder.Items.Item($i); $subject=[string]$item.Subject; $body=[string]$item.Body; $to=[string]$item.To; $from=[string]$item.SenderEmailAddress; if(($criteria.to -and $to -notlike ('*'+$criteria.to+'*')) -or ($criteria.from -and $from -notlike ('*'+$criteria.from+'*')) -or ($criteria.subject -and $subject -notlike ('*'+$criteria.subject+'*')) -or ($criteria.body -and $body -notlike ('*'+$criteria.body+'*'))){continue}; $bodyB64=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($body)); $rows += @{uid=[string]$item.EntryID;from=$from;to=$to;subject=$subject;text=$bodyB64;html='';date=$item.ReceivedTime.ToString('o');messageId=[string]$item.EntryID;hasAttachment=($item.Attachments.Count -gt 0);attachmentNames=@()}; if($rows.Count -ge 100){break} }", + "$json=$rows|ConvertTo-Json -Compress -Depth 4;[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))|Write-Output" + ].join(";"); +} + +register(["Mail_DoGetMailMessage", "Mail_DoGetMailMessage_v1", "Mail_DoGetMailMessage_v2"], async (_ctx, node, args) => { + const output = await powershell(outlookScript(args), 120000); + if (!output.trim()) throw new RuntimeError("outlook_unavailable", "Outlook COM 未返回数据;请确认 Outlook 已安装并至少配置一个邮箱", { nodeId: node.id, componentId: node.componentId }); + const rows = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8")); + const mails = (Array.isArray(rows) ? rows : [rows]).map((row: Record) => normalizeMail({ ...row, text: row.text ? Buffer.from(String(row.text), "base64").toString("utf8") : "" })); + const selected = Math.max(1, Number(args.email_num ?? 1) || 1); + return mails.slice(0, selected); +});