diff --git a/plugins/pdf-office-workbench/CHANGELOG.md b/plugins/pdf-office-workbench/CHANGELOG.md index d92c66b98..d3a6266cd 100644 --- a/plugins/pdf-office-workbench/CHANGELOG.md +++ b/plugins/pdf-office-workbench/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.2.1 - 2026-09-01 + +- 适配 ZTools 3.2:合并、拆分和重命名生成的 PDF 可直接拖到外部应用,并采用 5 分钟、单次、精确真实路径授权。 +- 新增 2.4.0 宿主版本门禁;低版本或真实宿主版本不可识别时在业务事件绑定前显示升级提示,2.4–3.1 保留打开所在文件夹流程。 + ## v0.2.0 - 接入真实 PDF 文件读写:选择、合并、按页拆分和输出结果。 diff --git a/plugins/pdf-office-workbench/README.md b/plugins/pdf-office-workbench/README.md index a95ffcb3a..7018d01bf 100644 --- a/plugins/pdf-office-workbench/README.md +++ b/plugins/pdf-office-workbench/README.md @@ -4,6 +4,13 @@ 当前范围聚焦 PDF 与票据流程;Word、Excel、PPT 的原生编辑/转换仍属于后续能力,不在本版本中宣称支持。 +## ZTools 兼容性 + +- ZTools 3.2.0:生成的 PDF 可直接拖到外部应用。 +- 外拖授权仅覆盖最近 5 分钟内由合并、拆分或重命名成功产生的普通 PDF,使用一次后立即失效。 +- ZTools 2.4–3.1:保留打开所在文件夹的结果操作。 +- 低于 2.4.0,或真实 ZTools 宿主无法提供可比较版本号:显示升级提示。仅未注入 `window.ztools` 的浏览器开发预览放行。 + ## 首批能力 - 选择多个 PDF 并真实合并为一个 PDF。 diff --git a/plugins/pdf-office-workbench/index.html b/plugins/pdf-office-workbench/index.html index b6b26cb76..549e83415 100644 --- a/plugins/pdf-office-workbench/index.html +++ b/plugins/pdf-office-workbench/index.html @@ -123,7 +123,16 @@

文件队列

