file-toolkit-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/pdf.js ADDED
@@ -0,0 +1,328 @@
1
+ /**
2
+ * PDF 处理:拆分、合并、转图片。
3
+ *
4
+ * 拆分 / 合并用 pdf-lib(纯 JS,MIT,零系统依赖)。
5
+ * 转图片用 pdfjs-dist + @napi-rs/canvas(均为 Apache-2.0 / MIT,带预编译二进制)。
6
+ *
7
+ * 刻意不用 ghostscript / poppler / ImageMagick:
8
+ * 用户机器上不一定装了它们,而且它们在不同平台上名字和参数都不一样。
9
+ */
10
+
11
+ import fs from 'node:fs/promises';
12
+ import { PDFDocument } from 'pdf-lib';
13
+ import { ToolError, invalid } from './errors.js';
14
+
15
+ /**
16
+ * 解析页码范围表达式。
17
+ * 支持 "1-3,5,7-9"、"2-"(第 2 页到末页)、"-3"(前 3 页)。
18
+ *
19
+ * @param {string|number[]} spec
20
+ * @param {number} totalPages
21
+ * @returns {number[]} 从 1 开始的页号数组,已去重并升序
22
+ */
23
+ export function parsePageRanges(spec, totalPages) {
24
+ if (Array.isArray(spec)) {
25
+ const pages = spec
26
+ .map((n) => Number(n))
27
+ .filter((n) => Number.isInteger(n) && n >= 1 && n <= totalPages);
28
+ return [...new Set(pages)].sort((a, b) => a - b);
29
+ }
30
+
31
+ const text = String(spec ?? '').trim();
32
+ if (text === '') {
33
+ throw invalid('页码范围是空的', '例如 "1-3,5" 表示第 1 到 3 页和第 5 页。');
34
+ }
35
+
36
+ const pages = new Set();
37
+ for (const chunk of text.split(/[,,、\s]+/).filter(Boolean)) {
38
+ const match = chunk.match(/^(\d*)\s*[-~至]\s*(\d*)$/);
39
+ if (match) {
40
+ const start = match[1] === '' ? 1 : Number(match[1]);
41
+ const end = match[2] === '' ? totalPages : Number(match[2]);
42
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start > end) {
43
+ throw invalid(`页码范围写错了:${chunk}`, '正确写法如 "1-3" 或 "5"。');
44
+ }
45
+ for (let i = start; i <= end; i += 1) {
46
+ if (i >= 1 && i <= totalPages) pages.add(i);
47
+ }
48
+ continue;
49
+ }
50
+ const single = Number(chunk);
51
+ if (Number.isInteger(single) && single >= 1 && single <= totalPages) {
52
+ pages.add(single);
53
+ continue;
54
+ }
55
+ throw invalid(
56
+ `页码 "${chunk}" 超出范围`,
57
+ `这个 PDF 一共 ${totalPages} 页,页码必须在 1 到 ${totalPages} 之间。`,
58
+ );
59
+ }
60
+
61
+ if (pages.size === 0) {
62
+ throw invalid('没有解析出任何有效页码', `这个 PDF 一共 ${totalPages} 页。`);
63
+ }
64
+ return [...pages].sort((a, b) => a - b);
65
+ }
66
+
67
+ /** 读取 PDF 页数等信息 */
68
+ export async function readPdfMeta(file) {
69
+ const bytes = await fs.readFile(file);
70
+ let doc;
71
+ try {
72
+ doc = await PDFDocument.load(bytes, { updateMetadata: false });
73
+ } catch (error) {
74
+ const message = String(error?.message ?? error);
75
+ if (/encrypt/i.test(message)) {
76
+ throw new ToolError('这个 PDF 有密码保护,无法直接处理', {
77
+ hint: '请先用 PDF 阅读器解除密码,再重新尝试。',
78
+ });
79
+ }
80
+ throw new ToolError('无法读取这个 PDF', {
81
+ hint: '文件可能已损坏,或者它并不是标准 PDF 文件。',
82
+ });
83
+ }
84
+ return {
85
+ pages: doc.getPageCount(),
86
+ title: doc.getTitle() ?? null,
87
+ author: doc.getAuthor() ?? null,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * 合并多个 PDF,按传入顺序拼装。
93
+ * @returns {Promise<Buffer>}
94
+ */
95
+ export async function mergePdfs(files) {
96
+ if (files.length < 2) {
97
+ throw invalid('合并至少需要 2 个 PDF 文件', '请再选一个 PDF 一起合并。');
98
+ }
99
+
100
+ const merged = await PDFDocument.create();
101
+
102
+ for (const file of files) {
103
+ const bytes = await fs.readFile(file);
104
+ let doc;
105
+ try {
106
+ doc = await PDFDocument.load(bytes, { updateMetadata: false });
107
+ } catch (error) {
108
+ const message = String(error?.message ?? error);
109
+ if (/encrypt/i.test(message)) {
110
+ throw new ToolError(`${file} 有密码保护,无法合并`, {
111
+ hint: '请先解除这个文件的密码。',
112
+ });
113
+ }
114
+ throw new ToolError(`${file} 无法读取`, {
115
+ hint: '文件可能已损坏,或者它不是标准 PDF。',
116
+ });
117
+ }
118
+ const pages = await merged.copyPages(doc, doc.getPageIndices());
119
+ for (const page of pages) merged.addPage(page);
120
+ }
121
+
122
+ return Buffer.from(await merged.save());
123
+ }
124
+
125
+ /**
126
+ * 拆分 PDF。
127
+ *
128
+ * @param {string} file
129
+ * @param {{ mode: 'each'|'range'|'chunk', pages?: string, chunkSize?: number }} options
130
+ * @returns {Promise<Array<{ name: string, buffer: Buffer, pageCount: number }>>}
131
+ */
132
+ export async function splitPdf(file, options = {}) {
133
+ const { mode = 'each', pages: pagesSpec, chunkSize = 1 } = options;
134
+
135
+ const bytes = await fs.readFile(file);
136
+ let src;
137
+ try {
138
+ src = await PDFDocument.load(bytes, { updateMetadata: false });
139
+ } catch (error) {
140
+ const message = String(error?.message ?? error);
141
+ if (/encrypt/i.test(message)) {
142
+ throw new ToolError('这个 PDF 有密码保护,无法拆分', {
143
+ hint: '请先解除密码再试。',
144
+ });
145
+ }
146
+ throw new ToolError('无法读取这个 PDF', {
147
+ hint: '文件可能已损坏,或者它不是标准 PDF 文件。',
148
+ });
149
+ }
150
+
151
+ const total = src.getPageCount();
152
+ const outputs = [];
153
+
154
+ if (mode === 'each') {
155
+ for (let i = 0; i < total; i += 1) {
156
+ const doc = await PDFDocument.create();
157
+ const [page] = await doc.copyPages(src, [i]);
158
+ doc.addPage(page);
159
+ outputs.push({
160
+ name: `page-${String(i + 1).padStart(3, '0')}`,
161
+ buffer: Buffer.from(await doc.save()),
162
+ pageCount: 1,
163
+ fromPage: i + 1,
164
+ toPage: i + 1,
165
+ });
166
+ }
167
+ return outputs;
168
+ }
169
+
170
+ if (mode === 'range') {
171
+ const wanted = parsePageRanges(pagesSpec, total);
172
+ const doc = await PDFDocument.create();
173
+ const copied = await doc.copyPages(src, wanted.map((p) => p - 1));
174
+ for (const page of copied) doc.addPage(page);
175
+ outputs.push({
176
+ name: `range-${wanted[0]}-${wanted[wanted.length - 1]}`,
177
+ buffer: Buffer.from(await doc.save()),
178
+ pageCount: wanted.length,
179
+ fromPage: wanted[0],
180
+ toPage: wanted[wanted.length - 1],
181
+ pages: wanted,
182
+ });
183
+ return outputs;
184
+ }
185
+
186
+ if (mode === 'chunk') {
187
+ const size = Number(chunkSize);
188
+ if (!Number.isInteger(size) || size < 1) {
189
+ throw invalid('每组页数必须是大于 0 的整数', '例如 5 表示每 5 页存成一个 PDF。');
190
+ }
191
+ let index = 0;
192
+ for (let start = 0; start < total; start += size) {
193
+ const indices = [];
194
+ for (let i = start; i < Math.min(start + size, total); i += 1) indices.push(i);
195
+ const doc = await PDFDocument.create();
196
+ const copied = await doc.copyPages(src, indices);
197
+ for (const page of copied) doc.addPage(page);
198
+ index += 1;
199
+ outputs.push({
200
+ name: `part-${String(index).padStart(3, '0')}`,
201
+ buffer: Buffer.from(await doc.save()),
202
+ pageCount: indices.length,
203
+ fromPage: indices[0] + 1,
204
+ toPage: indices[indices.length - 1] + 1,
205
+ });
206
+ }
207
+ return outputs;
208
+ }
209
+
210
+ throw invalid(`不支持的拆分方式:${mode}`, '可选 each(逐页)、range(指定页码)、chunk(每 N 页)。');
211
+ }
212
+
213
+ let pdfjsPromise = null;
214
+
215
+ /**
216
+ * 惰性加载 pdfjs 与 canvas。
217
+ * 这两者只有在真正需要把 PDF 转成图片时才加载,避免拖慢服务启动。
218
+ */
219
+ async function loadPdfJs() {
220
+ if (!pdfjsPromise) {
221
+ pdfjsPromise = (async () => {
222
+ const canvas = await import('@napi-rs/canvas');
223
+ // pdfjs 在 Node 环境里会用到这些浏览器全局对象
224
+ globalThis.DOMMatrix ??= canvas.DOMMatrix;
225
+ globalThis.ImageData ??= canvas.ImageData;
226
+ globalThis.Path2D ??= canvas.Path2D;
227
+ const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs');
228
+ return { pdfjs, canvas };
229
+ })();
230
+ }
231
+ return pdfjsPromise;
232
+ }
233
+
234
+ /**
235
+ * 把 PDF 的指定页面渲染成图片。
236
+ *
237
+ * @param {string} file
238
+ * @param {{ pages?: string|number[], dpi?: number, format?: 'png'|'jpg' }} options
239
+ * @returns {Promise<Array<{ name: string, buffer: Buffer, width: number, height: number, page: number }>>}
240
+ */
241
+ export async function pdfToImages(file, options = {}) {
242
+ const { pages: pagesSpec = null, dpi = 150, format = 'png' } = options;
243
+
244
+ const targetFormat = String(format).toLowerCase().replace(/^\./, '');
245
+ if (!['png', 'jpg', 'jpeg'].includes(targetFormat)) {
246
+ throw invalid(`PDF 只能转成 png 或 jpg,收到的是:${format}`);
247
+ }
248
+ const wanted = targetFormat === 'png' ? 'png' : 'jpeg';
249
+
250
+ const dpiNum = Number(dpi);
251
+ if (!Number.isFinite(dpiNum) || dpiNum < 36 || dpiNum > 600) {
252
+ throw invalid('分辨率(dpi)需要在 36 到 600 之间', '常用值是 72(屏幕)、150(普通)、300(印刷)。');
253
+ }
254
+
255
+ const { pdfjs, canvas } = await loadPdfJs();
256
+ const data = new Uint8Array(await fs.readFile(file));
257
+
258
+ let loadingTask;
259
+ let doc;
260
+ try {
261
+ loadingTask = pdfjs.getDocument({
262
+ data,
263
+ useSystemFonts: true,
264
+ isEvalSupported: false,
265
+ disableFontFace: true,
266
+ });
267
+ doc = await loadingTask.promise;
268
+ } catch (error) {
269
+ const message = String(error?.message ?? error);
270
+ if (/password/i.test(message)) {
271
+ throw new ToolError('这个 PDF 有密码保护,无法转成图片', {
272
+ hint: '请先解除密码再试。',
273
+ });
274
+ }
275
+ throw new ToolError('无法读取这个 PDF', {
276
+ hint: '文件可能已损坏,或者它不是标准 PDF 文件。',
277
+ });
278
+ }
279
+
280
+ try {
281
+ const total = doc.numPages;
282
+ const pageNumbers = pagesSpec
283
+ ? parsePageRanges(pagesSpec, total)
284
+ : Array.from({ length: total }, (_, i) => i + 1);
285
+
286
+ const scale = dpiNum / 72;
287
+ const results = [];
288
+
289
+ for (const pageNumber of pageNumbers) {
290
+ const page = await doc.getPage(pageNumber);
291
+ const viewport = page.getViewport({ scale });
292
+ const target = canvas.createCanvas(
293
+ Math.max(1, Math.ceil(viewport.width)),
294
+ Math.max(1, Math.ceil(viewport.height)),
295
+ );
296
+ const ctx = target.getContext('2d');
297
+ ctx.fillStyle = '#ffffff';
298
+ ctx.fillRect(0, 0, target.width, target.height);
299
+
300
+ await page.render({ canvasContext: ctx, viewport }).promise;
301
+
302
+ const buffer = wanted === 'png'
303
+ ? target.toBuffer('image/png')
304
+ : target.toBuffer('image/jpeg', 92);
305
+
306
+ results.push({
307
+ name: `page-${String(pageNumber).padStart(3, '0')}`,
308
+ buffer,
309
+ width: target.width,
310
+ height: target.height,
311
+ page: pageNumber,
312
+ });
313
+
314
+ page.cleanup();
315
+ }
316
+
317
+ return results;
318
+ } finally {
319
+ // pdfjs 各版本清理接口不一致,逐个尝试,清理失败不能影响已经拿到的结果
320
+ try {
321
+ if (typeof loadingTask?.destroy === 'function') await loadingTask.destroy();
322
+ else if (typeof doc?.destroy === 'function') await doc.destroy();
323
+ else if (typeof doc?.cleanup === 'function') await doc.cleanup();
324
+ } catch {
325
+ // 忽略清理阶段的异常
326
+ }
327
+ }
328
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 工具注册器。
3
+ *
4
+ * 所有工具共用一套外壳,保证:
5
+ * - 成功时统一返回 Markdown 文本
6
+ * - 失败时统一把异常翻译成自然语言,并且带上 isError 标记
7
+ * - 工具都声明 openWorldHint: false(明确告诉客户端:这个连接器不访问外网)
8
+ */
9
+
10
+ import { describeError } from './errors.js';
11
+
12
+ export function defineTool(server, config) {
13
+ const {
14
+ name,
15
+ title,
16
+ description,
17
+ inputSchema,
18
+ annotations = {},
19
+ action,
20
+ handler,
21
+ } = config;
22
+
23
+ server.registerTool(
24
+ name,
25
+ {
26
+ title,
27
+ description,
28
+ inputSchema,
29
+ annotations: {
30
+ readOnlyHint: false,
31
+ destructiveHint: false,
32
+ idempotentHint: true,
33
+ openWorldHint: false,
34
+ ...annotations,
35
+ },
36
+ },
37
+ async (args) => {
38
+ try {
39
+ const text = await handler(args ?? {});
40
+ return { content: [{ type: 'text', text }] };
41
+ } catch (error) {
42
+ if (process.env.FILE_TOOLKIT_DEBUG) {
43
+ console.error(`[${name}]`, error);
44
+ }
45
+ const message = describeError(error, { action });
46
+ return {
47
+ isError: true,
48
+ content: [{ type: 'text', text: `⚠️ ${message}` }],
49
+ };
50
+ }
51
+ },
52
+ );
53
+ }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * 批量重命名。
3
+ *
4
+ * 安全设计(这是本工具和「让模型自己写脚本」最大的区别):
5
+ * 1. 默认只预览,不执行。必须显式传 confirm=true 才真的改名。
6
+ * 2. 预览阶段就检测冲突:目标重名、目标已存在、与本次其他文件撞车。
7
+ * 3. 执行采用两阶段(先改临时名、再改目标名),避免 a→b、b→a 这类互换死锁。
8
+ * 4. 全过程写日志,可用 rename_undo 一键回滚。
9
+ */
10
+
11
+ import path from 'node:path';
12
+ import fs from 'node:fs/promises';
13
+ import { invalid } from './errors.js';
14
+
15
+ export const RENAME_LOG_PREFIX = 'rename-log-';
16
+
17
+ const TEMP_PREFIX = '.file-toolkit-tmp-';
18
+
19
+ /** 把 pattern 里的占位符替换掉:{n}、{n:3}、{name}、{ext} */
20
+ function renderPattern(pattern, { index, name, ext }) {
21
+ return String(pattern)
22
+ .replace(/\{n(?::(\d+))?\}/gi, (_, pad) =>
23
+ String(index).padStart(pad ? Number(pad) : 1, '0'),
24
+ )
25
+ .replace(/\{name\}/gi, name)
26
+ .replace(/\{ext\}/gi, ext);
27
+ }
28
+
29
+ /**
30
+ * 计算单个文件的新文件名。
31
+ *
32
+ * 处理顺序:
33
+ * 原始基础名 -> find/replace 替换 -> pattern 重写基础名 -> 加前后缀 -> 换扩展名
34
+ */
35
+ export function buildNewName(filePath, options, index) {
36
+ const {
37
+ pattern = null,
38
+ prefix = '',
39
+ suffix = '',
40
+ find = null,
41
+ replace = '',
42
+ newExtension = null,
43
+ } = options;
44
+
45
+ const ext = path.extname(filePath);
46
+ const extName = ext.replace(/^\./, '');
47
+ const baseName = path.basename(filePath, ext);
48
+
49
+ let nextBase = baseName;
50
+ if (find !== null && find !== undefined && String(find) !== '') {
51
+ nextBase = nextBase.split(String(find)).join(String(replace ?? ''));
52
+ }
53
+ if (pattern) {
54
+ nextBase = renderPattern(pattern, { index, name: nextBase, ext: extName });
55
+ }
56
+ nextBase = `${prefix ?? ''}${nextBase}${suffix ?? ''}`;
57
+
58
+ const finalExt = newExtension
59
+ ? `.${String(newExtension).replace(/^\./, '')}`
60
+ : ext;
61
+
62
+ const nextName = `${nextBase}${finalExt}`;
63
+
64
+ return {
65
+ from: path.basename(filePath),
66
+ to: nextName,
67
+ directory: path.dirname(filePath),
68
+ fromPath: path.resolve(filePath),
69
+ toPath: path.join(path.dirname(filePath), nextName),
70
+ };
71
+ }
72
+
73
+ async function exists(p) {
74
+ try {
75
+ await fs.access(p);
76
+ return true;
77
+ } catch {
78
+ return false;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 生成重命名方案,并标注每个文件的状态。
84
+ *
85
+ * status:
86
+ * ready 可以执行
87
+ * unchanged 新名字和原名一样,跳过
88
+ * conflict 有冲突,会被跳过(conflictReason 说明原因)
89
+ */
90
+ export async function planRename(files, options = {}) {
91
+ const startNumber = Number.isFinite(options.startNumber) ? Number(options.startNumber) : 1;
92
+ const sourceSet = new Set(files.map((f) => path.resolve(f)));
93
+ const plannedTargets = new Map();
94
+
95
+ const items = [];
96
+ let index = startNumber;
97
+
98
+ for (const file of files) {
99
+ const item = buildNewName(file, options, index);
100
+ index += 1;
101
+
102
+ let status = 'ready';
103
+ let conflictReason = '';
104
+
105
+ if (item.toPath === item.fromPath) {
106
+ status = 'unchanged';
107
+ } else if (plannedTargets.has(item.toPath)) {
108
+ status = 'conflict';
109
+ conflictReason = '和本次的另一个文件重名';
110
+ } else if (sourceSet.has(item.toPath)) {
111
+ // 目标正好是本次要改名的另一个文件 —— 靠两阶段执行可以安全处理
112
+ status = 'ready';
113
+ } else if (await exists(item.toPath)) {
114
+ status = 'conflict';
115
+ conflictReason = '同名文件已存在';
116
+ }
117
+
118
+ if (status === 'ready') plannedTargets.set(item.toPath, item.fromPath);
119
+
120
+ items.push({ ...item, status, conflictReason });
121
+ }
122
+
123
+ return items;
124
+ }
125
+
126
+ /**
127
+ * 执行重命名。
128
+ *
129
+ * @returns {Promise<{ done: object[], skipped: object[], logPath: string|null }>}
130
+ */
131
+ export async function executeRename(items, { logDirectory }) {
132
+ const runnable = items.filter((i) => i.status === 'ready');
133
+ const skipped = items.filter((i) => i.status !== 'ready');
134
+
135
+ if (runnable.length === 0) {
136
+ return { done: [], skipped, logPath: null };
137
+ }
138
+
139
+ const stamp = Date.now();
140
+ const moved = [];
141
+
142
+ // 阶段一:全部改成临时名,彻底消除命名冲突
143
+ try {
144
+ for (let i = 0; i < runnable.length; i += 1) {
145
+ const item = runnable[i];
146
+ const tmpPath = path.join(item.directory, `${TEMP_PREFIX}${stamp}-${i}`);
147
+ await fs.rename(item.fromPath, tmpPath);
148
+ moved.push({ ...item, tmpPath });
149
+ }
150
+ } catch (error) {
151
+ // 阶段一失败:把已经改名的还原回去
152
+ for (const m of moved.reverse()) {
153
+ try {
154
+ await fs.rename(m.tmpPath, m.fromPath);
155
+ } catch {
156
+ // 还原失败只能跳过,继续还原其余的
157
+ }
158
+ }
159
+ throw error;
160
+ }
161
+
162
+ // 阶段二:临时名 -> 目标名
163
+ const done = [];
164
+ try {
165
+ for (const item of moved) {
166
+ await fs.rename(item.tmpPath, item.toPath);
167
+ done.push(item);
168
+ }
169
+ } catch (error) {
170
+ // 阶段二失败:尽力还原
171
+ for (const item of done) {
172
+ try {
173
+ await fs.rename(item.toPath, item.fromPath);
174
+ } catch {
175
+ // 忽略
176
+ }
177
+ }
178
+ for (const item of moved) {
179
+ try {
180
+ await fs.rename(item.tmpPath, item.fromPath);
181
+ } catch {
182
+ // 忽略
183
+ }
184
+ }
185
+ throw error;
186
+ }
187
+
188
+ // 写回滚日志
189
+ await fs.mkdir(logDirectory, { recursive: true });
190
+ const logPath = path.join(logDirectory, `${RENAME_LOG_PREFIX}${stamp}.json`);
191
+ await fs.writeFile(
192
+ logPath,
193
+ JSON.stringify(
194
+ {
195
+ createdAt: new Date().toISOString(),
196
+ entries: done.map((d) => ({ from: d.fromPath, to: d.toPath })),
197
+ },
198
+ null,
199
+ 2,
200
+ ),
201
+ 'utf8',
202
+ );
203
+
204
+ return { done, skipped, logPath };
205
+ }
206
+
207
+ /**
208
+ * 找到最近一次的改名日志。
209
+ */
210
+ export async function findLatestLog(logDirectory, explicitPath = null) {
211
+ if (explicitPath) {
212
+ return path.resolve(explicitPath);
213
+ }
214
+ let entries = [];
215
+ try {
216
+ entries = await fs.readdir(logDirectory);
217
+ } catch {
218
+ return null;
219
+ }
220
+ const logs = entries
221
+ .filter((name) => name.startsWith(RENAME_LOG_PREFIX) && name.endsWith('.json'))
222
+ .sort();
223
+ if (logs.length === 0) return null;
224
+ return path.join(logDirectory, logs[logs.length - 1]);
225
+ }
226
+
227
+ /**
228
+ * 回滚一次重命名。
229
+ */
230
+ export async function undoRename(logPath) {
231
+ const raw = await fs.readFile(logPath, 'utf8');
232
+ let log;
233
+ try {
234
+ log = JSON.parse(raw);
235
+ } catch {
236
+ throw invalid('这个回滚记录文件已损坏,无法解析', `路径:${logPath}`);
237
+ }
238
+
239
+ const entries = Array.isArray(log?.entries) ? log.entries : [];
240
+ if (entries.length === 0) {
241
+ return { restored: [], missing: [] };
242
+ }
243
+
244
+ const restored = [];
245
+ const missing = [];
246
+ const moved = [];
247
+
248
+ // 同样两阶段,避免反向也出现互换冲突
249
+ const usable = [];
250
+ for (const entry of entries) {
251
+ if (await exists(entry.to)) {
252
+ usable.push(entry);
253
+ } else {
254
+ missing.push(entry);
255
+ }
256
+ }
257
+
258
+ for (let i = 0; i < usable.length; i += 1) {
259
+ const entry = usable[i];
260
+ const dir = path.dirname(entry.to);
261
+ const tmpPath = path.join(dir, `${TEMP_PREFIX}undo-${Date.now()}-${i}`);
262
+ await fs.rename(entry.to, tmpPath);
263
+ moved.push({ entry, tmpPath });
264
+ }
265
+
266
+ for (const m of moved) {
267
+ const target = m.entry.from;
268
+ // 原名字已经被占用时,退让为一个带后缀的备份名,绝不覆盖
269
+ let finalTarget = target;
270
+ if (await exists(target)) {
271
+ const ext = path.extname(target);
272
+ const base = path.basename(target, ext);
273
+ finalTarget = path.join(path.dirname(target), `${base}.restored${ext}`);
274
+ let counter = 1;
275
+ while (await exists(finalTarget)) {
276
+ counter += 1;
277
+ finalTarget = path.join(path.dirname(target), `${base}.restored-${counter}${ext}`);
278
+ }
279
+ }
280
+ await fs.rename(m.tmpPath, finalTarget);
281
+ restored.push({ from: m.entry.to, to: finalTarget });
282
+ }
283
+
284
+ return { restored, missing };
285
+ }