change-image-suffix 1.18.2
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 +22 -0
- package/LICENSE +21 -0
- package/README.md +302 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/assets/output/icon.png +0 -0
- package/dist/index.js +783 -0
- package/package.json +70 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
### [1.18.2](https://github.com/GuoSirius/change-image-suffix/compare/v1.18.1...v1.18.2) (2026-05-19)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* add npm publish config and github release with changelog ([105e113](https://github.com/GuoSirius/change-image-suffix/commit/105e113c6ecd03085560be99ce5236240b68fa17))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* improve GitHub Actions workflow and standard-version integration ([01e1ce4](https://github.com/GuoSirius/change-image-suffix/commit/01e1ce4d1a8b8e56b120f162ab93e7f3a3311074))
|
|
16
|
+
* remove deprecated husky script lines ([7261d48](https://github.com/GuoSirius/change-image-suffix/commit/7261d4874337105255beeca690d8b6013bc86f1c))
|
|
17
|
+
* remove duplicate content in CHANGELOG.md ([db0f4b6](https://github.com/GuoSirius/change-image-suffix/commit/db0f4b6403935ed713e2df2d6d1029b1b17aabe4))
|
|
18
|
+
* replace inquirer with enquirer for CommonJS compatibility ([6fe88a5](https://github.com/GuoSirius/change-image-suffix/commit/6fe88a51487cb92117e0c487592048b07804c89a))
|
|
19
|
+
|
|
20
|
+
# Changelog
|
|
21
|
+
|
|
22
|
+
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.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 siriussupreme
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# change-image-suffix
|
|
2
|
+
|
|
3
|
+
🖼️ 批量转换图片格式的 CLI 工具,支持递归搜索、深度限制、Windows 右键菜单等功能。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
- 📁 支持指定目录或使用当前目录
|
|
8
|
+
- 🔄 支持递归搜索子目录
|
|
9
|
+
- 📏 支持递归深度限制
|
|
10
|
+
- 🎯 支持指定源文件后缀(png, jpg, gif 等)
|
|
11
|
+
- 🎨 支持多种目标格式(webp, jpg, png, avif, gif)
|
|
12
|
+
- 📤 输出到 `output/` 子目录
|
|
13
|
+
- 🔢 同名不同后缀文件自动编号(`_01`, `_02`)
|
|
14
|
+
- 🖱️ Windows 右键菜单集成
|
|
15
|
+
- ⚡ 基于 [sharp](https://sharp.pixel.glass/) 高性能图片处理
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 快速开始
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# 安装
|
|
23
|
+
npm install -g change-image-suffix
|
|
24
|
+
|
|
25
|
+
# 基本用法(转换当前目录图片为 webp)
|
|
26
|
+
cis
|
|
27
|
+
|
|
28
|
+
# 指定目录
|
|
29
|
+
cis -p ./images
|
|
30
|
+
|
|
31
|
+
# 递归转换
|
|
32
|
+
cis -r
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 常用命令
|
|
38
|
+
|
|
39
|
+
| 场景 | 命令 |
|
|
40
|
+
|------|------|
|
|
41
|
+
| 转换当前目录 → webp | `cis` |
|
|
42
|
+
| 转换指定目录 → webp | `cis -p ./photos` |
|
|
43
|
+
| 递归转换所有子目录 | `cis -r` |
|
|
44
|
+
| 递归,限制深度 2 层 | `cis -r -d 2` |
|
|
45
|
+
| 仅转换 png 和 jpg | `cis -e png,jpg` |
|
|
46
|
+
| 转换为 jpg 格式 | `cis -t jpg` |
|
|
47
|
+
| 转换为 avif(更小体积) | `cis -t avif` |
|
|
48
|
+
| 单个文件转换 | `cis -f ./banner.png` |
|
|
49
|
+
| 多文件批量转换 | `cis -f ./a.png ./b.jpg ./c.gif` |
|
|
50
|
+
| 多选文件/目录混合 | `cis file1.png dir1 file2.jpg` |
|
|
51
|
+
| 显示帮助 | `cis --help` |
|
|
52
|
+
|
|
53
|
+
### Windows 右键菜单
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# 注册右键菜单(只需执行一次)
|
|
57
|
+
cis install-menu
|
|
58
|
+
|
|
59
|
+
# 卸载右键菜单
|
|
60
|
+
cis uninstall-menu
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
注册后,在文件夹或图片文件上**右键 → 悬停**即可看到格式子菜单:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
🖼 转换图片 (cis) ← 悬停展开
|
|
67
|
+
├── 🌀 WebP ← 默认
|
|
68
|
+
├── 📷 JPG
|
|
69
|
+
├── 🖼 PNG
|
|
70
|
+
├── 📺 AVIF
|
|
71
|
+
├── 🎞 GIF
|
|
72
|
+
├── 📋 TIFF
|
|
73
|
+
├── 🍎 HEIF
|
|
74
|
+
└── 📐 JPEG2000
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**适用场景:**
|
|
78
|
+
- 📁 文件夹空白处右键 → 转换该目录所有图片
|
|
79
|
+
- 📁 文件夹图标上右键 → 转换该目录所有图片
|
|
80
|
+
- 🖼 图片文件上右键 → 转换该单个图片
|
|
81
|
+
- 🔢 支持多选文件/文件夹后右键
|
|
82
|
+
- 🔀 支持文件和目录混合选择 → 自动识别并处理所有图片
|
|
83
|
+
|
|
84
|
+
**注意:**
|
|
85
|
+
- 非图片文件右键显示菜单但不会处理(静默跳过)
|
|
86
|
+
- 混合选择时会获取所有选中项中的图片文件进行转换
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 命令行参数
|
|
91
|
+
|
|
92
|
+
| 参数 | 说明 | 默认值 |
|
|
93
|
+
|------|------|--------|
|
|
94
|
+
| `-p, --path <dir>` | 指定工作目录 | 当前目录 |
|
|
95
|
+
| `-r, --recursive` | 递归搜索子目录 | 否 |
|
|
96
|
+
| `-d, --depth <n>` | 递归深度限制 | 无限制 |
|
|
97
|
+
| `-e, --extensions` | 指定源后缀,逗号分隔 | png,jpg,jpeg,gif,bmp,tiff,webp |
|
|
98
|
+
| `-t, --to <format>` | 目标格式 | webp |
|
|
99
|
+
| `-f, --file <file>` | 指定单个文件转换 | - |
|
|
100
|
+
| `-h, --help` | 显示帮助 | - |
|
|
101
|
+
| `-v, --version` | 显示版本 | - |
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
## 文件命名规范
|
|
106
|
+
|
|
107
|
+
转换后的文件输出到 **`output/`** 子目录下,命名规则如下:
|
|
108
|
+
|
|
109
|
+
### 同名不同后缀 → 自动编号
|
|
110
|
+
|
|
111
|
+
当源目录中存在同名但不同扩展名的文件时,按字母顺序编号:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
源目录: photo.png + photo.jpg + photo.gif
|
|
115
|
+
输出: output/photo_01.webp
|
|
116
|
+
output/photo_02.webp
|
|
117
|
+
output/photo_03.webp
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 不同名或不同文件 → 直接覆盖
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
源目录: banner.png + logo.jpg
|
|
124
|
+
输出: output/banner.webp
|
|
125
|
+
output/logo.webp
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 同格式转换 → 直接覆盖
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
源目录: photo.webp
|
|
132
|
+
输出: output/photo.webp (直接覆盖,无双重后缀)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### 输出目录结构
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
📁 原目录/
|
|
139
|
+
├── 📁 output/ ← 转换后的文件
|
|
140
|
+
│ ├── photo.webp
|
|
141
|
+
│ ├── banner.jpg
|
|
142
|
+
│ └── photo_01.png
|
|
143
|
+
├── photo.png
|
|
144
|
+
├── banner.jpg
|
|
145
|
+
└── logo.gif
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 支持的格式
|
|
151
|
+
|
|
152
|
+
| 类型 | 格式 |
|
|
153
|
+
|------|------|
|
|
154
|
+
| **输入** | png, jpg, jpeg, gif, bmp, tiff, webp, avif |
|
|
155
|
+
| **输出** | webp, jpg, png, avif, gif, tiff, heif, jp2 |
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 使用示例
|
|
160
|
+
|
|
161
|
+
### 示例 1:批量转换照片集
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
# 将 photos 目录下的所有图片递归转换为 webp
|
|
165
|
+
cis -r -p ./photos -t webp
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### 示例 2:压缩图片体积
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# 将图片转换为 avif(体积更小,画质接近)
|
|
172
|
+
cis -p ./screenshots -t avif
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### 示例 3:仅处理 PNG 图片
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
# 只转换 png 格式,输出为 jpg
|
|
179
|
+
cis -p ./icons -e png -t jpg
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### 示例 4:限制递归深度
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
# 只递归 1 层子目录
|
|
186
|
+
cis -r -d 1 -p ./project
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### 示例 5:单文件转换
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
# 转换单个文件,指定输出格式
|
|
193
|
+
cis -f ./avatar.png -t webp
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### 示例 6:多选文件/目录批量转换
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# 多选文件(空格分隔),每个文件的输出在其所在目录的 output/
|
|
200
|
+
cis ./photo1.png ./photo2.jpg ./folder3
|
|
201
|
+
|
|
202
|
+
# 多选多个目录
|
|
203
|
+
cis ./images ./icons ./logos
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
> 💡 **多选时**:每个文件/目录的输出结果放在**各自所在目录的 `output/` 子目录**中,互不干扰。
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## 安装与更新
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
# 安装
|
|
214
|
+
npm install -g change-image-suffix
|
|
215
|
+
|
|
216
|
+
# 更新
|
|
217
|
+
npm update -g change-image-suffix
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## 开发
|
|
223
|
+
|
|
224
|
+
```bash
|
|
225
|
+
# 克隆项目
|
|
226
|
+
git clone https://github.com/GuoSirius/change-image-suffix.git
|
|
227
|
+
cd change-image-suffix
|
|
228
|
+
|
|
229
|
+
# 安装依赖
|
|
230
|
+
npm install
|
|
231
|
+
|
|
232
|
+
# 编译 TypeScript
|
|
233
|
+
npm run build
|
|
234
|
+
|
|
235
|
+
# 链接到全局(开发时)
|
|
236
|
+
npm link
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### 提交规范
|
|
240
|
+
|
|
241
|
+
项目使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范,提交信息格式:
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
<type>(<scope>): <description>
|
|
245
|
+
|
|
246
|
+
[optional body]
|
|
247
|
+
|
|
248
|
+
[optional footer(s)]
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**type 类型:**
|
|
252
|
+
- `feat`: 新功能
|
|
253
|
+
- `fix`: 修复bug
|
|
254
|
+
- `docs`: 文档更新
|
|
255
|
+
- `style`: 代码格式(不影响代码运行的变动)
|
|
256
|
+
- `refactor`: 重构
|
|
257
|
+
- `perf`: 性能优化
|
|
258
|
+
- `test`: 测试相关
|
|
259
|
+
- `chore`: 构建/工具类改动
|
|
260
|
+
|
|
261
|
+
### 发布流程
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
# 执行一键发布脚本
|
|
265
|
+
npm run release
|
|
266
|
+
|
|
267
|
+
# 或指定版本类型
|
|
268
|
+
npm run release:patch # 修复bug
|
|
269
|
+
npm run release:minor # 新增功能
|
|
270
|
+
npm run release:major # 重大变更
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
发布脚本会自动:
|
|
274
|
+
1. 检测未提交文件并提示提交
|
|
275
|
+
2. 交互式选择版本更新类型
|
|
276
|
+
3. 更新版本号和 CHANGELOG.md
|
|
277
|
+
4. 推送至远程仓库
|
|
278
|
+
5. 发布到 npm
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## 项目结构
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
├── .github/workflows/ # GitHub Actions 工作流
|
|
286
|
+
│ └── release.yml # 自动发布工作流
|
|
287
|
+
├── .husky/ # Git 钩子
|
|
288
|
+
│ └── commit-msg # 提交信息校验钩子
|
|
289
|
+
├── scripts/ # 脚本目录
|
|
290
|
+
│ └── release.js # 一键发布脚本
|
|
291
|
+
├── src/ # 源代码
|
|
292
|
+
│ └── index.ts # 主入口
|
|
293
|
+
├── .commitlintrc.json # commitlint 配置
|
|
294
|
+
├── CHANGELOG.md # 变更日志
|
|
295
|
+
└── package.json # 项目配置
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## License
|
|
301
|
+
|
|
302
|
+
MIT
|
package/assets/icon.ico
ADDED
|
Binary file
|
package/assets/icon.png
ADDED
|
Binary file
|
|
Binary file
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
37
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
38
|
+
};
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const os = __importStar(require("os"));
|
|
43
|
+
const child_process_1 = require("child_process");
|
|
44
|
+
const sharp_1 = __importDefault(require("sharp"));
|
|
45
|
+
// 默认配置
|
|
46
|
+
const DEFAULT_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'webp'];
|
|
47
|
+
const DEFAULT_TARGET_FORMAT = 'webp';
|
|
48
|
+
// ─────────────────────────────────────────
|
|
49
|
+
// 右键菜单管理(仅 Windows)
|
|
50
|
+
// ─────────────────────────────────────────
|
|
51
|
+
function requireWindows() {
|
|
52
|
+
if (os.platform() !== 'win32') {
|
|
53
|
+
console.error('❌ 右键菜单功能仅支持 Windows 系统');
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 写注册表 key(调用 reg.exe,无需管理员权限写 HKCU)
|
|
59
|
+
*/
|
|
60
|
+
function regAdd(key, name, value, type = 'REG_SZ') {
|
|
61
|
+
const cmd = `reg add "${key}" /v "${name}" /t ${type} /d "${value}" /f`;
|
|
62
|
+
(0, child_process_1.execSync)(cmd, { stdio: 'ignore' });
|
|
63
|
+
}
|
|
64
|
+
function regAddDefault(key, value) {
|
|
65
|
+
const cmd = `reg add "${key}" /ve /d "${value}" /f`;
|
|
66
|
+
(0, child_process_1.execSync)(cmd, { stdio: 'ignore' });
|
|
67
|
+
}
|
|
68
|
+
function regDelete(key) {
|
|
69
|
+
try {
|
|
70
|
+
(0, child_process_1.execSync)(`reg delete "${key}" /f`, { stdio: 'ignore' });
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// 忽略不存在的键
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function installContextMenu() {
|
|
77
|
+
requireWindows();
|
|
78
|
+
// ── 查找 cis.cmd 和 node_modules 路径 ──
|
|
79
|
+
let cisCmd = '';
|
|
80
|
+
let nodeModulesDir = '';
|
|
81
|
+
try {
|
|
82
|
+
cisCmd = (0, child_process_1.execSync)('where cis.cmd', { encoding: 'utf8' }).trim().split('\n')[0].trim();
|
|
83
|
+
nodeModulesDir = path.dirname(cisCmd);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
try {
|
|
87
|
+
cisCmd = (0, child_process_1.execSync)('where cis', { encoding: 'utf8' }).trim().split('\n')[0].trim();
|
|
88
|
+
nodeModulesDir = path.dirname(cisCmd);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
console.error('❌ 找不到 cis 命令,请先执行 npm link 或 npm install -g change-image-suffix');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// ── 复制 ICO ──
|
|
96
|
+
const appDataDir = path.join(os.homedir(), 'AppData', 'Roaming', 'change-image-suffix');
|
|
97
|
+
if (!fs.existsSync(appDataDir)) {
|
|
98
|
+
fs.mkdirSync(appDataDir, { recursive: true });
|
|
99
|
+
}
|
|
100
|
+
const icoTarget = path.join(appDataDir, 'icon.ico');
|
|
101
|
+
const icoSource = path.join(__dirname, '..', 'assets', 'icon.ico');
|
|
102
|
+
if (fs.existsSync(icoSource)) {
|
|
103
|
+
fs.copyFileSync(icoSource, icoTarget);
|
|
104
|
+
}
|
|
105
|
+
const iconPath = fs.existsSync(icoTarget) ? icoTarget : cisCmd;
|
|
106
|
+
// ── 辅助脚本路径定义(需要在 batContent 之前,因为 bat 中引用了 ps1Path)──
|
|
107
|
+
const batPath = path.join(appDataDir, 'cis_file.bat');
|
|
108
|
+
const ps1Path = path.join(appDataDir, 'cis_getfiles.ps1');
|
|
109
|
+
// cis_getfiles.ps1: 通过 Shell.Application COM 获取 Explorer 选中文件
|
|
110
|
+
const cisGetfilesContent = `
|
|
111
|
+
Add-Type -AssemblyName Microsoft.VisualBasic
|
|
112
|
+
Add-Type -AssemblyName UIAutomationClient
|
|
113
|
+
$files = @()
|
|
114
|
+
try {
|
|
115
|
+
$shell = New-Object -ComObject Shell.Application
|
|
116
|
+
$windows = $shell.Windows()
|
|
117
|
+
foreach ($win in $windows) {
|
|
118
|
+
if ($win -and $win.FullName -like "*explorer.exe") {
|
|
119
|
+
$selected = $win.Document.SelectedItems()
|
|
120
|
+
foreach ($item in $selected) {
|
|
121
|
+
if ($item -and $item.Path) {
|
|
122
|
+
$files += $item.Path
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} catch {}
|
|
128
|
+
if ($files.Count -gt 0) {
|
|
129
|
+
$files | ForEach-Object { $_ }
|
|
130
|
+
} else {
|
|
131
|
+
Write-Output "NO_FILES"
|
|
132
|
+
}
|
|
133
|
+
`;
|
|
134
|
+
fs.writeFileSync(ps1Path, cisGetfilesContent, 'utf8');
|
|
135
|
+
// ── bat 脚本:接收 Windows 传递的文件路径和格式参数 ──
|
|
136
|
+
// 根据 Windows ExtendedSubCommandsKey 机制:
|
|
137
|
+
// - 子命令的 command 参数(格式)在前
|
|
138
|
+
// - Windows 自动将父命令收到的文件路径追加在末尾
|
|
139
|
+
// - 最终执行: cmd /c "bat" "格式" "文件路径"
|
|
140
|
+
// 移除 AppliesTo 限制后,bat 需要过滤非图片文件
|
|
141
|
+
const batContent = `
|
|
142
|
+
@echo off
|
|
143
|
+
chcp 65001 >nul
|
|
144
|
+
setlocal enabledelayedexpansion
|
|
145
|
+
|
|
146
|
+
REM Get this script's directory (no trailing backslash)
|
|
147
|
+
set "SCRIPT_DIR=%~dp0"
|
|
148
|
+
set "SCRIPT_DIR=!SCRIPT_DIR:~0,-1!"
|
|
149
|
+
|
|
150
|
+
REM Get cis.cmd path
|
|
151
|
+
for /f "delims=" %%c in ('where cis.cmd 2^>nul') do set "CIS_CMD=%%c"
|
|
152
|
+
|
|
153
|
+
REM Supported image extensions
|
|
154
|
+
set "SUPPORTED_EXT=.png;.jpg;.jpeg;.gif;.bmp;.tiff;.tif;.webp;.avif"
|
|
155
|
+
|
|
156
|
+
REM %1 = format (from subcommand), %2 = file path (from Windows)
|
|
157
|
+
if "%~1"=="" (
|
|
158
|
+
echo Error: No format specified.
|
|
159
|
+
timeout /t 2 >nul
|
|
160
|
+
goto :done
|
|
161
|
+
)
|
|
162
|
+
set "format=%~1"
|
|
163
|
+
|
|
164
|
+
REM Collect all image files
|
|
165
|
+
set "fileList="
|
|
166
|
+
|
|
167
|
+
REM Windows passes file path as %2. If multiple files selected, use PowerShell.
|
|
168
|
+
REM Otherwise use direct argument.
|
|
169
|
+
if "%~2"=="" (
|
|
170
|
+
REM Multi-file via PowerShell (with retries for reliability)
|
|
171
|
+
for /f "delims=" %%i in ('powershell -ExecutionPolicy Bypass -File "!SCRIPT_DIR!\\cis_getfiles.ps1"') do (
|
|
172
|
+
if not "%%i"=="NO_FILES" (
|
|
173
|
+
call :add_if_image "%%i"
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
) else (
|
|
177
|
+
REM Single file or multiple via %2 (space-separated)
|
|
178
|
+
REM Split space-separated paths
|
|
179
|
+
for %%F in (%~2) do (
|
|
180
|
+
call :add_if_image "%%F"
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
REM Process all collected files - use start /b to avoid new window
|
|
185
|
+
if not "!fileList!"=="" (
|
|
186
|
+
start "" /b cmd /c "!CIS_CMD! -t !format! !fileList!"
|
|
187
|
+
)
|
|
188
|
+
goto :done
|
|
189
|
+
|
|
190
|
+
:add_if_image
|
|
191
|
+
set "filePath=%~1"
|
|
192
|
+
REM Skip if empty or NO_FILES
|
|
193
|
+
if "!filePath!"=="" exit /b
|
|
194
|
+
if "!filePath!"=="NO_FILES" exit /b
|
|
195
|
+
|
|
196
|
+
REM Get file extension
|
|
197
|
+
for %%E in ("!filePath!") do set "ext=%%~xE"
|
|
198
|
+
if "!ext!"=="" exit /b
|
|
199
|
+
|
|
200
|
+
REM Convert to lowercase
|
|
201
|
+
set "ext_lower=!ext!"
|
|
202
|
+
call set "ext_lower=%%ext_lower:A=a%%
|
|
203
|
+
call set "ext_lower=%%ext_lower:B=b%%
|
|
204
|
+
call set "ext_lower=%%ext_lower:C=c%%
|
|
205
|
+
call set "ext_lower=%%ext_lower:D=d%%
|
|
206
|
+
call set "ext_lower=%%ext_lower:E=e%%
|
|
207
|
+
call set "ext_lower=%%ext_lower:F=f%%
|
|
208
|
+
call set "ext_lower=%%ext_lower:G=g%%
|
|
209
|
+
call set "ext_lower=%%ext_lower:H=h%%
|
|
210
|
+
call set "ext_lower=%%ext_lower:I=i%%
|
|
211
|
+
call set "ext_lower=%%ext_lower:J=j%%
|
|
212
|
+
call set "ext_lower=%%ext_lower:K=k%%
|
|
213
|
+
call set "ext_lower=%%ext_lower:L=l%%
|
|
214
|
+
call set "ext_lower=%%ext_lower:M=m%%
|
|
215
|
+
call set "ext_lower=%%ext_lower:N=n%%
|
|
216
|
+
call set "ext_lower=%%ext_lower:O=o%%
|
|
217
|
+
call set "ext_lower=%%ext_lower:P=p%%
|
|
218
|
+
call set "ext_lower=%%ext_lower:Q=q%%
|
|
219
|
+
call set "ext_lower=%%ext_lower:R=r%%
|
|
220
|
+
call set "ext_lower=%%ext_lower:S=s%%
|
|
221
|
+
call set "ext_lower=%%ext_lower:T=t%%
|
|
222
|
+
call set "ext_lower=%%ext_lower:U=u%%
|
|
223
|
+
call set "ext_lower=%%ext_lower:V=v%%
|
|
224
|
+
call set "ext_lower=%%ext_lower:W=w%%
|
|
225
|
+
call set "ext_lower=%%ext_lower:X=x%%
|
|
226
|
+
call set "ext_lower=%%ext_lower:Y=y%%
|
|
227
|
+
call set "ext_lower=%%ext_lower:Z=z%%"
|
|
228
|
+
|
|
229
|
+
REM Check if extension is supported
|
|
230
|
+
echo !SUPPORTED_EXT! | findstr /i /c:"!ext_lower!" >nul 2>&1
|
|
231
|
+
if !errorlevel!==0 (
|
|
232
|
+
set "fileList=!fileList! -f "!filePath!""
|
|
233
|
+
)
|
|
234
|
+
exit /b
|
|
235
|
+
|
|
236
|
+
:done
|
|
237
|
+
endlocal
|
|
238
|
+
exit
|
|
239
|
+
|
|
240
|
+
exit
|
|
241
|
+
|
|
242
|
+
exit
|
|
243
|
+
|
|
244
|
+
exit
|
|
245
|
+
|
|
246
|
+
`;
|
|
247
|
+
fs.writeFileSync(batPath, batContent, 'utf8');
|
|
248
|
+
// ── 格式列表(webp 排第一,其他按常见程度排序)──
|
|
249
|
+
const formats = [
|
|
250
|
+
{ verb: 'webp', label: '🌀 WebP' },
|
|
251
|
+
{ verb: 'jpg', label: '📷 JPG' },
|
|
252
|
+
{ verb: 'png', label: '🖼 PNG' },
|
|
253
|
+
{ verb: 'avif', label: '📺 AVIF' },
|
|
254
|
+
{ verb: 'gif', label: '🎞 GIF' },
|
|
255
|
+
{ verb: 'tiff', label: '📋 TIFF' },
|
|
256
|
+
{ verb: 'heif', label: '🍎 HEIF' },
|
|
257
|
+
{ verb: 'jp2', label: '📐 JPEG2000' },
|
|
258
|
+
];
|
|
259
|
+
// ── 使用 ExtendedSubCommandsKey 方式(PowerShell 7 同款)──
|
|
260
|
+
// 主菜单项配置(每个菜单类型有独立的子菜单路径)
|
|
261
|
+
// 注意:文件右键和目录右键都使用 bat + PowerShell 获取选中文件
|
|
262
|
+
// 这样混合选择时也能处理所有选中项
|
|
263
|
+
const menuBases = [
|
|
264
|
+
{ base: 'HKCU\\Software\\Classes\\Directory\\Background\\shell\\cis', subMenu: 'Directory\\ContextMenus\\cis', arg: '-p "%V"' },
|
|
265
|
+
{ base: 'HKCU\\Software\\Classes\\Directory\\shell\\cis', subMenu: 'Directory\\ContextMenus\\cis_dir', useBat: true },
|
|
266
|
+
{ base: 'HKCU\\Software\\Classes\\*\\shell\\cis', subMenu: 'Directory\\ContextMenus\\cis_file', useBat: true },
|
|
267
|
+
];
|
|
268
|
+
// 1. 注册公共子菜单(每种菜单类型独立注册)
|
|
269
|
+
const REG_ROOT = 'HKCU\\Software\\Classes\\';
|
|
270
|
+
for (const menu of menuBases) {
|
|
271
|
+
for (const fmt of formats) {
|
|
272
|
+
const shellKey = `${REG_ROOT}${menu.subMenu}\\shell\\${fmt.verb}`;
|
|
273
|
+
let cmd;
|
|
274
|
+
if (menu.useBat) {
|
|
275
|
+
// 文件右键:子命令传递格式参数,Windows 自动追加文件路径
|
|
276
|
+
// 最终执行: cmd /c "bat" "格式" "文件路径"
|
|
277
|
+
cmd = `"${batPath}" ${fmt.verb}`;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
cmd = `"${cisCmd}" -t ${fmt.verb} ${menu.arg}`;
|
|
281
|
+
}
|
|
282
|
+
(0, child_process_1.execSync)(`reg add "${shellKey}" /ve /d "${fmt.label}" /f`, { stdio: 'ignore' });
|
|
283
|
+
(0, child_process_1.execSync)(`reg add "${shellKey}" /v Icon /d "${iconPath}" /f`, { stdio: 'ignore' });
|
|
284
|
+
// 直接调用 bat,不需要 cmd /c,Windows 会自动追加文件路径
|
|
285
|
+
(0, child_process_1.execSync)(`reg add "${shellKey}\\command" /ve /d "${cmd}" /f`, { stdio: 'ignore' });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// 2. 注册主菜单项(使用 ExtendedSubCommandsKey 关联各自的子菜单)
|
|
289
|
+
for (const menu of menuBases) {
|
|
290
|
+
(0, child_process_1.execSync)(`reg add "${menu.base}" /ve /d "🖼 转换图片 (cis)" /f`, { stdio: 'ignore' });
|
|
291
|
+
(0, child_process_1.execSync)(`reg add "${menu.base}" /v Icon /d "${iconPath}" /f`, { stdio: 'ignore' });
|
|
292
|
+
(0, child_process_1.execSync)(`reg add "${menu.base}" /v ExtendedSubCommandsKey /d "${menu.subMenu}" /f`, { stdio: 'ignore' });
|
|
293
|
+
// 使用 bat 的菜单(文件右键和目录右键):设置 command 接收文件路径 %1
|
|
294
|
+
// Windows 会将父命令收到的 %1 自动传递给子命令
|
|
295
|
+
if (menu.useBat) {
|
|
296
|
+
(0, child_process_1.execSync)(`reg add "${menu.base}\\command" /ve /d "cmd /c echo %1 > nul" /f`, { stdio: 'ignore' });
|
|
297
|
+
// 注意:不添加 AppliesTo 限制,让菜单始终显示
|
|
298
|
+
// bat 脚本会检查文件扩展名,自动忽略非图片文件
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
console.log('✅ 右键菜单安装成功!');
|
|
302
|
+
console.log(' 📁 文件夹空白处/图标右键 → 悬停展开格式子菜单');
|
|
303
|
+
console.log(' 🖼 图片文件上右键 → 悬停展开格式子菜单');
|
|
304
|
+
console.log(' ⚠️ 非图片文件右键 → 菜单显示但不处理');
|
|
305
|
+
console.log(` 📂 输出目录: <原目录>/output/`);
|
|
306
|
+
console.log('\n💡 提示:如需卸载,执行 cis uninstall-menu');
|
|
307
|
+
}
|
|
308
|
+
function uninstallContextMenu() {
|
|
309
|
+
requireWindows();
|
|
310
|
+
// 删除主菜单项
|
|
311
|
+
const mainKeys = [
|
|
312
|
+
'HKCU\\Software\\Classes\\Directory\\Background\\shell\\cis',
|
|
313
|
+
'HKCU\\Software\\Classes\\Directory\\shell\\cis',
|
|
314
|
+
'HKCU\\Software\\Classes\\*\\shell\\cis',
|
|
315
|
+
];
|
|
316
|
+
for (const key of mainKeys) {
|
|
317
|
+
try {
|
|
318
|
+
(0, child_process_1.execSync)(`reg delete "${key}" /f`, { stdio: 'ignore' });
|
|
319
|
+
}
|
|
320
|
+
catch { /* ignore */ }
|
|
321
|
+
}
|
|
322
|
+
// 删除公共子菜单(三个:目录空白、目录图标、文件)
|
|
323
|
+
const subMenuRoots = [
|
|
324
|
+
'HKCU\\Software\\Classes\\Directory\\ContextMenus\\cis',
|
|
325
|
+
'HKCU\\Software\\Classes\\Directory\\ContextMenus\\cis_dir',
|
|
326
|
+
'HKCU\\Software\\Classes\\Directory\\ContextMenus\\cis_file',
|
|
327
|
+
];
|
|
328
|
+
for (const root of subMenuRoots) {
|
|
329
|
+
try {
|
|
330
|
+
(0, child_process_1.execSync)(`reg delete "${root}" /f`, { stdio: 'ignore' });
|
|
331
|
+
}
|
|
332
|
+
catch { /* ignore */ }
|
|
333
|
+
}
|
|
334
|
+
// 删除批处理文件和 PowerShell 脚本
|
|
335
|
+
const appDataDir = path.join(os.homedir(), 'AppData', 'Roaming', 'change-image-suffix');
|
|
336
|
+
const batPath = path.join(appDataDir, 'cis_file.bat');
|
|
337
|
+
const ps1Path = path.join(appDataDir, 'cis_getfiles.ps1');
|
|
338
|
+
try {
|
|
339
|
+
fs.unlinkSync(batPath);
|
|
340
|
+
}
|
|
341
|
+
catch { /* ignore */ }
|
|
342
|
+
try {
|
|
343
|
+
fs.unlinkSync(ps1Path);
|
|
344
|
+
}
|
|
345
|
+
catch { /* ignore */ }
|
|
346
|
+
console.log('✅ 右键菜单已卸载');
|
|
347
|
+
}
|
|
348
|
+
// ─────────────────────────────────────────
|
|
349
|
+
// 参数解析
|
|
350
|
+
// ─────────────────────────────────────────
|
|
351
|
+
function parseArgs() {
|
|
352
|
+
const args = process.argv.slice(2);
|
|
353
|
+
const options = {
|
|
354
|
+
directory: process.cwd(),
|
|
355
|
+
recursive: false,
|
|
356
|
+
maxDepth: Infinity,
|
|
357
|
+
extensions: [...DEFAULT_EXTENSIONS],
|
|
358
|
+
targetFormat: DEFAULT_TARGET_FORMAT,
|
|
359
|
+
};
|
|
360
|
+
// 用于分类收集的临时数组
|
|
361
|
+
let filesFromFlag = []; // -f 收集的文件
|
|
362
|
+
let dirsFromFlag = []; // -p 收集的目录
|
|
363
|
+
let positionalFiles = []; // 位置参数中的文件
|
|
364
|
+
let positionalDirs = []; // 位置参数中的目录
|
|
365
|
+
let i = 0;
|
|
366
|
+
while (i < args.length) {
|
|
367
|
+
const arg = args[i];
|
|
368
|
+
if (arg === '-r' || arg === '--recursive') {
|
|
369
|
+
options.recursive = true;
|
|
370
|
+
i++;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (arg === '-d' || arg === '--depth') {
|
|
374
|
+
if (i + 1 < args.length) {
|
|
375
|
+
options.maxDepth = parseInt(args[++i], 10);
|
|
376
|
+
if (isNaN(options.maxDepth) || options.maxDepth < 1) {
|
|
377
|
+
console.error('❌ 深度必须是正整数');
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
i++;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
if (arg === '-e' || arg === '--extensions') {
|
|
385
|
+
if (i + 1 < args.length) {
|
|
386
|
+
options.extensions = args[++i].split(',').map(e => e.trim().toLowerCase().replace(/^\./, ''));
|
|
387
|
+
}
|
|
388
|
+
i++;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (arg === '-t' || arg === '--to') {
|
|
392
|
+
if (i + 1 < args.length) {
|
|
393
|
+
options.targetFormat = args[++i].trim().toLowerCase().replace(/^\./, '');
|
|
394
|
+
}
|
|
395
|
+
i++;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (arg === '-h' || arg === '--help') {
|
|
399
|
+
printHelp();
|
|
400
|
+
process.exit(0);
|
|
401
|
+
}
|
|
402
|
+
if (arg === '-v' || arg === '--version') {
|
|
403
|
+
console.log('change-image-suffix v1.18.0');
|
|
404
|
+
process.exit(0);
|
|
405
|
+
}
|
|
406
|
+
if (arg === '-p' || arg === '--path') {
|
|
407
|
+
// 收集 -p 后的目录
|
|
408
|
+
const start = i + 1;
|
|
409
|
+
while (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
410
|
+
i++;
|
|
411
|
+
dirsFromFlag.push(path.resolve(args[i]));
|
|
412
|
+
}
|
|
413
|
+
// 如果 -p 后面没有参数,用当前目录
|
|
414
|
+
if (start > i) {
|
|
415
|
+
dirsFromFlag.push(path.resolve('.'));
|
|
416
|
+
}
|
|
417
|
+
i++;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (arg === '-f' || arg === '--file') {
|
|
421
|
+
// 收集 -f 后的所有文件
|
|
422
|
+
while (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
423
|
+
i++;
|
|
424
|
+
filesFromFlag.push(path.resolve(args[i]));
|
|
425
|
+
}
|
|
426
|
+
i++;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (!arg.startsWith('-')) {
|
|
430
|
+
// 位置参数:根据实际类型分类
|
|
431
|
+
const resolvedPath = path.resolve(arg);
|
|
432
|
+
if (fs.existsSync(resolvedPath)) {
|
|
433
|
+
if (fs.statSync(resolvedPath).isFile()) {
|
|
434
|
+
positionalFiles.push(resolvedPath);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
positionalDirs.push(resolvedPath);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
else {
|
|
441
|
+
// 文件不存在但不是以 - 开头,当作文件收集(后续验证会报错)
|
|
442
|
+
positionalFiles.push(resolvedPath);
|
|
443
|
+
}
|
|
444
|
+
i++;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
// 未知选项,跳过
|
|
448
|
+
i++;
|
|
449
|
+
}
|
|
450
|
+
// ─── 合并所有收集的内容 ───
|
|
451
|
+
// 合并文件:-f 收集的 + 位置参数中的文件
|
|
452
|
+
const allFiles = [...filesFromFlag, ...positionalFiles];
|
|
453
|
+
// 合并目录:-p 收集的 + 位置参数中的目录
|
|
454
|
+
const allDirs = [...dirsFromFlag, ...positionalDirs];
|
|
455
|
+
// ─── 确定最终模式 ───
|
|
456
|
+
// 情况1:只有文件(单文件/多文件模式)
|
|
457
|
+
if (allFiles.length > 0 && allDirs.length === 0) {
|
|
458
|
+
options.multiFiles = allFiles;
|
|
459
|
+
return options;
|
|
460
|
+
}
|
|
461
|
+
// 情况2:只有目录(单目录/多目录模式)
|
|
462
|
+
if (allDirs.length > 0 && allFiles.length === 0) {
|
|
463
|
+
if (allDirs.length === 1) {
|
|
464
|
+
options.directory = allDirs[0];
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
options.multiPaths = allDirs;
|
|
468
|
+
}
|
|
469
|
+
return options;
|
|
470
|
+
}
|
|
471
|
+
// 情况3:文件和目录混合(混合模式)
|
|
472
|
+
if (allFiles.length > 0 && allDirs.length > 0) {
|
|
473
|
+
options.multiFiles = allFiles;
|
|
474
|
+
options.multiPaths = allDirs;
|
|
475
|
+
return options;
|
|
476
|
+
}
|
|
477
|
+
// 情况4:没有任何路径参数,使用当前目录
|
|
478
|
+
return options;
|
|
479
|
+
}
|
|
480
|
+
function printHelp() {
|
|
481
|
+
console.log(`
|
|
482
|
+
🔄 change-image-suffix - 图片格式批量转换工具
|
|
483
|
+
|
|
484
|
+
用法:
|
|
485
|
+
change-image-suffix [选项]
|
|
486
|
+
cis [选项] # 简写
|
|
487
|
+
cis install-menu # 添加到 Windows 右键菜单
|
|
488
|
+
cis uninstall-menu # 从 Windows 右键菜单移除
|
|
489
|
+
|
|
490
|
+
选项:
|
|
491
|
+
-f, --file <file> 转换单个文件(右键文件时自动传入)
|
|
492
|
+
-p, --path <dir> 指定工作目录(默认: 当前目录)
|
|
493
|
+
-r, --recursive 递归搜索子目录
|
|
494
|
+
-d, --depth <n> 递归深度限制(需要 -r 选项)
|
|
495
|
+
-e, --extensions <ext> 指定要转换的后缀,逗号分隔(不含点号)
|
|
496
|
+
-t, --to <format> 转换到的目标格式(默认: webp)
|
|
497
|
+
-h, --help 显示帮助信息
|
|
498
|
+
-v, --version 显示版本信息
|
|
499
|
+
|
|
500
|
+
示例:
|
|
501
|
+
cis # 转换当前目录的图片为 webp
|
|
502
|
+
cis -f ./photo.png # 转换单个文件
|
|
503
|
+
cis -p ./images # 转换指定目录
|
|
504
|
+
cis -r # 递归转换当前目录
|
|
505
|
+
cis -r -d 2 -p ./images # 递归转换,深度限制为2
|
|
506
|
+
cis -e png,jpg -t jpg # png/jpg 转换为 jpg
|
|
507
|
+
cis install-menu # 注册 Windows 右键菜单
|
|
508
|
+
`);
|
|
509
|
+
}
|
|
510
|
+
// ─────────────────────────────────────────
|
|
511
|
+
// 图片处理
|
|
512
|
+
// ─────────────────────────────────────────
|
|
513
|
+
function getAllFiles(dir, extensions, recursive, currentDepth, maxDepth) {
|
|
514
|
+
const files = [];
|
|
515
|
+
try {
|
|
516
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
517
|
+
for (const entry of entries) {
|
|
518
|
+
const fullPath = path.join(dir, entry.name);
|
|
519
|
+
if (entry.isDirectory() && recursive && currentDepth < maxDepth) {
|
|
520
|
+
files.push(...getAllFiles(fullPath, extensions, recursive, currentDepth + 1, maxDepth));
|
|
521
|
+
}
|
|
522
|
+
else if (entry.isFile()) {
|
|
523
|
+
const ext = path.extname(entry.name).slice(1).toLowerCase();
|
|
524
|
+
if (extensions.includes(ext)) {
|
|
525
|
+
files.push(fullPath);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
console.warn(`⚠️ 无法读取目录: ${dir}`);
|
|
532
|
+
}
|
|
533
|
+
return files;
|
|
534
|
+
}
|
|
535
|
+
function getOutputPath(inputPath, targetFormat, allInputFiles) {
|
|
536
|
+
const dir = path.dirname(inputPath);
|
|
537
|
+
const ext = path.extname(inputPath);
|
|
538
|
+
const basename = path.basename(inputPath, ext);
|
|
539
|
+
const originalExt = ext.slice(1).toLowerCase();
|
|
540
|
+
const targetExt = targetFormat;
|
|
541
|
+
// 源格式与目标格式相同时,直接覆盖
|
|
542
|
+
let coreName = basename;
|
|
543
|
+
const outputDir = path.join(dir, 'output');
|
|
544
|
+
// 检查输入目录中是否有同名(不含扩展名)但不同后缀的文件
|
|
545
|
+
// 如 photo.png 和 photo.jpg 会被判定为同名冲突
|
|
546
|
+
const hasNameConflict = allInputFiles.some(f => {
|
|
547
|
+
if (f === inputPath)
|
|
548
|
+
return false;
|
|
549
|
+
const fDir = path.dirname(f);
|
|
550
|
+
const fExt = path.extname(f);
|
|
551
|
+
const fBasename = path.basename(f, fExt);
|
|
552
|
+
return fDir === dir && fBasename === basename && fExt.toLowerCase() !== ext.toLowerCase();
|
|
553
|
+
});
|
|
554
|
+
if (hasNameConflict) {
|
|
555
|
+
// 找所有同basename的文件的序号
|
|
556
|
+
const allBasenameMatches = allInputFiles.filter(f => {
|
|
557
|
+
if (f === inputPath)
|
|
558
|
+
return false;
|
|
559
|
+
const fDir = path.dirname(f);
|
|
560
|
+
const fExt = path.extname(f);
|
|
561
|
+
const fBasename = path.basename(f, fExt);
|
|
562
|
+
return fDir === dir && fBasename === basename;
|
|
563
|
+
});
|
|
564
|
+
// 当前文件在所有同名文件中的索引(从1开始)
|
|
565
|
+
const sortedMatches = [...allBasenameMatches, inputPath].sort();
|
|
566
|
+
const index = sortedMatches.indexOf(inputPath) + 1;
|
|
567
|
+
const padded = String(index).padStart(2, '0');
|
|
568
|
+
coreName = `${basename}_${padded}`;
|
|
569
|
+
}
|
|
570
|
+
const filename = `${coreName}.${targetExt}`;
|
|
571
|
+
return path.join(outputDir, filename);
|
|
572
|
+
}
|
|
573
|
+
async function convertImage(inputPath, targetFormat, allInputFiles) {
|
|
574
|
+
try {
|
|
575
|
+
const outputPath = getOutputPath(inputPath, targetFormat, allInputFiles);
|
|
576
|
+
// 确保 output 目录存在
|
|
577
|
+
const outputDir = path.dirname(outputPath);
|
|
578
|
+
if (!fs.existsSync(outputDir)) {
|
|
579
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
580
|
+
}
|
|
581
|
+
const image = (0, sharp_1.default)(inputPath);
|
|
582
|
+
switch (targetFormat.toLowerCase()) {
|
|
583
|
+
case 'webp':
|
|
584
|
+
await image.webp({ quality: 85 }).toFile(outputPath);
|
|
585
|
+
break;
|
|
586
|
+
case 'jpg':
|
|
587
|
+
case 'jpeg':
|
|
588
|
+
await image.jpeg({ quality: 85 }).toFile(outputPath);
|
|
589
|
+
break;
|
|
590
|
+
case 'png':
|
|
591
|
+
await image.png({ quality: 85 }).toFile(outputPath);
|
|
592
|
+
break;
|
|
593
|
+
case 'gif':
|
|
594
|
+
await image.gif().toFile(outputPath);
|
|
595
|
+
break;
|
|
596
|
+
case 'tiff':
|
|
597
|
+
case 'tif':
|
|
598
|
+
await image.tiff({ quality: 85 }).toFile(outputPath);
|
|
599
|
+
break;
|
|
600
|
+
case 'avif':
|
|
601
|
+
await image.avif({ quality: 85 }).toFile(outputPath);
|
|
602
|
+
break;
|
|
603
|
+
default: await image.toFormat(targetFormat).toFile(outputPath);
|
|
604
|
+
}
|
|
605
|
+
return { success: true, outputPath };
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
return {
|
|
609
|
+
success: false,
|
|
610
|
+
outputPath: inputPath,
|
|
611
|
+
error: err instanceof Error ? err.message : '未知错误'
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
// ─────────────────────────────────────────
|
|
616
|
+
// 入口
|
|
617
|
+
// ─────────────────────────────────────────
|
|
618
|
+
async function main() {
|
|
619
|
+
const firstArg = process.argv[2];
|
|
620
|
+
// 子命令:右键菜单管理
|
|
621
|
+
if (firstArg === 'install-menu') {
|
|
622
|
+
installContextMenu();
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (firstArg === 'uninstall-menu') {
|
|
626
|
+
uninstallContextMenu();
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
console.log('\n🖼️ change-image-suffix - 图片格式批量转换工具\n');
|
|
630
|
+
const options = parseArgs();
|
|
631
|
+
// ─── 辅助函数:处理文件列表 ───
|
|
632
|
+
async function processFiles(files, title) {
|
|
633
|
+
console.log(`\n🖼️ change-image-suffix - ${title}\n`);
|
|
634
|
+
console.log(`🎯 目标格式: ${options.targetFormat}`);
|
|
635
|
+
console.log(`📦 待处理: ${files.length} 个文件\n`);
|
|
636
|
+
console.log('----------------------------------------\n');
|
|
637
|
+
const supportedExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'avif'];
|
|
638
|
+
let totalSuccess = 0;
|
|
639
|
+
let totalFail = 0;
|
|
640
|
+
for (const filePath of files) {
|
|
641
|
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
|
642
|
+
if (!supportedExts.includes(ext)) {
|
|
643
|
+
console.log(` ⚠️ 跳过(不支持格式): ${filePath}`);
|
|
644
|
+
totalFail++;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
console.log(` 📄 文件: ${filePath}`);
|
|
648
|
+
process.stdout.write(` 处理中: ${path.basename(filePath)} ... `);
|
|
649
|
+
const result = await convertImage(filePath, options.targetFormat, [filePath]);
|
|
650
|
+
if (result.success) {
|
|
651
|
+
console.log(`✅ -> ${path.relative(path.dirname(filePath), result.outputPath)}`);
|
|
652
|
+
totalSuccess++;
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
console.log(`❌ 失败 (${result.error})`);
|
|
656
|
+
totalFail++;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return { success: totalSuccess, fail: totalFail };
|
|
660
|
+
}
|
|
661
|
+
// ─── 辅助函数:处理目录列表 ───
|
|
662
|
+
async function processDirs(dirs) {
|
|
663
|
+
console.log(`\n🎯 目标格式: ${options.targetFormat}`);
|
|
664
|
+
console.log(`📦 待处理: ${dirs.length} 个目录\n`);
|
|
665
|
+
console.log('----------------------------------------\n');
|
|
666
|
+
let totalSuccess = 0;
|
|
667
|
+
let totalFail = 0;
|
|
668
|
+
for (const inputPath of dirs) {
|
|
669
|
+
const stat = fs.existsSync(inputPath) ? fs.statSync(inputPath) : null;
|
|
670
|
+
if (!stat) {
|
|
671
|
+
console.log(` ⚠️ 跳过(不存在): ${inputPath}`);
|
|
672
|
+
totalFail++;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (stat.isFile()) {
|
|
676
|
+
const ext = path.extname(inputPath).slice(1).toLowerCase();
|
|
677
|
+
const supportedExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tiff', 'tif', 'webp', 'avif'];
|
|
678
|
+
if (!supportedExts.includes(ext)) {
|
|
679
|
+
console.log(` ⚠️ 跳过(不支持格式): ${inputPath}`);
|
|
680
|
+
totalFail++;
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
console.log(` 📄 文件: ${inputPath}`);
|
|
684
|
+
process.stdout.write(` 处理中: ${path.basename(inputPath)} ... `);
|
|
685
|
+
const result = await convertImage(inputPath, options.targetFormat, [inputPath]);
|
|
686
|
+
if (result.success) {
|
|
687
|
+
console.log(`✅ -> ${path.relative(path.dirname(inputPath), result.outputPath)}`);
|
|
688
|
+
totalSuccess++;
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
console.log(`❌ 失败 (${result.error})`);
|
|
692
|
+
totalFail++;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
else {
|
|
696
|
+
const files = getAllFiles(inputPath, options.extensions, options.recursive, 0, options.maxDepth);
|
|
697
|
+
console.log(` 📁 目录: ${inputPath} (${files.length} 个文件)`);
|
|
698
|
+
if (files.length === 0) {
|
|
699
|
+
console.log(' ✅ 没有找到图片文件');
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
for (const file of files) {
|
|
703
|
+
process.stdout.write(` 处理中: ${path.basename(file)} ... `);
|
|
704
|
+
const result = await convertImage(file, options.targetFormat, files);
|
|
705
|
+
if (result.success) {
|
|
706
|
+
console.log(`✅`);
|
|
707
|
+
totalSuccess++;
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
console.log(`❌ (${result.error})`);
|
|
711
|
+
totalFail++;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
return { success: totalSuccess, fail: totalFail };
|
|
717
|
+
}
|
|
718
|
+
// ─── 混合模式:同时有文件和目录 ───
|
|
719
|
+
if (options.multiFiles && options.multiFiles.length > 0 && options.multiPaths && options.multiPaths.length > 0) {
|
|
720
|
+
console.log('\n🖼️ change-image-suffix - 混合模式(文件+目录)\n');
|
|
721
|
+
const fileResult = await processFiles(options.multiFiles, '图片转换工具');
|
|
722
|
+
console.log('\n');
|
|
723
|
+
const dirResult = await processDirs(options.multiPaths);
|
|
724
|
+
console.log('\n----------------------------------------');
|
|
725
|
+
console.log(`📊 转换完成!成功: ${fileResult.success + dirResult.success}, 失败: ${fileResult.fail + dirResult.fail}\n`);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
// ─── 单/多文件模式 ───
|
|
729
|
+
if (options.multiFiles && options.multiFiles.length > 0) {
|
|
730
|
+
const result = await processFiles(options.multiFiles, '图片转换工具');
|
|
731
|
+
console.log('\n----------------------------------------\n');
|
|
732
|
+
console.log(`📊 转换完成!成功: ${result.success}, 失败: ${result.fail}\n`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
// ─── 多路径模式 ───
|
|
736
|
+
if (options.multiPaths) {
|
|
737
|
+
console.log(`\n🖼️ change-image-suffix - 批量转换工具\n`);
|
|
738
|
+
const result = await processDirs(options.multiPaths);
|
|
739
|
+
console.log('\n----------------------------------------');
|
|
740
|
+
console.log(`📊 转换完成!成功: ${result.success}, 失败: ${result.fail}\n`);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
// ─── 目录批量模式 ───
|
|
744
|
+
console.log(`📂 目录: ${options.directory}`);
|
|
745
|
+
console.log(`🔁 递归: ${options.recursive ? `是 (深度: ${options.maxDepth === Infinity ? '无限制' : options.maxDepth})` : '否'}`);
|
|
746
|
+
console.log(`📄 后缀: ${options.extensions.join(', ')}`);
|
|
747
|
+
console.log(`🎯 目标格式: ${options.targetFormat}`);
|
|
748
|
+
console.log('\n----------------------------------------\n');
|
|
749
|
+
const files = getAllFiles(options.directory, options.extensions, options.recursive, 0, options.maxDepth);
|
|
750
|
+
if (files.length === 0) {
|
|
751
|
+
console.log('✅ 没有找到需要转换的图片文件。');
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
console.log(`📋 找到 ${files.length} 个文件,准备开始转换...\n`);
|
|
755
|
+
let successCount = 0;
|
|
756
|
+
let failCount = 0;
|
|
757
|
+
const results = [];
|
|
758
|
+
for (const file of files) {
|
|
759
|
+
const relativePath = path.relative(options.directory, file);
|
|
760
|
+
process.stdout.write(` 处理中: ${relativePath} ... `);
|
|
761
|
+
const result = await convertImage(file, options.targetFormat, files);
|
|
762
|
+
if (result.success) {
|
|
763
|
+
const outputRelativePath = path.relative(options.directory, result.outputPath);
|
|
764
|
+
console.log(`✅ -> ${outputRelativePath}`);
|
|
765
|
+
results.push({ input: file, output: result.outputPath, status: 'success' });
|
|
766
|
+
successCount++;
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
console.log(`❌ 失败 (${result.error})`);
|
|
770
|
+
results.push({ input: file, output: '', status: 'fail' });
|
|
771
|
+
failCount++;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
console.log('\n----------------------------------------');
|
|
775
|
+
console.log(`\n📊 转换完成!成功: ${successCount}, 失败: ${failCount}\n`);
|
|
776
|
+
if (failCount > 0) {
|
|
777
|
+
console.log('❌ 失败的文件:');
|
|
778
|
+
for (const r of results.filter(x => x.status === 'fail')) {
|
|
779
|
+
console.log(` - ${r.input}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
main().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "change-image-suffix",
|
|
3
|
+
"version": "1.18.2",
|
|
4
|
+
"description": "批量转换图片格式的CLI工具,支持递归搜索、深度限制、指定后缀、Windows右键菜单等功能",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist/",
|
|
8
|
+
"assets/",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"CHANGELOG.md"
|
|
12
|
+
],
|
|
13
|
+
"bin": {
|
|
14
|
+
"change-image-suffix": "./dist/index.js",
|
|
15
|
+
"cis": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public",
|
|
19
|
+
"registry": "https://registry.npmjs.org/"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"clean": "rimraf dist",
|
|
23
|
+
"build": "tsc",
|
|
24
|
+
"prepublishOnly": "npm run clean && npm run build",
|
|
25
|
+
"release": "node scripts/release.js",
|
|
26
|
+
"release:patch": "node scripts/release.js --patch",
|
|
27
|
+
"release:minor": "node scripts/release.js --minor",
|
|
28
|
+
"release:major": "node scripts/release.js --major",
|
|
29
|
+
"lint": "echo \"Linting not configured yet\"",
|
|
30
|
+
"lint:fix": "echo \"Linting fix not configured yet\"",
|
|
31
|
+
"typecheck": "tsc --noEmit"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"cli",
|
|
35
|
+
"image",
|
|
36
|
+
"converter",
|
|
37
|
+
"webp",
|
|
38
|
+
"png",
|
|
39
|
+
"jpg",
|
|
40
|
+
"batch",
|
|
41
|
+
"sharp",
|
|
42
|
+
"context-menu"
|
|
43
|
+
],
|
|
44
|
+
"author": "siriussupreme",
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/GuoSirius/change-image-suffix.git"
|
|
49
|
+
},
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/GuoSirius/change-image-suffix/issues"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/GuoSirius/change-image-suffix#readme",
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"sharp": "^0.33.2"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@commitlint/cli": "^21.0.1",
|
|
59
|
+
"@commitlint/config-conventional": "^21.0.1",
|
|
60
|
+
"@types/node": "^20.11.0",
|
|
61
|
+
"enquirer": "^2.4.1",
|
|
62
|
+
"husky": "^9.1.7",
|
|
63
|
+
"rimraf": "^6.1.3",
|
|
64
|
+
"standard-version": "^9.5.0",
|
|
65
|
+
"typescript": "^5.3.3"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": ">=16.0.0"
|
|
69
|
+
}
|
|
70
|
+
}
|