change-image-suffix 2.1.2 → 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,30 @@
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
+
22
+ ### [2.1.3](https://github.com/GuoSirius/change-image-suffix/compare/v2.1.2...v2.1.3) (2026-05-21)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * **context-menu:** auto-refresh menu on version change, gate preuninstall on global ([396dc45](https://github.com/GuoSirius/change-image-suffix/commit/396dc45f1d0606a16828f8e9467194ec933487d8))
28
+
5
29
  ### [2.1.2](https://github.com/GuoSirius/change-image-suffix/compare/v2.1.1...v2.1.2) (2026-05-21)
6
30
 
7
31
 
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
@@ -195,11 +195,15 @@ endlocal
195
195
  // bat 脚本会检查文件扩展名,自动忽略非图片文件
196
196
  }
197
197
  }
198
+ // 写入版本标记,用于检测 npm update 后自动刷新菜单
199
+ const versionFile = path.join(appDataDir, 'version.json');
200
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
201
+ fs.writeFileSync(versionFile, JSON.stringify({ version: pkg.version }), 'utf8');
198
202
  console.log('✅ 右键菜单安装成功!');
199
203
  console.log(' 📁 文件夹空白处/图标右键 → 悬停展开格式子菜单');
200
204
  console.log(' 🖼 图片文件上右键 → 悬停展开格式子菜单');
201
205
  console.log(' ⚠️ 非图片文件右键 → 菜单显示但不处理');
202
- console.log(` 📂 输出目录: <原目录>/output/`);
206
+ console.log(` 📂 输出目录: <原目录>/<目标格式>/`);
203
207
  console.log('\n💡 提示:如需卸载,执行 cis uninstall-menu');
204
208
  }
205
209
  function uninstallContextMenu() {
@@ -228,15 +232,55 @@ function uninstallContextMenu() {
228
232
  }
229
233
  catch { /* ignore */ }
230
234
  }
231
- // 删除批处理文件
235
+ // 删除批处理文件、图标和版本标记
232
236
  const appDataDir = path.join(os.homedir(), 'AppData', 'Roaming', 'change-image-suffix');
233
237
  const batPath = path.join(appDataDir, 'cis_file.bat');
234
238
  try {
235
239
  fs.unlinkSync(batPath);
236
240
  }
237
241
  catch { /* ignore */ }
242
+ const iconPath = path.join(appDataDir, 'icon.ico');
243
+ try {
244
+ fs.unlinkSync(iconPath);
245
+ }
246
+ catch { /* ignore */ }
247
+ const versionFile = path.join(appDataDir, 'version.json');
248
+ try {
249
+ fs.unlinkSync(versionFile);
250
+ }
251
+ catch { /* ignore */ }
252
+ // 尝试删除目录(仅当为空时),失败也不影响
253
+ try {
254
+ fs.rmdirSync(appDataDir);
255
+ }
256
+ catch { /* ignore */ }
238
257
  console.log('✅ 右键菜单已卸载');
239
258
  }
259
+ // 自动检测版本变化并更新右键菜单(解决 npm update 不触发 postinstall 的问题)
260
+ function autoUpdateContextMenu() {
261
+ if (os.platform() !== 'win32')
262
+ return;
263
+ const appDataDir = path.join(os.homedir(), 'AppData', 'Roaming', 'change-image-suffix');
264
+ const versionFile = path.join(appDataDir, 'version.json');
265
+ // 如果从未安装过右键菜单,跳过自动更新(postinstall 负责首次安装)
266
+ if (!fs.existsSync(versionFile))
267
+ return;
268
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
269
+ const currentVersion = pkg.version;
270
+ let installedVersion = '';
271
+ try {
272
+ installedVersion = JSON.parse(fs.readFileSync(versionFile, 'utf8')).version || '';
273
+ }
274
+ catch { /* ignore */ }
275
+ if (installedVersion !== currentVersion) {
276
+ try {
277
+ installContextMenu();
278
+ }
279
+ catch {
280
+ console.warn('⚠️ 右键菜单自动更新失败,请手动执行 cis install-menu');
281
+ }
282
+ }
283
+ }
240
284
  // ─────────────────────────────────────────
