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.
@@ -0,0 +1,331 @@
1
+ /**
2
+ * 图片处理:压缩、格式转换、尺寸调整。
3
+ *
4
+ * 全部走 sharp(libvips,Apache-2.0),不需要用户安装 ImageMagick,
5
+ * 也不需要联网,所有编解码都在本机完成。
6
+ */
7
+
8
+ import path from 'node:path';
9
+ import fs from 'node:fs/promises';
10
+ import sharp from 'sharp';
11
+ import { ToolError, assertThat, invalid } from './errors.js';
12
+
13
+ /** 本项目支持的图片格式 */
14
+ export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
15
+
16
+ const FORMAT_ALIASES = {
17
+ jpg: 'jpeg',
18
+ jpeg: 'jpeg',
19
+ jpe: 'jpeg',
20
+ png: 'png',
21
+ webp: 'webp',
22
+ };
23
+
24
+ const EXT_FOR_FORMAT = {
25
+ jpeg: 'jpg',
26
+ png: 'png',
27
+ webp: 'webp',
28
+ };
29
+
30
+ /**
31
+ * 归一化格式名。传入 "JPG"/"jpeg"/".png" 都能正确识别。
32
+ */
33
+ export function normalizeFormat(input) {
34
+ const key = String(input ?? '').trim().toLowerCase().replace(/^\./, '');
35
+ const format = FORMAT_ALIASES[key];
36
+ assertThat(
37
+ format,
38
+ `不支持的目标格式:${input}`,
39
+ '目前支持 jpg、png、webp 三种。',
40
+ );
41
+ return format;
42
+ }
43
+
44
+ function openImage(file) {
45
+ // failOn: 'error' —— 容忍部分 jpg 尾部的垃圾字节,但真正的损坏仍然报错
46
+ return sharp(file, { failOn: 'error' });
47
+ }
48
+
49
+ /** 读取图片基本信息 */
50
+ export async function readImageMeta(file) {
51
+ const meta = await openImage(file).metadata();
52
+ return {
53
+ width: meta.width ?? null,
54
+ height: meta.height ?? null,
55
+ format: meta.format ?? null,
56
+ channels: meta.channels ?? null,
57
+ hasAlpha: Boolean(meta.hasAlpha),
58
+ };
59
+ }
60
+
61
+ function encode(pipeline, { format, quality }) {
62
+ switch (format) {
63
+ case 'jpeg':
64
+ // jpeg 不支持透明通道,先铺白底,否则透明区域会变成黑块
65
+ return pipeline
66
+ .flatten({ background: '#ffffff' })
67
+ .jpeg({ quality, mozjpeg: true, chromaSubsampling: '4:2:0' });
68
+ case 'webp':
69
+ return pipeline.webp({ quality, effort: 4 });
70
+ case 'png':
71
+ // PNG 是无损格式:要真正减小体积必须走调色板量化
72
+ return pipeline.png({
73
+ compressionLevel: 9,
74
+ palette: true,
75
+ quality,
76
+ effort: 7,
77
+ });
78
+ default:
79
+ throw invalid(`不支持的目标格式:${format}`);
80
+ }
81
+ }
82
+
83
+ function applyResize(pipeline, { maxWidth, maxHeight }) {
84
+ if (!maxWidth && !maxHeight) return pipeline;
85
+ return pipeline.resize({
86
+ width: maxWidth || undefined,
87
+ height: maxHeight || undefined,
88
+ fit: 'inside',
89
+ withoutEnlargement: true,
90
+ });
91
+ }
92
+
93
+ /**
94
+ * 压缩单张图片,尽量逼近目标体积。
95
+ *
96
+ * 策略:如果只给了 quality,一次到位;
97
+ * 如果给了 target_size,用二分法在 quality 1~100 之间逼近,最多 8 轮。
98
+ *
99
+ * @returns {Promise<{ buffer: Buffer, quality: number|null, width: number, height: number }>}
100
+ */
101
+ export async function compressImage(file, options = {}) {
102
+ const {
103
+ quality = null,
104
+ targetSize = null,
105
+ maxWidth = null,
106
+ maxHeight = null,
107
+ } = options;
108
+
109
+ const meta = await readImageMeta(file);
110
+ const format = FORMAT_ALIASES[meta.format] ?? 'jpeg';
111
+
112
+ // 每轮都重新构造管道:sharp 实例复用会让中间状态互相污染
113
+ const render = async (q) => {
114
+ let pipeline = openImage(file).rotate();
115
+ pipeline = applyResize(pipeline, { maxWidth, maxHeight });
116
+ pipeline = encode(pipeline, { format, quality: q });
117
+ return pipeline.toBuffer({ resolveWithObject: true });
118
+ };
119
+
120
+ if (targetSize && Number.isFinite(targetSize) && targetSize > 0) {
121
+ let low = 1;
122
+ let high = 100;
123
+ let best = null;
124
+ let bestQuality = null;
125
+
126
+ for (let round = 0; round < 8 && low <= high; round += 1) {
127
+ const q = Math.round((low + high) / 2);
128
+ const { data } = await render(q);
129
+
130
+ if (data.length <= targetSize) {
131
+ // 已经达标,记录后继续尝试更高的画质
132
+ if (!best || data.length > best.length) {
133
+ best = data;
134
+ bestQuality = q;
135
+ }
136
+ low = q + 1;
137
+ } else {
138
+ high = q - 1;
139
+ }
140
+ }
141
+
142
+ if (!best) {
143
+ // 最低画质仍然超标:返回最低画质的结果,由调用方如实告知用户
144
+ const { data, info } = await render(1);
145
+ return {
146
+ buffer: data,
147
+ quality: 1,
148
+ width: info.width,
149
+ height: info.height,
150
+ reachedTarget: false,
151
+ };
152
+ }
153
+
154
+ const info = await sharp(best).metadata();
155
+ return {
156
+ buffer: best,
157
+ quality: bestQuality,
158
+ width: info.width ?? meta.width,
159
+ height: info.height ?? meta.height,
160
+ reachedTarget: true,
161
+ };
162
+ }
163
+
164
+ const q = Number.isFinite(quality) ? Math.min(100, Math.max(1, Math.round(quality))) : 80;
165
+ const { data, info } = await render(q);
166
+ return {
167
+ buffer: data,
168
+ quality: q,
169
+ width: info.width,
170
+ height: info.height,
171
+ reachedTarget: null,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * 转换图片格式。
177
+ */
178
+ export async function convertImage(file, options = {}) {
179
+ const { format, quality = 90, maxWidth = null, maxHeight = null } = options;
180
+ const targetFormat = normalizeFormat(format);
181
+
182
+ let pipeline = openImage(file).rotate();
183
+ pipeline = applyResize(pipeline, { maxWidth, maxHeight });
184
+ pipeline = encode(pipeline, { format: targetFormat, quality });
185
+
186
+ const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
187
+ return {
188
+ buffer: data,
189
+ width: info.width,
190
+ height: info.height,
191
+ format: targetFormat,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * 调整图片尺寸。
197
+ *
198
+ * fit_mode 语义(面向自然语言,不用 sharp 的术语):
199
+ * fit 等比缩放到不超过给定宽高(默认)
200
+ * max 同 fit,但绝不放大超过原图
201
+ * cover 等比缩放到填满给定宽高,多余部分裁掉
202
+ * stretch 拉伸到精确宽高(会变形)
203
+ */
204
+ export async function resizeImage(file, options = {}) {
205
+ const {
206
+ width = null,
207
+ height = null,
208
+ fitMode = 'fit',
209
+ scalePercent = null,
210
+ quality = 90,
211
+ } = options;
212
+
213
+ const meta = await readImageMeta(file);
214
+ if (!meta.width || !meta.height) {
215
+ throw new ToolError('无法读取这张图片的尺寸', {
216
+ hint: '文件可能已损坏,或它不是标准图片。',
217
+ });
218
+ }
219
+
220
+ let targetWidth = width;
221
+ let targetHeight = height;
222
+
223
+ if (Number.isFinite(scalePercent)) {
224
+ const ratio = scalePercent / 100;
225
+ if (ratio <= 0) throw invalid('缩放比例必须大于 0', '例如 50 表示缩小到一半。');
226
+ targetWidth = Math.max(1, Math.round(meta.width * ratio));
227
+ targetHeight = Math.max(1, Math.round(meta.height * ratio));
228
+ }
229
+
230
+ if (!targetWidth && !targetHeight) {
231
+ throw invalid(
232
+ '没有指定新的尺寸',
233
+ '请告诉我要缩到多宽、多高,或者按百分之多少缩放。',
234
+ );
235
+ }
236
+
237
+ const fitMap = {
238
+ fit: { fit: 'inside', withoutEnlargement: false },
239
+ max: { fit: 'inside', withoutEnlargement: true },
240
+ cover: { fit: 'cover', withoutEnlargement: false },
241
+ stretch: { fit: 'fill', withoutEnlargement: false },
242
+ };
243
+
244
+ const preset = fitMap[fitMode];
245
+ assertThat(
246
+ preset,
247
+ `不支持的缩放方式:${fitMode}`,
248
+ '可选 fit(等比缩放)、max(只缩小不放大)、cover(裁切填满)、stretch(拉伸)。',
249
+ );
250
+
251
+ const format = FORMAT_ALIASES[meta.format] ?? 'jpeg';
252
+
253
+ let pipeline = openImage(file).rotate().resize({
254
+ width: targetWidth || undefined,
255
+ height: targetHeight || undefined,
256
+ fit: preset.fit,
257
+ withoutEnlargement: preset.withoutEnlargement,
258
+ background: { r: 255, g: 255, b: 255, alpha: 1 },
259
+ });
260
+ pipeline = encode(pipeline, { format, quality });
261
+
262
+ const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
263
+ return {
264
+ buffer: data,
265
+ width: info.width,
266
+ height: info.height,
267
+ originalWidth: meta.width,
268
+ originalHeight: meta.height,
269
+ };
270
+ }
271
+
272
+ /**
273
+ * 图片 -> PDF(单张或多张)。
274
+ * 走 pdf-lib,纯 JS,不依赖任何系统组件。
275
+ */
276
+ export async function imagesToPdf(files, options = {}) {
277
+ const { PDFDocument } = await import('pdf-lib');
278
+ const { pageSize = 'fit', margin = 0 } = options;
279
+
280
+ const pdf = await PDFDocument.create();
281
+
282
+ for (const file of files) {
283
+ const buffer = await fs.readFile(file);
284
+ const meta = await readImageMeta(file);
285
+ const ext = (meta.format ?? path.extname(file).slice(1)).toLowerCase();
286
+
287
+ let embedded;
288
+ if (ext === 'jpeg' || ext === 'jpg') {
289
+ embedded = await pdf.embedJpg(buffer);
290
+ } else if (ext === 'png') {
291
+ embedded = await pdf.embedPng(buffer);
292
+ } else {
293
+ // webp 等格式 pdf-lib 不认,先转成 png
294
+ const png = await openImage(file).png().toBuffer();
295
+ embedded = await pdf.embedPng(png);
296
+ }
297
+
298
+ const imgW = embedded.width;
299
+ const imgH = embedded.height;
300
+
301
+ let pageW = imgW + margin * 2;
302
+ let pageH = imgH + margin * 2;
303
+
304
+ if (pageSize === 'a4') {
305
+ pageW = 595.28;
306
+ pageH = 841.89;
307
+ }
308
+
309
+ const page = pdf.addPage([pageW, pageH]);
310
+
311
+ let drawW = imgW;
312
+ let drawH = imgH;
313
+ if (pageSize === 'a4') {
314
+ const scale = Math.min(
315
+ (pageW - margin * 2) / imgW,
316
+ (pageH - margin * 2) / imgH,
317
+ );
318
+ drawW = imgW * scale;
319
+ drawH = imgH * scale;
320
+ }
321
+
322
+ page.drawImage(embedded, {
323
+ x: (pageW - drawW) / 2,
324
+ y: (pageH - drawH) / 2,
325
+ width: drawW,
326
+ height: drawH,
327
+ });
328
+ }
329
+
330
+ return Buffer.from(await pdf.save());
331
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 文件信息提取。
3
+ * 图片尺寸走 sharp,PDF 页数走 pdf-lib,其余用 Node 内置能力。
4
+ */
5
+
6
+ import path from 'node:path';
7
+ import fs from 'node:fs/promises';
8
+ import { readImageMeta, IMAGE_EXTENSIONS } from './image.js';
9
+ import { readPdfMeta } from './pdf.js';
10
+
11
+ const MIME_TYPES = {
12
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp',
13
+ gif: 'image/gif', bmp: 'image/bmp', svg: 'image/svg+xml', tif: 'image/tiff',
14
+ tiff: 'image/tiff', heic: 'image/heic', avif: 'image/avif', ico: 'image/x-icon',
15
+ pdf: 'application/pdf',
16
+ json: 'application/json', csv: 'text/csv', tsv: 'text/tab-separated-values',
17
+ md: 'text/markdown', markdown: 'text/markdown', html: 'text/html', htm: 'text/html',
18
+ txt: 'text/plain', log: 'text/plain', xml: 'application/xml',
19
+ yml: 'application/yaml', yaml: 'application/yaml',
20
+ doc: 'application/msword',
21
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
22
+ xls: 'application/vnd.ms-excel',
23
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
24
+ ppt: 'application/vnd.ms-powerpoint',
25
+ pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
26
+ zip: 'application/zip', rar: 'application/vnd.rar', '7z': 'application/x-7z-compressed',
27
+ tar: 'application/x-tar', gz: 'application/gzip',
28
+ mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', flac: 'audio/flac',
29
+ mp4: 'video/mp4', mov: 'video/quicktime', avi: 'video/x-msvideo',
30
+ mkv: 'video/x-matroska', webm: 'video/webm',
31
+ js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
32
+ ts: 'text/typescript', css: 'text/css', sh: 'text/x-shellscript',
33
+ py: 'text/x-python', java: 'text/x-java',
34
+ dmg: 'application/x-apple-diskimage',
35
+ apk: 'application/vnd.android.package-archive',
36
+ };
37
+
38
+ /** 把扩展名翻译成人能看懂的类型名 */
39
+ function describeKind(ext) {
40
+ if (['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'tif', 'tiff', 'heic', 'avif', 'svg', 'ico'].includes(ext)) return '图片';
41
+ if (ext === 'pdf') return 'PDF 文档';
42
+ if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) return 'Word 文档';
43
+ if (['xls', 'xlsx', 'ods'].includes(ext)) return '表格文件';
44
+ if (['ppt', 'pptx', 'odp'].includes(ext)) return '演示文稿';
45
+ if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'].includes(ext)) return '压缩包';
46
+ if (['mp3', 'wav', 'm4a', 'flac', 'aac', 'ogg'].includes(ext)) return '音频';
47
+ if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'].includes(ext)) return '视频';
48
+ if (['txt', 'md', 'markdown', 'log', 'json', 'csv', 'tsv', 'xml', 'yml', 'yaml', 'html', 'htm'].includes(ext)) return '文本';
49
+ if (['js', 'mjs', 'cjs', 'ts', 'py', 'java', 'go', 'rs', 'c', 'cpp', 'sh', 'css', 'rb', 'php'].includes(ext)) return '代码';
50
+ if (['app', 'exe', 'dmg', 'pkg', 'msi', 'deb', 'rpm', 'apk'].includes(ext)) return '程序安装包';
51
+ if (ext === '') return '无扩展名文件';
52
+ return '其他';
53
+ }
54
+
55
+ function formatTime(date) {
56
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null;
57
+ const pad = (n) => String(n).padStart(2, '0');
58
+ return (
59
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
60
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
61
+ );
62
+ }
63
+
64
+ /**
65
+ * 读取单个文件的信息。
66
+ * 图片或 PDF 损坏时不会让整个调用失败,只是把对应字段标为无法读取。
67
+ */
68
+ export async function inspectFile(filePath) {
69
+ const st = await fs.stat(filePath);
70
+ const ext = path.extname(filePath).toLowerCase().replace(/^\./, '');
71
+
72
+ const info = {
73
+ path: path.resolve(filePath),
74
+ name: path.basename(filePath),
75
+ extension: ext || null,
76
+ kind: describeKind(ext),
77
+ mimeType: MIME_TYPES[ext] ?? 'application/octet-stream',
78
+ sizeBytes: st.size,
79
+ createdAt: formatTime(st.birthtime),
80
+ modifiedAt: formatTime(st.mtime),
81
+ accessedAt: formatTime(st.atime),
82
+ width: null,
83
+ height: null,
84
+ imageFormat: null,
85
+ pdfPages: null,
86
+ note: null,
87
+ };
88
+
89
+ if (IMAGE_EXTENSIONS.includes(ext)) {
90
+ try {
91
+ const meta = await readImageMeta(filePath);
92
+ info.width = meta.width;
93
+ info.height = meta.height;
94
+ info.imageFormat = meta.format;
95
+ } catch {
96
+ info.note = '这个文件用的是图片扩展名,但内容无法当作图片读取';
97
+ }
98
+ } else if (ext === 'pdf') {
99
+ try {
100
+ const meta = await readPdfMeta(filePath);
101
+ info.pdfPages = meta.pages;
102
+ } catch {
103
+ info.note = '这个文件用的是 pdf 扩展名,但内容无法当作 PDF 读取';
104
+ }
105
+ }
106
+
107
+ return info;
108
+ }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * 路径解析与「统一输出目录规则」。
3
+ *
4
+ * 全部工具共用同一套输出策略,避免每个工具各写一套导致行为不一致:
5
+ *
6
+ * 1. 用户显式指定 output_directory -> 用它
7
+ * 2. 没指定 -> 用第一个输入文件所在目录下的 file-toolkit-output/
8
+ * 3. 输出文件已存在 -> 自动追加序号(name (2).ext),永不覆盖
9
+ * 4. 输出路径与任一输入文件相同 -> 直接拒绝,永不覆盖原文件
10
+ */
11
+
12
+ import path from 'node:path';
13
+ import fs from 'node:fs/promises';
14
+ import { ToolError, assertThat } from './errors.js';
15
+
16
+ export const OUTPUT_DIR_NAME = 'file-toolkit-output';
17
+
18
+ /** 单个操作默认最多处理多少个文件,防止一次调用把内存打满 */
19
+ export const DEFAULT_MAX_FILES = 500;
20
+
21
+ /**
22
+ * 转成绝对路径。相对路径以 base(默认当前工作目录)为基准。
23
+ */
24
+ export function toAbsolute(input, base = process.cwd()) {
25
+ assertThat(
26
+ typeof input === 'string' && input.trim() !== '',
27
+ '文件路径不能为空',
28
+ '请提供要处理文件的完整路径。',
29
+ );
30
+ const cleaned = input.trim().replace(/^["']|["']$/g, '');
31
+ return path.resolve(base, cleaned);
32
+ }
33
+
34
+ async function statOrNull(p) {
35
+ try {
36
+ return await fs.stat(p);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * 把用户给的输入展开成实际要处理的文件列表。
44
+ *
45
+ * 支持三种写法:
46
+ * - 单个文件
47
+ * - 目录(自动收集里面的文件,可按扩展名过滤)
48
+ * - 多个以上任意组合
49
+ *
50
+ * @param {string[]} inputs 用户给的路径列表
51
+ * @param {{ extensions?: string[]|null, recursive?: boolean, maxFiles?: number, label?: string }} [options]
52
+ * @returns {Promise<{ files: string[], warnings: string[] }>}
53
+ */
54
+ export async function expandInputs(inputs, options = {}) {
55
+ const {
56
+ extensions = null,
57
+ recursive = true,
58
+ maxFiles = DEFAULT_MAX_FILES,
59
+ label = '文件',
60
+ } = options;
61
+
62
+ assertThat(
63
+ Array.isArray(inputs) && inputs.length > 0,
64
+ '没有提供任何要处理的文件',
65
+ '请告诉我要处理哪个文件或哪个文件夹。',
66
+ );
67
+
68
+ const wanted = extensions
69
+ ? new Set(extensions.map((e) => e.toLowerCase().replace(/^\./, '')))
70
+ : null;
71
+
72
+ const files = [];
73
+ const warnings = [];
74
+ const seen = new Set();
75
+
76
+ const accept = (filePath) => {
77
+ if (wanted) {
78
+ const ext = path.extname(filePath).toLowerCase().replace(/^\./, '');
79
+ if (!wanted.has(ext)) return;
80
+ }
81
+ const abs = path.resolve(filePath);
82
+ if (seen.has(abs)) return;
83
+ seen.add(abs);
84
+ files.push(abs);
85
+ };
86
+
87
+ for (const raw of inputs) {
88
+ const abs = toAbsolute(raw);
89
+ const st = await statOrNull(abs);
90
+
91
+ if (!st) {
92
+ warnings.push(`跳过不存在的路径:${abs}`);
93
+ continue;
94
+ }
95
+
96
+ if (st.isFile()) {
97
+ accept(abs);
98
+ continue;
99
+ }
100
+
101
+ if (st.isDirectory()) {
102
+ const walk = async (dir) => {
103
+ let entries = [];
104
+ try {
105
+ entries = await fs.readdir(dir, { withFileTypes: true });
106
+ } catch (error) {
107
+ warnings.push(`无法读取目录 ${dir}:${error?.message ?? error}`);
108
+ return;
109
+ }
110
+ for (const entry of entries) {
111
+ if (entry.name.startsWith('.')) continue;
112
+ // 跳过工具自己的输出目录,避免把上一轮结果又处理一遍
113
+ if (entry.isDirectory() && entry.name === OUTPUT_DIR_NAME) continue;
114
+ const full = path.join(dir, entry.name);
115
+ if (entry.isDirectory()) {
116
+ if (recursive) await walk(full);
117
+ } else if (entry.isFile()) {
118
+ accept(full);
119
+ }
120
+ }
121
+ };
122
+ await walk(abs);
123
+ continue;
124
+ }
125
+
126
+ warnings.push(`跳过不支持的文件类型:${abs}`);
127
+ }
128
+
129
+ files.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN', { numeric: true }));
130
+
131
+ if (files.length > maxFiles) {
132
+ throw new ToolError(
133
+ `一次要处理的${label}太多了(${files.length} 个,上限 ${maxFiles} 个)`,
134
+ { hint: '请分批处理,或缩小到具体的子文件夹。' },
135
+ );
136
+ }
137
+
138
+ // 一个都没找到时,把「为什么没找到」如实说出来,
139
+ // 否则用户只会看到一句笼统的「没找到文件」,不知道是路径写错了还是格式不对。
140
+ if (files.length === 0) {
141
+ const extHint = wanted
142
+ ? `支持的文件后缀:${[...wanted].map((e) => `.${e}`).join('、')}。`
143
+ : '';
144
+ const reasons =
145
+ warnings.length > 0 ? `\n具体原因:\n- ${warnings.join('\n- ')}` : '';
146
+ throw new ToolError(`没有找到可以处理的${label}`, {
147
+ hint: `${extHint}请确认路径是否正确。${reasons}`,
148
+ });
149
+ }
150
+
151
+ return { files, warnings };
152
+ }
153
+
154
+ /**
155
+ * 决定这一次操作的输出目录。
156
+ *
157
+ * @param {{ inputs?: string[], outputDirectory?: string, anchor?: string }} params
158
+ * anchor:当输入是新建内容(没有源文件)时,用它作为基准目录
159
+ */
160
+ export async function resolveOutputDir({ inputs = [], outputDirectory, anchor } = {}) {
161
+ if (outputDirectory && String(outputDirectory).trim() !== '') {
162
+ const dir = toAbsolute(outputDirectory);
163
+ await fs.mkdir(dir, { recursive: true });
164
+ return dir;
165
+ }
166
+
167
+ let base = anchor ? toAbsolute(anchor) : null;
168
+ if (!base && inputs.length > 0) base = toAbsolute(inputs[0]);
169
+ if (!base) base = process.cwd();
170
+
171
+ const st = await statOrNull(base);
172
+ const baseDir = st && st.isFile() ? path.dirname(base) : base;
173
+
174
+ // 如果输入本身就在 file-toolkit-output 里,就地输出,不再套一层同名目录
175
+ if (path.basename(baseDir) === OUTPUT_DIR_NAME) {
176
+ await fs.mkdir(baseDir, { recursive: true });
177
+ return baseDir;
178
+ }
179
+
180
+ const dir = path.join(baseDir, OUTPUT_DIR_NAME);
181
+ await fs.mkdir(dir, { recursive: true });
182
+ return dir;
183
+ }
184
+
185
+ /**
186
+ * 在目录下生成一个不会覆盖已有文件的路径。
187
+ * 名字冲突时依次尝试 "name (2).ext"、"name (3).ext"……
188
+ */
189
+ export async function uniquePath(dir, filename) {
190
+ const ext = path.extname(filename);
191
+ const stem = path.basename(filename, ext);
192
+
193
+ let candidate = path.join(dir, filename);
194
+ let counter = 1;
195
+ while (await statOrNull(candidate)) {
196
+ counter += 1;
197
+ candidate = path.join(dir, `${stem} (${counter})${ext}`);
198
+ if (counter > 9999) {
199
+ throw new ToolError('输出目录里同名文件太多,无法生成新文件名', {
200
+ hint: '请清理输出目录,或指定另一个 output_directory。',
201
+ });
202
+ }
203
+ }
204
+ return candidate;
205
+ }
206
+
207
+ /**
208
+ * 双保险:确认输出路径没有落在任何一个输入文件上。
209
+ * 正常流程下不会触发,触发即代表逻辑有问题,宁可直接失败也不要覆盖用户文件。
210
+ */
211
+ export function assertNotOverwritingInput(outPath, inputPaths) {
212
+ const target = path.resolve(outPath);
213
+ const clash = inputPaths.find((p) => path.resolve(p) === target);
214
+ if (clash) {
215
+ throw new ToolError('输出路径和原文件重复,为避免覆盖原文件已中止', {
216
+ hint: '请指定另一个 output_directory。',
217
+ });
218
+ }
219
+ return true;
220
+ }
221
+
222
+ /**
223
+ * 推测「输出目录」的相对展示形式,用于回显给用户。
224
+ */
225
+ export function displayPath(p) {
226
+ const home = process.env.HOME || process.env.USERPROFILE;
227
+ if (home && path.resolve(p).startsWith(path.resolve(home))) {
228
+ return `~${path.resolve(p).slice(path.resolve(home).length)}`;
229
+ }
230
+ return path.resolve(p);
231
+ }