change-image-suffix 2.1.3 → 2.1.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [2.1.4](https://github.com/GuoSirius/change-image-suffix/compare/v2.1.3...v2.1.4) (2026-05-23)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * output to <format>/ dir, command injection, recursive nesting, and other issues ([32da38a](https://github.com/GuoSirius/change-image-suffix/commit/32da38aef36a7d736e91e0375573a905d565d864))
11
+
12
+
13
+ ### Chores
14
+
15
+ * update local settings permissions ([1a86130](https://github.com/GuoSirius/change-image-suffix/commit/1a86130de6005348d6ba8aaba26500d7688be996))
16
+
17
+
18
+ ### Documentation
19
+
20
+ * update README output convention, add .claude/ commit policy memory ([b689bc6](https://github.com/GuoSirius/change-image-suffix/commit/b689bc60ed397eb826954e289c3f89488bd97449))
21
+
5
22
  ### [2.1.3](https://github.com/GuoSirius/change-image-suffix/compare/v2.1.2...v2.1.3) (2026-05-21)
6
23
 
7
24
 
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  - 📏 支持递归深度限制
10
10
  - 🎯 支持指定源文件后缀(png, jpg, gif 等)
11
11
  - 🎨 支持多种目标格式(webp, jpg, png, avif, tiff)
12
- - 📤 输出到 `output/` 子目录
12
+ - 📤 输出到 `<目标格式>/` 子目录(如 `webp/`, `jpg/`)
13
13
  - 🔢 同名不同后缀文件自动编号(`_01`, `_02`)
14
14
  - 🖱️ Windows 右键菜单集成
15
15
  - ⚡ 基于 [sharp](https://sharp.pixel.glass/) 高性能图片处理
@@ -101,7 +101,7 @@ cis uninstall-menu
101
101
 
102
102
  ## 文件命名规范
103
103
 
104
- 转换后的文件输出到 **`output/`** 子目录下,命名规则如下:
104
+ 转换后的文件输出到 **`<目标格式>/`** 子目录下(例如转换为 webp 则输出到 `webp/`,转换为 jpg 则输出到 `jpg/`),命名规则如下:
105
105
 
106
106
  ### 同名不同后缀 → 自动编号
107
107
 
@@ -109,34 +109,36 @@ cis uninstall-menu
109
109
 
110
110
  ```
111
111
  源目录: photo.png + photo.jpg + photo.gif
112
- 输出: output/photo_01.webp
113
- output/photo_02.webp
114
- output/photo_03.webp
112
+ 输出: webp/photo_01.webp
113
+ webp/photo_02.webp
114
+ webp/photo_03.webp
115
115
  ```
116
116
 
117
117
  ### 不同名或不同文件 → 直接覆盖
118
118
 
119
119
  ```
120
120
  源目录: banner.png + logo.jpg
121
- 输出: output/banner.webp
122
- output/logo.webp
121
+ 输出: webp/banner.webp
122
+ webp/logo.webp
123
123
  ```
124
124
 
125
125
  ### 同格式转换 → 直接覆盖
126
126
 
127
127
  ```
128
128
  源目录: photo.webp
129
- 输出: output/photo.webp (直接覆盖,无双重后缀)
129
+ 输出: webp/photo.webp (直接复制,无双重后缀)
130
130
  ```
131
131
 
132
132
  ### 输出目录结构
133
133
 
134
134
  ```
135
135
  📁 原目录/
136
- ├── 📁 output/ 转换后的文件
136
+ ├── 📁 webp/ 转换后的 webp 文件
137
137
  │ ├── photo.webp
138
- │ ├── banner.jpg
139
- │ └── photo_01.png
138
+ │ ├── banner.webp
139
+ │ └── photo_01.webp
140
+ ├── 📁 jpg/ ← 转换后的 jpg 文件
141
+ │ └── ...
140
142
  ├── photo.png
141
143
  ├── banner.jpg
142
144
  └── logo.gif
@@ -193,14 +195,14 @@ cis -f ./avatar.png -t webp
193
195
  ### 示例 6:多选文件/目录批量转换
194
196
 
195
197
  ```bash
196
- # 多选文件(空格分隔),每个文件的输出在其所在目录的 output/
198
+ # 多选文件(空格分隔),每个文件的输出在其所在目录的 <目标格式>/
197
199
  cis ./photo1.png ./photo2.jpg ./folder3
198
200
 
199
201
  # 多选多个目录
200
202
  cis ./images ./icons ./logos
201
203
  ```
202
204
 
203
- > 💡 **多选时**:每个文件/目录的输出结果放在**各自所在目录的 `output/` 子目录**中,互不干扰。
205
+ > 💡 **多选时**:每个文件/目录的输出结果放在**各自所在目录的 `<目标格式>/` 子目录**中(如 `webp/`, `jpg/`),互不干扰。
204
206
 
205
207
  ---
206
208
 
package/dist/index.js CHANGED
@@ -203,7 +203,7 @@ endlocal
203
203
  console.log(' 📁 文件夹空白处/图标右键 → 悬停展开格式子菜单');
204
204
  console.log(' 🖼 图片文件上右键 → 悬停展开格式子菜单');
205
205
  console.log(' ⚠️ 非图片文件右键 → 菜单显示但不处理');
206
- console.log(` 📂 输出目录: <原目录>/output/`);
206
+ console.log(` 📂 输出目录: <原目录>/<目标格式>/`);
207
207
  console.log('\n💡 提示:如需卸载,执行 cis uninstall-menu');
208
208
  }
209
209
  function uninstallContextMenu() {
@@ -232,18 +232,28 @@ function uninstallContextMenu() {
232
232
  }
233
233
  catch { /* ignore */ }
234
234
  }
235
- // 删除批处理文件和版本标记
235
+ // 删除批处理文件、图标和版本标记
236
236
  const appDataDir = path.join(os.homedir(), 'AppData', 'Roaming', 'change-image-suffix');
237
237
  const batPath = path.join(appDataDir, 'cis_file.bat');
238
238
  try {
239
239
  fs.unlinkSync(batPath);
240
240
  }
241
241
  catch { /* ignore */ }
242
+ const iconPath = path.join(appDataDir, 'icon.ico');
243
+ try {
244
+ fs.unlinkSync(iconPath);
245
+ }
246
+ catch { /* ignore */ }
242
247
  const versionFile = path.join(appDataDir, 'version.json');
243
248
  try {
244
249
  fs.unlinkSync(versionFile);
245
250
  }
246
251
  catch { /* ignore */ }
252
+ // 尝试删除目录(仅当为空时),失败也不影响
253
+ try {
254
+ fs.rmdirSync(appDataDir);
255
+ }
256
+ catch { /* ignore */ }
247
257
  console.log('✅ 右键菜单已卸载');
248
258
  }
249
259
  // 自动检测版本变化并更新右键菜单(解决 npm update 不触发 postinstall 的问题)
@@ -252,20 +262,23 @@ function autoUpdateContextMenu() {
252
262
  return;
253
263
  const appDataDir = path.join(os.homedir(), 'AppData', 'Roaming', 'change-image-suffix');
254
264
  const versionFile = path.join(appDataDir, 'version.json');
265
+ // 如果从未安装过右键菜单,跳过自动更新(postinstall 负责首次安装)
266
+ if (!fs.existsSync(versionFile))
267
+ return;
255
268
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
256
269
  const currentVersion = pkg.version;
257
270
  let installedVersion = '';
258
271
  try {
259
- if (fs.existsSync(versionFile)) {
260
- installedVersion = JSON.parse(fs.readFileSync(versionFile, 'utf8')).version || '';
261
- }
272
+ installedVersion = JSON.parse(fs.readFileSync(versionFile, 'utf8')).version || '';
262
273
  }
263
274
  catch { /* ignore */ }
264
275
  if (installedVersion !== currentVersion) {
265
276
  try {
266
277
  installContextMenu();
267
278
  }
268
- catch { /* menu update failed silently, will retry next invocation */ }
279
+ catch {
280
+ console.warn('⚠️ 右键菜单自动更新失败,请手动执行 cis install-menu');
281
+ }
269
282
  }
270
283
  }
271
284
  // ─────────────────────────────────────────
@@ -294,27 +307,40 @@ function parseArgs() {
294
307
  continue;
295
308
  }
296
309
  if (arg === '-d' || arg === '--depth') {
297
- if (i + 1 < args.length) {
298
- options.maxDepth = parseInt(args[++i], 10);
299
- if (isNaN(options.maxDepth) || options.maxDepth < 1) {
310
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
311
+ const val = parseInt(args[++i], 10);
312
+ if (isNaN(val) || val < 1) {
300
313
  console.error('❌ 深度必须是正整数');
301
314
  process.exit(1);
302
315
  }
316
+ options.maxDepth = val;
317
+ }
318
+ else {
319
+ console.error('❌ -d/--depth 需要指定一个正整数参数');
320
+ process.exit(1);
303
321
  }
304
322
  i++;
305
323
  continue;
306
324
  }
307
325
  if (arg === '-e' || arg === '--extensions') {
308
- if (i + 1 < args.length) {
326
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
309
327
  options.extensions = args[++i].split(',').map(e => e.trim().toLowerCase().replace(/^\./, ''));
310
328
  }
329
+ else {
330
+ console.error('❌ -e/--extensions 需要指定后缀参数');
331
+ process.exit(1);
332
+ }
311
333
  i++;
312
334
  continue;
313
335
  }
314
336
  if (arg === '-t' || arg === '--to') {
315
- if (i + 1 < args.length) {
337
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
316
338
  options.targetFormat = args[++i].trim().toLowerCase().replace(/^\./, '');
317
339
  }
340
+ else {
341
+ console.error('❌ -t/--to 需要指定目标格式');
342
+ process.exit(1);
343
+ }
318
344
  i++;
319
345
  continue;
320
346
  }
@@ -343,10 +369,15 @@ function parseArgs() {
343
369
  }
344
370
  if (arg === '-f' || arg === '--file') {
345
371
  // 收集 -f 后的所有文件
372
+ const start = i + 1;
346
373
  while (i + 1 < args.length && !args[i + 1].startsWith('-')) {
347
374
  i++;
348
375
  filesFromFlag.push(path.resolve(args[i]));
349
376
  }
377
+ if (start > i) {
378
+ console.error('❌ -f/--file 需要指定至少一个文件路径');
379
+ process.exit(1);
380
+ }
350
381
  i++;
351
382
  continue;
352
383
  }
@@ -434,14 +465,16 @@ function printHelp() {
434
465
  // ─────────────────────────────────────────
435
466
  // 图片处理
436
467
  // ─────────────────────────────────────────
437
- function getAllFiles(dir, extensions, recursive, currentDepth, maxDepth) {
468
+ function getAllFiles(dir, extensions, recursive, currentDepth, maxDepth, excludeDirName) {
438
469
  const files = [];
439
470
  try {
440
471
  const entries = fs.readdirSync(dir, { withFileTypes: true });
441
472
  for (const entry of entries) {
442
473
  const fullPath = path.join(dir, entry.name);
443
474
  if (entry.isDirectory() && recursive && currentDepth < maxDepth) {
444
- files.push(...getAllFiles(fullPath, extensions, recursive, currentDepth + 1, maxDepth));
475
+ if (excludeDirName && entry.name === excludeDirName)
476
+ continue;
477
+ files.push(...getAllFiles(fullPath, extensions, recursive, currentDepth + 1, maxDepth, excludeDirName));
445
478
  }
446
479
  else if (entry.isFile()) {
447
480
  const ext = path.extname(entry.name).slice(1).toLowerCase();
@@ -462,7 +495,7 @@ function getOutputPath(inputPath, targetFormat, allInputFiles) {
462
495
  const basename = path.basename(inputPath, ext);
463
496
  const targetExt = targetFormat;
464
497
  let coreName = basename;
465
- const outputDir = path.join(dir, 'output');
498
+ const outputDir = path.join(dir, targetFormat);
466
499
  // 检查输入目录中是否有同名(不含扩展名)但不同后缀的文件
467
500
  // 如 photo.png 和 photo.jpg 会被判定为同名冲突
468
501
  const hasNameConflict = allInputFiles.some(f => {
@@ -471,7 +504,7 @@ function getOutputPath(inputPath, targetFormat, allInputFiles) {
471
504
  const fDir = path.dirname(f);
472
505
  const fExt = path.extname(f);
473
506
  const fBasename = path.basename(f, fExt);
474
- return fDir === dir && fBasename === basename && fExt.toLowerCase() !== ext.toLowerCase();
507
+ return fDir === dir && fBasename.toLowerCase() === basename.toLowerCase() && fExt.toLowerCase() !== ext.toLowerCase();
475
508
  });
476
509
  if (hasNameConflict) {
477
510
  // 找所有同basename的文件的序号
@@ -481,7 +514,7 @@ function getOutputPath(inputPath, targetFormat, allInputFiles) {
481
514
  const fDir = path.dirname(f);
482
515
  const fExt = path.extname(f);
483
516
  const fBasename = path.basename(f, fExt);
484
- return fDir === dir && fBasename === basename;
517
+ return fDir === dir && fBasename.toLowerCase() === basename.toLowerCase();
485
518
  });
486
519
  // 当前文件在所有同名文件中的索引(从1开始)
487
520
  const sortedMatches = [...allBasenameMatches, inputPath].sort();
@@ -493,22 +526,23 @@ function getOutputPath(inputPath, targetFormat, allInputFiles) {
493
526
  return path.join(outputDir, filename);
494
527
  }
495
528
  async function convertImage(inputPath, targetFormat, allInputFiles) {
529
+ const outputPath = getOutputPath(inputPath, targetFormat, allInputFiles);
530
+ const srcExt = path.extname(inputPath).slice(1).toLowerCase();
531
+ const fmt = targetFormat.toLowerCase();
532
+ // 先验证格式,避免在无效路径上创建目录
533
+ if (!SUPPORTED_OUTPUT_FORMATS.includes(fmt)) {
534
+ return { success: false, outputPath, error: `不支持的目标格式: ${targetFormat},支持: ${SUPPORTED_OUTPUT_FORMATS.join(', ')}` };
535
+ }
496
536
  try {
497
- const outputPath = getOutputPath(inputPath, targetFormat, allInputFiles);
498
537
  const outputDir = path.dirname(outputPath);
499
538
  if (!fs.existsSync(outputDir)) {
500
539
  fs.mkdirSync(outputDir, { recursive: true });
501
540
  }
502
- const srcExt = path.extname(inputPath).slice(1).toLowerCase();
503
- const fmt = targetFormat.toLowerCase();
504
541
  // 同格式直接复制,避免重新编码导致质量损失
505
542
  if (srcExt === fmt || (srcExt === 'jpeg' && fmt === 'jpg') || (srcExt === 'jpg' && fmt === 'jpeg') || (srcExt === 'tif' && fmt === 'tiff') || (srcExt === 'tiff' && fmt === 'tif')) {
506
543
  fs.copyFileSync(inputPath, outputPath);
507
544
  return { success: true, outputPath };
508
545
  }
509
- if (!SUPPORTED_OUTPUT_FORMATS.includes(fmt)) {
510
- return { success: false, outputPath: inputPath, error: `不支持的目标格式: ${targetFormat},支持: ${SUPPORTED_OUTPUT_FORMATS.join(', ')}` };
511
- }
512
546
  const image = (0, sharp_1.default)(inputPath);
513
547
  switch (fmt) {
514
548
  case 'webp':
@@ -534,7 +568,7 @@ async function convertImage(inputPath, targetFormat, allInputFiles) {
534
568
  catch (err) {
535
569
  return {
536
570
  success: false,
537
- outputPath: inputPath,
571
+ outputPath,
538
572
  error: err instanceof Error ? err.message : '未知错误'
539
573
  };
540
574
  }
@@ -565,16 +599,18 @@ async function main() {
565
599
  console.log('----------------------------------------\n');
566
600
  let totalSuccess = 0;
567
601
  let totalFail = 0;
602
+ const failures = [];
568
603
  for (const filePath of files) {
569
604
  const ext = path.extname(filePath).slice(1).toLowerCase();
570
605
  if (!SUPPORTED_INPUT_EXTENSIONS.includes(ext)) {
571
606
  console.log(` ⚠️ 跳过(不支持格式): ${filePath}`);
572
607
  totalFail++;
608
+ failures.push(filePath);
573
609
  continue;
574
610
  }
575
611
  console.log(` 📄 文件: ${filePath}`);
576
612
  process.stdout.write(` 处理中: ${path.basename(filePath)} ... `);
577
- const result = await convertImage(filePath, options.targetFormat, [filePath]);
613
+ const result = await convertImage(filePath, options.targetFormat, files);
578
614
  if (result.success) {
579
615
  console.log(`✅ -> ${path.relative(path.dirname(filePath), result.outputPath)}`);
580
616
  totalSuccess++;
@@ -582,6 +618,13 @@ async function main() {
582
618
  else {
583
619
  console.log(`❌ 失败 (${result.error})`);
584
620
  totalFail++;
621
+ failures.push(filePath);
622
+ }
623
+ }
624
+ if (failures.length > 0) {
625
+ console.log('\n❌ 失败的文件:');
626
+ for (const f of failures) {
627
+ console.log(` - ${f}`);
585
628
  }
586
629
  }
587
630
  return { success: totalSuccess, fail: totalFail };
@@ -593,11 +636,13 @@ async function main() {
593
636
  console.log('----------------------------------------\n');
594
637
  let totalSuccess = 0;
595
638
  let totalFail = 0;
639
+ const failures = [];
596
640
  for (const inputPath of dirs) {
597
641
  const stat = fs.existsSync(inputPath) ? fs.statSync(inputPath) : null;
598
642
  if (!stat) {
599
643
  console.log(` ⚠️ 跳过(不存在): ${inputPath}`);
600
644
  totalFail++;
645
+ failures.push(inputPath);
601
646
  continue;
602
647
  }
603
648
  if (stat.isFile()) {
@@ -605,11 +650,12 @@ async function main() {
605
650
  if (!SUPPORTED_INPUT_EXTENSIONS.includes(ext)) {
606
651
  console.log(` ⚠️ 跳过(不支持格式): ${inputPath}`);
607
652
  totalFail++;
653
+ failures.push(inputPath);
608
654
  continue;
609
655
  }
610
656
  console.log(` 📄 文件: ${inputPath}`);
611
657
  process.stdout.write(` 处理中: ${path.basename(inputPath)} ... `);
612
- const result = await convertImage(inputPath, options.targetFormat, [inputPath]);
658
+ const result = await convertImage(inputPath, options.targetFormat, dirs);
613
659
  if (result.success) {
614
660
  console.log(`✅ -> ${path.relative(path.dirname(inputPath), result.outputPath)}`);
615
661
  totalSuccess++;
@@ -617,10 +663,11 @@ async function main() {
617
663
  else {
618
664
  console.log(`❌ 失败 (${result.error})`);
619
665
  totalFail++;
666
+ failures.push(inputPath);
620
667
  }
621
668
  }
622
669
  else {
623
- const files = getAllFiles(inputPath, options.extensions, options.recursive, 0, options.maxDepth);
670
+ const files = getAllFiles(inputPath, options.extensions, options.recursive, 0, options.maxDepth, options.targetFormat);
624
671
  console.log(` 📁 目录: ${inputPath} (${files.length} 个文件)`);
625
672
  if (files.length === 0) {
626
673
  console.log(' ✅ 没有找到图片文件');
@@ -636,10 +683,17 @@ async function main() {
636
683
  else {
637
684
  console.log(`❌ (${result.error})`);
638
685
  totalFail++;
686
+ failures.push(file);
639
687
  }
640
688
  }
641
689
  }
642
690
  }
691
+ if (failures.length > 0) {
692
+ console.log('\n❌ 失败的文件:');
693
+ for (const f of failures) {
694
+ console.log(` - ${f}`);
695
+ }
696
+ }
643
697
  return { success: totalSuccess, fail: totalFail };
644
698
  }
645
699
  // ─── 混合模式:同时有文件和目录 ───
@@ -673,7 +727,7 @@ async function main() {
673
727
  console.log(`📄 后缀: ${options.extensions.join(', ')}`);
674
728
  console.log(`🎯 目标格式: ${options.targetFormat}`);
675
729
  console.log('\n----------------------------------------\n');
676
- const files = getAllFiles(options.directory, options.extensions, options.recursive, 0, options.maxDepth);
730
+ const files = getAllFiles(options.directory, options.extensions, options.recursive, 0, options.maxDepth, options.targetFormat);
677
731
  if (files.length === 0) {
678
732
  console.log('✅ 没有找到需要转换的图片文件。');
679
733
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "change-image-suffix",
3
- "version": "2.1.3",
3
+ "version": "2.1.4",
4
4
  "description": "批量转换图片格式的CLI工具,支持递归搜索、深度限制、指定后缀、Windows右键菜单等功能",
5
5
  "main": "dist/index.js",
6
6
  "files": [