241
285
  // 参数解析
242
286
  // ─────────────────────────────────────────
@@ -263,27 +307,40 @@ function parseArgs() {
263
307
  continue;
264
308
  }
265
309
  if (arg === '-d' || arg === '--depth') {
266
- if (i + 1 < args.length) {
267
- options.maxDepth = parseInt(args[++i], 10);
268
- 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) {
269
313
  console.error('❌ 深度必须是正整数');
270
314
  process.exit(1);
271
315
  }
316
+ options.maxDepth = val;
317
+ }
318
+ else {
319
+ console.error('❌ -d/--depth 需要指定一个正整数参数');
320
+ process.exit(1);
272
321
  }
273
322
  i++;
274
323
  continue;
275
324
  }
276
325
  if (arg === '-e' || arg === '--extensions') {
277
- if (i + 1 < args.length) {
326
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
278
327
  options.extensions = args[++i].split(',').map(e => e.trim().toLowerCase().replace(/^\./, ''));
279
328
  }
329
+ else {
330
+ console.error('❌ -e/--extensions 需要指定后缀参数');
331
+ process.exit(1);
332
+ }
280
333
  i++;
281
334
  continue;
282
335
  }
283
336
  if (arg === '-t' || arg === '--to') {
284
- if (i + 1 < args.length) {
337
+ if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
285
338
  options.targetFormat = args[++i].trim().toLowerCase().replace(/^\./, '');
286
339
  }
340
+ else {
341
+ console.error('❌ -t/--to 需要指定目标格式');
342
+ process.exit(1);
343
+ }
287
344
  i++;
288
345
  continue;
289
346
  }
@@ -312,10 +369,15 @@ function parseArgs() {
312
369
  }
313
370
  if (arg === '-f' || arg === '--file') {
314
371
  // 收集 -f 后的所有文件
372
+ const start = i + 1;
315
373
  while (i + 1 < args.length && !args[i + 1].startsWith('-')) {
316
374
  i++;
317
375
  filesFromFlag.push(path.resolve(args[i]));
318
376
  }
377
+ if (start > i) {
378
+ console.error('❌ -f/--file 需要指定至少一个文件路径');
379
+ process.exit(1);
380
+ }
319
381
  i++;
320
382
  continue;
321
383
  }