import { extractInvoiceFields } from './src/core/index.mjs'; const services = window.services; - const state = { files: [], lastPath: '' }; + const compatibility = !window.ztools + ? { supported: true } + : (() => { + try { return services?.hostCompatibility?.() || { supported: false }; } + catch (_) { return { supported: false }; } + })(); + if (!compatibility.supported) { + document.querySelector('main').innerHTML = '

需要升级 ZTools

当前 ZTools 版本过低或无法识别(最低支持 2.4.0)。为了获得更完整、稳定的体验,请升级后再使用 PDF 办公工坊。

'; + } else { + const state = { files: [], lastPath: '', outputPaths: [] }; const $ = (selector) => document.querySelector(selector); const output = $('#output'); const status = $('#status'); @@ -146,6 +155,11 @@

文件队列

status.className = `status${isError ? ' error' : ''}`; } + function outputPaths(value) { + const entries = Array.isArray(value) ? value : [value]; + return entries.map((entry) => entry?.path).filter((entry) => typeof entry === 'string' && entry.length > 0); + } + function render() { const files = $('#files'); $('#summary').textContent = state.files.length @@ -184,6 +198,7 @@

文件队列

try { const result = await task(); state.lastPath = Array.isArray(result) ? result.at(-1)?.path || '' : result?.path || ''; + state.outputPaths = outputPaths(result); if (state.lastPath) $('#open-result').disabled = false; show(result); setStatus('完成'); @@ -214,9 +229,15 @@

文件队列

}); $('#invoice').addEventListener('click', () => { show(extractInvoiceFields($('#invoice-input').value)); setStatus('字段提取完成'); }); $('#open-result').addEventListener('click', () => state.lastPath && services.reveal(state.lastPath)); + $('#open-result').draggable = services.canStartDrag?.() || false; + $('#open-result').addEventListener('dragstart', (event) => { + event.preventDefault(); + if (state.outputPaths.length) services.startDrag(state.outputPaths).catch((error) => setStatus(error?.message || String(error), true)); + }); window.addEventListener('pdf-office-enter', event => addFiles(event.detail.files)); render(); + } diff --git a/plugins/pdf-office-workbench/package.json b/plugins/pdf-office-workbench/package.json index f279ea7ce..f7a3b61af 100644 --- a/plugins/pdf-office-workbench/package.json +++ b/plugins/pdf-office-workbench/package.json @@ -1,6 +1,6 @@ { "name": "ztools-pdf-office-workbench", - "version": "0.2.0", + "version": "0.2.1", "private": true, "type": "module", "description": "ZTools PDF and office document workflow plugin.", diff --git a/plugins/pdf-office-workbench/plugin.json b/plugins/pdf-office-workbench/plugin.json index 795ddccf9..754ec47f2 100644 --- a/plugins/pdf-office-workbench/plugin.json +++ b/plugins/pdf-office-workbench/plugin.json @@ -3,7 +3,7 @@ "title": "PDF 办公工坊", "description": "本地 PDF 和票据处理工作台:真实合并拆分、批量重命名、票据字段提取和批次汇总。", "author": "harris", - "version": "0.2.0", + "version": "0.2.1", "main": "index.html", "preload": "preload/services.js", "logo": "logo.svg", diff --git a/plugins/pdf-office-workbench/preload/file-drag-grants.js b/plugins/pdf-office-workbench/preload/file-drag-grants.js new file mode 100644 index 000000000..1becd4a6a --- /dev/null +++ b/plugins/pdf-office-workbench/preload/file-drag-grants.js @@ -0,0 +1,60 @@ +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('只能拖出刚刚由插件生成的 PDF 文件。') + } + canonical.forEach(filePath => grants.delete(filePath)) + return canonical + } + } +} + +module.exports = { DEFAULT_TTL_MS, createFileDragGrantStore } diff --git a/plugins/pdf-office-workbench/preload/services.js b/plugins/pdf-office-workbench/preload/services.js index cc9d0502c..e415a0355 100644 --- a/plugins/pdf-office-workbench/preload/services.js +++ b/plugins/pdf-office-workbench/preload/services.js @@ -2,6 +2,7 @@ const fs = require('node:fs') const os = require('node:os') const path = require('node:path') const { PDFDocument } = require('pdf-lib') +const { createFileDragGrantStore } = require('./file-drag-grants.js') let shell try { @@ -10,6 +11,38 @@ try { shell = null } +const MINIMUM_VERSION = '2.4.0' +const dragGrants = createFileDragGrantStore({ fs, path, requiredExtension: '.pdf' }) + +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 isSupportedHost() { + 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 } +} + function hostPath(name, fallback) { try { const value = window.ztools?.getPath?.(name) @@ -128,6 +161,7 @@ async function mergePdfs(paths, outputPath) { } fs.mkdirSync(path.dirname(output), { recursive: true }) fs.writeFileSync(output, await merged.save()) + dragGrants.grant(output) return { path: output, pages: totalPages, size: fs.statSync(output).size } } @@ -154,6 +188,7 @@ async function splitPdf(sourcePath, expression, outputDirectory) { fs.writeFileSync(output, await document.save()) outputs.push({ path: output, pages: pages.length, size: fs.statSync(output).size }) } + outputs.forEach(item => dragGrants.grant(item.path)) return outputs } @@ -179,6 +214,7 @@ async function renameFiles(paths, template) { for (const operation of operations) { if (operation.source !== operation.target) fs.renameSync(operation.source, operation.target) } + operations.forEach(operation => dragGrants.grant(operation.target)) return operations.map(operation => ({ path: operation.target, name: path.basename(operation.target) })) } @@ -195,7 +231,17 @@ const services = { reveal(filePath) { if (shell?.showItemInFolder) shell.showItemInFolder(filePath) }, + hostCompatibility: isSupportedHost, + 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(normalizePaths(paths)) + await Promise.resolve(window.ztools.startDrag(values.length === 1 ? values[0] : values)) + }, async handlePluginEnter(action) { + if (!isSupportedHost().supported) return const paths = normalizePaths(action?.payload) if (!paths.length) return const files = [] @@ -212,8 +258,12 @@ const services = { window.services = services -window.ztools?.onPluginEnter?.(action => { - services.handlePluginEnter(action).catch(error => { - window.ztools?.showNotification?.(error instanceof Error ? error.message : String(error)) +if (isSupportedHost().supported) { + window.ztools?.onPluginEnter?.(action => { + services.handlePluginEnter(action).catch(error => { + window.ztools?.showNotification?.(error instanceof Error ? error.message : String(error)) + }) }) -}) +} + +module.exports = { MINIMUM_VERSION, parseVersion, isSupportedHost, normalizePaths, parsePageRanges, services } diff --git a/plugins/pdf-office-workbench/tests/file-drag-grants.test.mjs b/plugins/pdf-office-workbench/tests/file-drag-grants.test.mjs new file mode 100644 index 000000000..8859ca4ea --- /dev/null +++ b/plugins/pdf-office-workbench/tests/file-drag-grants.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { createFileDragGrantStore } = require('../preload/file-drag-grants.js'); + +test('PDF output drag grants expire', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-office-drag-expiry-')); + const output = path.join(root, 'generated.pdf'); + fs.writeFileSync(output, 'pdf'); + let currentTime = 100; + const grants = createFileDragGrantStore({ + fs, + path, + now: () => currentTime, + ttlMs: 50, + requiredExtension: '.pdf' + }); + + try { + grants.grant(output); + currentTime = 151; + assert.throws(() => grants.consume(output), /刚刚由插件生成/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/plugins/pdf-office-workbench/tests/file-operations.test.mjs b/plugins/pdf-office-workbench/tests/file-operations.test.mjs index 16f851035..74d5d4477 100644 --- a/plugins/pdf-office-workbench/tests/file-operations.test.mjs +++ b/plugins/pdf-office-workbench/tests/file-operations.test.mjs @@ -19,7 +19,8 @@ async function makePdf(filePath, pageCount) { test('preload services merge, split and rename real PDF files', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-office-workbench-')); const previousWindow = globalThis.window; - globalThis.window = { ztools: {} }; + const dragged = []; + globalThis.window = { ztools: { getAppVersion: () => '3.2.0', startDrag: value => dragged.push(value) } }; try { require('../preload/services.js'); const serviceModule = globalThis.window.services; @@ -28,12 +29,17 @@ test('preload services merge, split and rename real PDF files', async () => { await makePdf(first, 2); await makePdf(second, 1); + await assert.rejects(serviceModule.startDrag(first), /刚刚由插件生成/); + const firstInfo = await serviceModule.readPdfInfo(first); assert.equal(firstInfo.pages, 2); const merged = await serviceModule.mergePdfs([first, second], path.join(root, 'merged.pdf')); assert.equal(merged.pages, 3); assert.equal((await serviceModule.readPdfInfo(merged.path)).pages, 3); + await serviceModule.startDrag(merged.path); + assert.deepEqual(dragged, [await fs.realpath(merged.path)]); + await assert.rejects(serviceModule.startDrag(merged.path), /刚刚由插件生成/); const split = await serviceModule.splitPdf(merged.path, '1-2,3', root); assert.deepEqual(split.map(item => item.pages), [2, 1]); @@ -42,8 +48,44 @@ test('preload services merge, split and rename real PDF files', async () => { const renamed = await serviceModule.renameFiles([split[0].path, split[1].path], 'invoice-{index}'); assert.deepEqual(renamed.map(item => item.name), ['invoice-1.pdf', 'invoice-2.pdf']); assert.equal((await serviceModule.readPdfInfo(renamed[1].path)).pages, 1); + await serviceModule.startDrag(renamed.map(item => item.path)); + assert.deepEqual(dragged[1], await Promise.all(renamed.map(item => fs.realpath(item.path)))); + await assert.rejects(serviceModule.startDrag(renamed.map(item => item.path)), /刚刚由插件生成/); } finally { globalThis.window = previousWindow; await fs.rm(root, { recursive: true, force: true }); } }); + +test('host version gate only bypasses an explicit browser preview', async () => { + const previousWindow = globalThis.window; + globalThis.window = {}; + try { + const module = require('../preload/services.js'); + assert.equal(module.isSupportedHost().supported, true); + globalThis.window.ztools = {}; + assert.equal(module.isSupportedHost().supported, false); + const throwingGetter = {}; + Object.defineProperty(throwingGetter, 'getAppVersion', { get() { throw new Error('unavailable'); } }); + globalThis.window.ztools = throwingGetter; + assert.equal(module.isSupportedHost().supported, false); + globalThis.window.ztools = { getAppVersion: () => { throw new Error('unavailable'); } }; + assert.equal(module.isSupportedHost().supported, false); + for (const version of ['', 'unknown', 320]) { + globalThis.window.ztools = { getAppVersion: () => version }; + assert.equal(module.isSupportedHost().supported, false); + } + globalThis.window.ztools = { getAppVersion: () => '2.3.9' }; + assert.equal(module.isSupportedHost().supported, false); + globalThis.window.ztools = { getAppVersion: () => '2.4.0-beta.1' }; + assert.equal(module.isSupportedHost().supported, false); + globalThis.window.ztools = { getAppVersion: () => '2.4.0' }; + assert.equal(module.isSupportedHost().supported, true); + globalThis.window.ztools = { getAppVersion: () => '3.1.9' }; + assert.equal(module.isSupportedHost().supported, true); + assert.deepEqual(module.parseVersion('3.2'), [3, 2, 0]); + assert.equal(module.parseVersion('ZTools 3.2.0'), null); + } finally { + globalThis.window = previousWindow; + } +});