Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions plugins/address-parser/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# 更新日志

## 0.1.1 - 2026-09-01

- 适配 ZTools 3.2:CSV 导出后可直接拖出文件;仅允许最近生成的普通 CSV 单次外拖,旧版继续使用保存对话框。
- 新增 2.4.0 宿主版本门禁;低版本或真实宿主版本不可识别时显示升级提示,仅浏览器开发预览放行。

## 0.1.0 - 2026-07-29

### 新增
Expand Down
7 changes: 7 additions & 0 deletions plugins/address-parser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

面向电商、客服、行政和物流场景的 ZTools 插件。把聊天消息、订单备注或收货清单粘贴进来,即可批量提取姓名、电话、省、市、区县和详细地址,检查缺失项,并导出 Excel/WPS 可直接打开的 CSV 表格。

## ZTools 兼容性

- ZTools 3.2.0:导出的 CSV 可从“拖出 CSV”按钮拖至外部应用。
- ZTools 2.4–3.1:保留保存对话框导出流程。
- 低于 2.4.0,或真实 ZTools 宿主无法提供可比较版本号:显示升级提示。仅未注入 `window.ztools` 的浏览器开发预览放行。

## 功能

- 本地规则解析:支持中国大陆手机号(含 `+86` 和空格/连字符)、常见座机、34 个省级行政区、地级市/自治州/地区/盟以及区县。
Expand Down Expand Up @@ -96,4 +102,5 @@ address-parser/
- 不发送网络请求,不保存输入历史。
- preload 仅暴露 `saveCsv(content, suggestedName)`,不向页面暴露 `fs` 或任意文件写入能力。
- 导出内容限制为 20 MB,保存位置必须由用户在系统对话框中选择。
- 文件外拖仅接受最近 5 分钟内由插件成功保存的 CSV,校验真实普通文件后单次授权,不能拖出任意绝对路径。
- CSV 对以 `= + - @` 开头的单元格增加文本前缀,降低表格软件公式注入风险。
28 changes: 28 additions & 0 deletions plugins/address-parser/app.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
(function () {
"use strict";

function isSupportedHost() {
if (!window.ztools) return true;
try {
return window.addressParserBridge?.hostCompatibility?.().supported === true;
} catch (_) {
return false;
}
}

if (!isSupportedHost()) {
document.querySelector("main").innerHTML = '<section class="workbench"><h2>需要升级 ZTools</h2><p>当前 ZTools 版本过低或无法识别(最低支持 2.4.0)。为了获得更完整、稳定的体验,请升级后再使用收货地址智能解析。</p></section>';
return;
}

const core = window.AddressParserCore;
const csv = window.AddressCsv;
const sourceInput = document.getElementById("source-input");
const parseButton = document.getElementById("parse-button");
const sampleButton = document.getElementById("sample-button");
const clearButton = document.getElementById("clear-button");
const exportButton = document.getElementById("export-button");
const dragExportButton = document.getElementById("drag-export-button");
const resultBody = document.getElementById("result-body");
const emptyState = document.getElementById("empty-state");
const tableWrap = document.getElementById("table-wrap");
Expand Down Expand Up @@ -35,6 +50,7 @@
let activeFilter = "all";
let currentPage = 1;
let toastTimer = null;
let lastExportPath = "";

function getSplitMode() {
const checked = document.querySelector('input[name="split-mode"]:checked');
Expand Down Expand Up @@ -198,6 +214,11 @@
if (window.addressParserBridge && typeof window.addressParserBridge.saveCsv === "function") {
const result = await window.addressParserBridge.saveCsv(content, fileName);
if (result && result.canceled) return;
lastExportPath = result.path || "";
if (lastExportPath && window.addressParserBridge.canStartDrag()) {
dragExportButton.hidden = false;
dragExportButton.draggable = true;
}
showToast("CSV 已导出:" + (result.path || fileName), false);
} else {
downloadInBrowser(content, fileName);
Expand Down Expand Up @@ -259,6 +280,13 @@
sampleButton.addEventListener("click", function () { sourceInput.value = sampleText; sourceInput.focus(); });
clearButton.addEventListener("click", clearAll);
exportButton.addEventListener("click", exportCsv);
dragExportButton.addEventListener("dragstart", function (event) {
event.preventDefault();
if (!lastExportPath) return;
window.addressParserBridge.startDrag(lastExportPath).catch(function (error) {
showToast(error && error.message ? error.message : String(error), true);
});
});
sourceInput.addEventListener("keydown", function (event) {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
Expand Down
62 changes: 62 additions & 0 deletions plugins/address-parser/core/file-drag-grants.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use strict";

const DEFAULT_TTL_MS = 5 * 60 * 1000;

function createFileDragGrantStore(options) {
const fs = options.fs;
const path = options.path;
const now = options.now || Date.now;
const ttlMs = options.ttlMs || DEFAULT_TTL_MS;
const requiredExtension = String(options.requiredExtension || "").toLowerCase();
const grants = new Map();

function canonicalFile(filePath) {
if (typeof filePath !== "string" || !path.isAbsolute(filePath)) {
throw new Error("拖出的导出路径无效。");
}
let canonical;
try {
canonical = fs.realpathSync(filePath);
if (!fs.statSync(canonical).isFile()) throw new Error("not a regular file");
} catch (_) {
throw new Error("拖出的导出路径无效或文件已不存在。");
}
if (requiredExtension && path.extname(canonical).toLowerCase() !== requiredExtension) {
throw new Error("拖出的导出文件类型无效。");
}
return canonical;
}

function pruneExpired() {
const current = now();
for (const [filePath, expiresAt] of grants) {
if (expiresAt <= current) grants.delete(filePath);
}
}

return {
grant(filePath) {
const canonical = canonicalFile(filePath);
pruneExpired();
grants.set(canonical, now() + ttlMs);
return canonical;
},

consume(filePaths) {
const values = Array.isArray(filePaths) ? filePaths : [filePaths];
if (!values.length) throw new Error("拖出的导出路径无效。");
pruneExpired();
const canonical = values.map(canonicalFile);
if (new Set(canonical).size !== canonical.length) {
throw new Error("拖出的导出路径包含重复文件。");
}
if (canonical.some(filePath => !grants.has(filePath))) {
throw new Error("只能拖出刚刚由插件导出的 CSV 文件。");
}
canonical.forEach(filePath => grants.delete(filePath));
return canonical;
}
};
}

module.exports = { DEFAULT_TTL_MS, createFileDragGrantStore };
2 changes: 1 addition & 1 deletion plugins/address-parser/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ <h2 id="results-title">逐项核对后导出</h2>
<button class="filter-tab" data-filter="complete" role="tab" aria-selected="false">完整</button>
<button class="filter-tab" data-filter="missing" role="tab" aria-selected="false">待补充</button>
</div>
<button id="export-button" class="button button-export" type="button" disabled>导出全部 CSV</button>
<div class="export-actions"><button id="export-button" class="button button-export" type="button" disabled>导出全部 CSV</button><button id="drag-export-button" class="button" type="button" hidden>拖出 CSV</button></div>
</div>

<div id="empty-state" class="empty-state">
Expand Down
4 changes: 2 additions & 2 deletions plugins/address-parser/package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"name": "ztools-address-parser",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"description": "ZTools shipping address parser and CSV exporter.",
"scripts": {
"test": "node --test tests/*.test.cjs",
"check": "node --check app.js && node --check preload.js && node --check core/address-parser.js && node --check core/csv.js && node --check core/exporter.cjs && npm test"
"check": "node --check app.js && node --check preload.js && node --check core/address-parser.js && node --check core/csv.js && node --check core/exporter.cjs && node --check core/file-drag-grants.cjs && npm test"
},
"engines": {
"node": ">=16.17.0"
Expand Down
2 changes: 1 addition & 1 deletion plugins/address-parser/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"title": "收货地址智能解析",
"description": "批量提取收货人、电话、省市区和详细地址,检查缺失字段并导出 CSV 表格。",
"author": "harris",
"version": "0.1.0",
"version": "0.1.1",
"main": "index.html",
"preload": "preload.js",
"logo": "logo.svg",
Expand Down
59 changes: 57 additions & 2 deletions plugins/address-parser/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,40 @@
const fs = require("fs");
const path = require("path");
const { createExportService } = require("./core/exporter.cjs");
const { createFileDragGrantStore } = require("./core/file-drag-grants.cjs");

const saveCsv = createExportService({
const MINIMUM_VERSION = "2.4.0";

function parseVersion(value) {
if (typeof value !== "string") return null;
const match = value.match(/^\s*v?(\d+)\.(\d+)(?:\.(\d+))?(?:[-+][0-9A-Za-z.-]+)?\s*$/);
if (!match) return null;
const parts = [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
return parts.every(Number.isSafeInteger) ? parts : null;
}

function isPrereleaseVersion(value) {
return typeof value === "string" && /^\s*v?\d+\.\d+(?:\.\d+)?-/.test(value);
}

function hostCompatibility() {
const ztools = window.ztools;
if (!ztools) return { version: "", supported: true };
let getAppVersion;
try { getAppVersion = ztools.getAppVersion; } catch (_) { return { version: "", supported: false }; }
if (typeof getAppVersion !== "function") return { version: "", supported: false };
let version;
try { version = getAppVersion.call(ztools); } catch (_) { return { version: "", supported: false }; }
const current = parseVersion(version);
const minimum = parseVersion(MINIMUM_VERSION);
const atMinimum = Boolean(current && minimum) && current.every((part, index) => part === minimum[index]);
const supported = Boolean(current && minimum) && !(
atMinimum && isPrereleaseVersion(version)
) && (current[0] > minimum[0] || (current[0] === minimum[0] && (current[1] > minimum[1] || (current[1] === minimum[1] && current[2] >= minimum[2]))));
return { version: typeof version === "string" ? version : "", supported };
}

const saveCsvFile = createExportService({
fs,
path,
showSaveDialog(options) {
Expand All @@ -19,4 +51,27 @@ const saveCsv = createExportService({
}
});

window.addressParserBridge = Object.freeze({ saveCsv });
const dragGrants = createFileDragGrantStore({
fs,
path,
requiredExtension: ".csv"
});

async function saveCsv(content, suggestedName) {
const result = await saveCsvFile(content, suggestedName);
if (!result.canceled && result.path) dragGrants.grant(result.path);
return result;
}

window.addressParserBridge = Object.freeze({
saveCsv,
hostCompatibility,
canStartDrag() { return typeof window.ztools?.startDrag === "function"; },
async startDrag(paths) {
if (typeof window.ztools?.startDrag !== "function") throw new Error("请升级到 ZTools 3.2.0 以拖出文件。");
const values = dragGrants.consume(paths);
await Promise.resolve(window.ztools.startDrag(values.length === 1 ? values[0] : values));
}
});

module.exports = { MINIMUM_VERSION, parseVersion, hostCompatibility };
1 change: 1 addition & 0 deletions plugins/address-parser/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ kbd { padding: 2px 5px; border: 1px solid var(--line); border-bottom-width: 2px;
.stats .stat-complete { border-left-color: var(--verified); background: var(--verified-soft); }
.stats .stat-missing { border-left-color: var(--parcel); background: var(--parcel-soft); }
.result-toolbar { padding: 10px 20px; background: #fbfcfd; border-bottom: 1px solid var(--line); }
.export-actions { display: flex; gap: 8px; align-items: center; }
.filter-tabs { display: flex; gap: 3px; }
.filter-tab { padding: 7px 12px; border: 0; border-radius: 6px; background: transparent; color: var(--ink-soft); cursor: pointer; font-size: 12px; font-weight: 700; }
.filter-tab.is-active { background: var(--ink); color: white; }
Expand Down
63 changes: 63 additions & 0 deletions plugins/address-parser/tests/file-drag-grants.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"use strict";

const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { createFileDragGrantStore } = require("../core/file-drag-grants.cjs");

test("only a freshly exported CSV can be dragged and the grant is single-use", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "address-parser-drag-"));
const output = path.join(root, "result.csv");
const arbitrary = path.join(root, "arbitrary.csv");
fs.writeFileSync(arbitrary, "untrusted");
const dragged = [];
const previousWindow = global.window;
global.window = {
ztools: {
getPath: () => root,
showSaveDialog: () => output,
startDrag: value => dragged.push(value)
}
};
delete require.cache[require.resolve("../preload.js")];

try {
require("../preload.js");
await assert.rejects(global.window.addressParserBridge.startDrag(arbitrary), /刚刚由插件导出/);

const result = await global.window.addressParserBridge.saveCsv("name,address\nA,B", "result.csv");
assert.equal(result.canceled, false);
await global.window.addressParserBridge.startDrag(result.path);
assert.deepEqual(dragged, [fs.realpathSync(output)]);

await assert.rejects(global.window.addressParserBridge.startDrag(result.path), /刚刚由插件导出/);
} finally {
delete require.cache[require.resolve("../preload.js")];
global.window = previousWindow;
fs.rmSync(root, { recursive: true, force: true });
}
});

test("CSV drag grants expire", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "address-parser-drag-expiry-"));
const output = path.join(root, "result.csv");
fs.writeFileSync(output, "ok");
let currentTime = 100;
const grants = createFileDragGrantStore({
fs,
path,
now: () => currentTime,
ttlMs: 50,
requiredExtension: ".csv"
});

try {
grants.grant(output);
currentTime = 151;
assert.throws(() => grants.consume(output), /刚刚由插件导出/);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
38 changes: 38 additions & 0 deletions plugins/address-parser/tests/host-compatibility.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");

test("preload version gate only bypasses an explicit browser preview", () => {
const previousWindow = global.window;
global.window = {};
try {
const { parseVersion, hostCompatibility } = require("../preload.js");
assert.deepEqual(parseVersion("3.2"), [3, 2, 0]);
assert.deepEqual(parseVersion("v3.2.0-beta.1"), [3, 2, 0]);
assert.equal(parseVersion("ZTools 3.2.0"), null);
assert.equal(hostCompatibility().supported, true);

global.window.ztools = {};
assert.equal(hostCompatibility().supported, false);
const throwingGetter = {};
Object.defineProperty(throwingGetter, "getAppVersion", { get() { throw new Error("unavailable"); } });
global.window.ztools = throwingGetter;
assert.equal(hostCompatibility().supported, false);
global.window.ztools = { getAppVersion: () => { throw new Error("unavailable"); } };
assert.equal(hostCompatibility().supported, false);
for (const version of ["", "unknown", 320]) {
global.window.ztools = { getAppVersion: () => version };
assert.equal(hostCompatibility().supported, false);
}
global.window.ztools = { getAppVersion: () => "2.3.9" };
assert.equal(hostCompatibility().supported, false);
global.window.ztools = { getAppVersion: () => "2.4.0-beta.1" };
assert.equal(hostCompatibility().supported, false);
for (const version of ["2.4", "3.1.9", "3.2.0"]) {
global.window.ztools = { getAppVersion: () => version };
assert.equal(hostCompatibility().supported, true);
}
} finally {
global.window = previousWindow;
}
});
Loading