@@ -403,14 +465,16 @@ function printHelp() {
403
465
  // ─────────────────────────────────────────
404
466
  // 图片处理
405
467
  // ─────────────────────────────────────────
406
- function getAllFiles(dir, extensions, recursive, currentDepth, maxDepth) {
468
+ function getAllFiles(dir, extensions, recursive, currentDepth, maxDepth, excludeDirName) {
407
469
  const files = [];
408
470
  try {
409
471
  const entries = fs.readdirSync(dir, { withFileTypes: true });
410
472
  for (const entry of entries) {
411
473
  const fullPath = path.join(dir, entry.name);
412
474
  if (entry.isDirectory() && recursive && currentDepth < maxDepth) {
413
- 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));
414
478
  }
415
479
  else if (entry.isFile()) {
416
480
  const ext = path.extname(entry.name).slice(1).toLowerCase();
@@ -431,7 +495,7 @@ function getOutputPath(inputPath, targetFormat, allInputFiles) {
431
495
  const basename = path.basename(inputPath, ext);
432
496
  const targetExt = targetFormat;
433
497
  let coreName = basename;
434
- const outputDir = path.join(dir, 'output');
498
+ const outputDir = path.join(dir, targetFormat);
435
499
  // 检查输入目录中是否有同名(不含扩展名)但不同后缀的文件
436
500
  // 如 photo.png 和 photo.jpg 会被判定为同名冲突
437
501
  const hasNameConflict = allInputFiles.some(f => {
@@ -440,7 +504,7 @@ function getOutputPath(inputPath, targetFormat, allInputFiles) {
440
504
  const fDir = path.dirname(f);
441
505
  const fExt = path.extname(f);
442
506
  const fBasename = path.basename(f, fExt);
443
- return fDir === dir && fBasename === basename && fExt.toLowerCase() !== ext.toLowerCase();
507
+ return fDir === dir && fBasename.toLowerCase() === basename.toLowerCase() && fExt.toLowerCase() !== ext.toLowerCase();
444
508
  });
445
509
  if (hasNameConflict) {
446
510
  // 找所有同basename的文件的序号
@@ -450,7 +514,7 @@ function getOutputPath(inputPath, targetFormat, allInputFiles) {
450
514
  const fDir = path.dirname(f);
451
515
  const fExt = path.extname(f);
452
516
  const fBasename = path.basename(f, fExt);
453
- return fDir === dir && fBasename === basename;
517
+ return fDir === dir && fBasename.toLowerCase() === basename.toLowerCase();
454
518
  });
455
519
  // 当前文件在所有同名文件中的索引(从1开始)
456
520
  const sortedMatches = [...allBasenameMatches, inputPath].sort();
@@ -462,22 +526,23 @@ function getOutputPath(inputPath, targetFormat, allInputFiles) {
462
526
  return path.join(outputDir, filename);
463
527
  }
464
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
+ }
465
536
  try {
466
- const outputPath = getOutputPath(inputPath, targetFormat, allInputFiles);
467
537
  const outputDir = path.dirname(outputPath);
468
538
  if (!fs.existsSync(outputDir)) {
469
539
  fs.mkdirSync(outputDir, { recursive: true });
470
540
  }
471
- const srcExt = path.extname(inputPath).slice(1).toLowerCase();
472
- const fmt = targetFormat.toLowerCase();
473
541
  // 同格式直接复制,避免重新编码导致质量损失
474
542
  if (srcExt === fmt || (srcExt === 'jpeg' && fmt === 'jpg') || (srcExt === 'jpg' && fmt === 'jpeg') || (srcExt === 'tif' && fmt === 'tiff') || (srcExt === 'tiff' && fmt === 'tif')) {
475
543
  fs.copyFileSync(inputPath, outputPath);
476
544
  return { success: true, outputPath };
477
545
  }
478
- if (!SUPPORTED_OUTPUT_FORMATS.includes(fmt)) {
479
- return { success: false, outputPath: inputPath, error: `不支持的目标格式: ${targetFormat},支持: ${SUPPORTED_OUTPUT_FORMATS.join(', ')}` };
480
- }
481
546
  const image = (0, sharp_1.default)(inputPath);
482
547
  switch (fmt) {
483
548
  case 'webp':
@@ -503,7 +568,7 @@ async function convertImage(inputPath, targetFormat, allInputFiles) {
503
568
  catch (err) {
504
569
  return {
505
570
  success: false,
506
- outputPath: inputPath,
571
+ outputPath,
507
572
  error: err instanceof Error ? err.message : '未知错误'
508
573
  };
509
574
  }
@@ -522,6 +587,8 @@ async function main() {
522
587
  uninstallContextMenu();
523
588
  return;
524
589
  }
590
+ // 自动检测版本变化并刷新右键菜单(npm update 不触发 postinstall)
591
+ autoUpdateContextMenu();
525
592
  console.log('\n🖼️ change-image-suffix - 图片格式批量转换工具\n');
526
593
  const options = parseArgs();
527
594
  // ─── 辅助函数:处理文件列表 ───
@@ -532,16 +599,18 @@ async function main() {
532
599
  console.log('----------------------------------------\n');
533
600
  let totalSuccess = 0;
534
601
  let totalFail = 0;
602
+ const failures = [];
535
603
  for (const filePath of files) {
536
604
  const ext = path.extname(filePath).slice(1).toLowerCase();
537
605
  if (!SUPPORTED_INPUT_EXTENSIONS.includes(ext)) {
538
606
  console.log(` ⚠️ 跳过(不支持格式): ${filePath}`);
539
607
  totalFail++;
608
+ failures.push(filePath);
540
609
  continue;
541
610
  }
542
611
  console.log(` 📄 文件: ${filePath}`);
543
612
  process.stdout.write(` 处理中: ${path.basename(filePath)} ... `);
544
- const result = await convertImage(filePath, options.targetFormat, [filePath]);
613
+ const result = await convertImage(filePath, options.targetFormat, files);
545
614
  if (result.success) {
546
615
  console.log(`✅ -> ${path.relative(path.dirname(filePath), result.outputPath)}`);
547
616
  totalSuccess++;
@@ -549,6 +618,13 @@ async function main() {
549
618
  else {
550
619
  console.log(`❌ 失败 (${result.error})`);
551
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}`);
552
628
  }
553
629
  }
554
630
  return { success: totalSuccess, fail: totalFail };
@@ -560,11 +636,13 @@ async function main() {
560
636
  console.log('----------------------------------------\n');
561
637
  let totalSuccess = 0;
562
638
  let totalFail = 0;
639
+ const failures = [];
563
640
  for (const inputPath of dirs) {
564
641
  const stat = fs.existsSync(inputPath) ? fs.statSync(inputPath) : null;
565
642
  if (!stat) {
566
643
  console.log(` ⚠️ 跳过(不存在): ${inputPath}`);
567
644
  totalFail++;
645
+ failures.push(inputPath);
568
646
  continue;
569
647
  }
570
648
  if (stat.isFile()) {
@@ -572,11 +650,12 @@ async function main() {
572
650
  if (!SUPPORTED_INPUT_EXTENSIONS.includes(ext)) {
573
651
  console.log(` ⚠️ 跳过(不支持格式): ${inputPath}`);
574
652
  totalFail++;
653
+ failures.push(inputPath);
575
654
  continue;
576
655
  }
577
656
  console.log(` 📄 文件: ${inputPath}`);
578
657
  process.stdout.write(` 处理中: ${path.basename(inputPath)} ... `);
579
- const result = await convertImage(inputPath, options.targetFormat, [inputPath]);
658
+ const result = await convertImage(inputPath, options.targetFormat, dirs);
580
659
  if (result.success) {
581
660
  console.log(`✅ -> ${path.relative(path.dirname(inputPath), result.outputPath)}`);
582
661
  totalSuccess++;
@@ -584,10 +663,11 @@ async function main() {
584
663
  else {
585
664
  console.log(`❌ 失败 (${result.error})`);
586
665
  totalFail++;
666
+ failures.push(inputPath);
587
667
  }
588
668
  }
589
669
  else {
590
- 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);
591
671
  console.log(` 📁 目录: ${inputPath} (${files.length} 个文件)`);
592
672
  if (files.length === 0) {
593
673
  console.log(' ✅ 没有找到图片文件');
@@ -603,10 +683,17 @@ async function main() {
603
683
  else {
604
684
  console.log(`❌ (${result.error})`);
605
685
  totalFail++;
686
+ failures.push(file);
606
687
  }
607
688
  }
608
689
  }
609
690
  }
691
+ if (failures.length > 0) {
692
+ console.log('\n❌ 失败的文件:');
693
+ for (const f of failures) {
694
+ console.log(` - ${f}`);
695
+ }
696
+ }
610
697
  return { success: totalSuccess, fail: totalFail };
611
698
  }
612
699
  // ─── 混合模式:同时有文件和目录 ───
@@ -640,7 +727,7 @@ async function main() {
640
727
  console.log(`📄 后缀: ${options.extensions.join(', ')}`);
641
728
  console.log(`🎯 目标格式: ${options.targetFormat}`);
642
729
  console.log('\n----------------------------------------\n');
643
- 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);
644
731
  if (files.length === 0) {
645
732
  console.log('✅ 没有找到需要转换的图片文件。');
646
733
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "change-image-suffix",
3
- "version": "2.1.2",
3
+ "version": "2.1.4",
4
4
  "description": "批量转换图片格式的CLI工具,支持递归搜索、深度限制、指定后缀、Windows右键菜单等功能",
5
5
  "main": "dist/index.js",
6
6
  "files": [
@@ -4,7 +4,7 @@ const os = require('os');
4
4
  const { execSync } = require('child_process');
5
5
  const path = require('path');
6
6
 
7
- if (os.platform() === 'win32') {
7
+ if (os.platform() === 'win32' && process.env.npm_config_global === 'true') {
8
8
  try {
9
9
  const indexJs = path.join(__dirname, '..', 'dist', 'index.js');
10
10
  execSync(`node "${indexJs}" uninstall-menu`, { stdio: 'inherit' });