mediac 0.7.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cmd/cmd_audiotag.js +3 -0
- package/cmd/cmd_compress.js +360 -0
- package/cmd/cmd_dcim.js +169 -0
- package/cmd/cmd_moveup.js +232 -0
- package/cmd/cmd_prefix.js +487 -0
- package/cmd/cmd_remove.js +461 -0
- package/cmd/cmd_rename.js +171 -0
- package/cmd/cmd_shared.js +191 -0
- package/index.js +1 -1
- package/labs/cjk_demo.js +46 -46
- package/labs/download_urls.js +74 -74
- package/labs/file_organize.js +90 -0
- package/labs/fs_demo.js +9 -11
- package/labs/make_thumbs.js +149 -0
- package/labs/string_format.js +68 -68
- package/labs/yargs_demo.js +24 -24
- package/lib/core.js +86 -0
- package/lib/debug.js +171 -135
- package/lib/exif.js +300 -295
- package/lib/file.js +168 -50
- package/lib/helper.js +246 -166
- package/lib/tools.js +21 -0
- package/lib/unicode.js +53 -50
- package/lib/walk.js +39 -0
- package/package.json +37 -17
- package/scripts/file_cli.js +153 -153
- package/scripts/media_cli.js +471 -370
- package/scripts/test.js +55 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* File: cmd_compress.js
|
|
3
|
+
* Created: 2024-03-15 20:34:49
|
|
4
|
+
* Modified: 2024-03-23 11:51:09
|
|
5
|
+
* Author: mcxiaoke (github@mcxiaoke.com)
|
|
6
|
+
* License: Apache License 2.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
import assert from "assert";
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import * as cliProgress from "cli-progress";
|
|
13
|
+
import dayjs from "dayjs";
|
|
14
|
+
import exif from 'exif-reader';
|
|
15
|
+
import fs from 'fs-extra';
|
|
16
|
+
import inquirer from "inquirer";
|
|
17
|
+
import { cpus } from "os";
|
|
18
|
+
import pMap from 'p-map';
|
|
19
|
+
import path from "path";
|
|
20
|
+
import sharp from "sharp";
|
|
21
|
+
import * as log from '../lib/debug.js';
|
|
22
|
+
import * as mf from '../lib/file.js';
|
|
23
|
+
import * as helper from '../lib/helper.js';
|
|
24
|
+
import { compressImage } from "./cmd_shared.js";
|
|
25
|
+
export { aliases, builder, command, describe, handler };
|
|
26
|
+
|
|
27
|
+
const command = "compress <input> [output]"
|
|
28
|
+
const aliases = ["cs", "cps"]
|
|
29
|
+
const describe = 'Compress input images to target size'
|
|
30
|
+
|
|
31
|
+
const QUALITY_DEFAULT = 86;
|
|
32
|
+
const SIZE_DEFAULT = 2048 // in kbytes
|
|
33
|
+
const WIDTH_DEFAULT = 6000;
|
|
34
|
+
|
|
35
|
+
const builder = function addOptions(ya, helpOrVersionSet) {
|
|
36
|
+
return ya.option("purge", {
|
|
37
|
+
alias: "p",
|
|
38
|
+
type: "boolean",
|
|
39
|
+
default: false,
|
|
40
|
+
description: "Purge original image files",
|
|
41
|
+
})
|
|
42
|
+
.option("purge-only", {
|
|
43
|
+
type: "boolean",
|
|
44
|
+
default: false,
|
|
45
|
+
description: "Just delete original image files only",
|
|
46
|
+
})
|
|
47
|
+
// 是否覆盖已存在的压缩后文件
|
|
48
|
+
.option("override", {
|
|
49
|
+
type: "boolean",
|
|
50
|
+
default: false,
|
|
51
|
+
description: "Override existing dst files",
|
|
52
|
+
})
|
|
53
|
+
// 压缩后文件质量参数
|
|
54
|
+
.option("quality", {
|
|
55
|
+
alias: "q",
|
|
56
|
+
type: "number",
|
|
57
|
+
default: QUALITY_DEFAULT,
|
|
58
|
+
description: "Target image file compress quality",
|
|
59
|
+
})
|
|
60
|
+
// 需要处理的最小文件大小
|
|
61
|
+
.option("size", {
|
|
62
|
+
alias: "s",
|
|
63
|
+
type: "number",
|
|
64
|
+
default: SIZE_DEFAULT,
|
|
65
|
+
description: "Processing file bigger than this size (unit:k)",
|
|
66
|
+
})
|
|
67
|
+
// 需要处理的图片最小尺寸
|
|
68
|
+
.option("width", {
|
|
69
|
+
alias: "w",
|
|
70
|
+
type: "number",
|
|
71
|
+
default: WIDTH_DEFAULT,
|
|
72
|
+
description: "Max width of long side of image thumb",
|
|
73
|
+
})
|
|
74
|
+
// 确认执行所有系统操作,非测试模式,如删除和重命名和移动操作
|
|
75
|
+
.option("doit", {
|
|
76
|
+
alias: "d",
|
|
77
|
+
type: "boolean",
|
|
78
|
+
default: false,
|
|
79
|
+
description: "execute os operations in real mode, not dry run",
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const handler = async function cmdCompress(argv) {
|
|
84
|
+
const testMode = !argv.doit;
|
|
85
|
+
const logTag = "cmdCompress";
|
|
86
|
+
const root = path.resolve(argv.input);
|
|
87
|
+
assert.strictEqual("string", typeof root, "root must be string");
|
|
88
|
+
if (!root || !(await fs.pathExists(root))) {
|
|
89
|
+
log.error(logTag, `Invalid Input: '${root}'`);
|
|
90
|
+
throw new Error(`Invalid Input: ${root}`);
|
|
91
|
+
}
|
|
92
|
+
if (!testMode) {
|
|
93
|
+
log.fileLog(`Root:${root}`, logTag);
|
|
94
|
+
log.fileLog(`Argv:${JSON.stringify(argv)}`, logTag);
|
|
95
|
+
}
|
|
96
|
+
log.show(logTag, argv);
|
|
97
|
+
const override = argv.override || false;
|
|
98
|
+
const quality = argv.quality || QUALITY_DEFAULT;
|
|
99
|
+
const minFileSize = (argv.size || SIZE_DEFAULT) * 1024;
|
|
100
|
+
const maxWidth = argv.width || WIDTH_DEFAULT;
|
|
101
|
+
const purgeOnly = argv.purgeOnly || false;
|
|
102
|
+
const purgeSource = argv.purge || false;
|
|
103
|
+
log.show(`${logTag} input:`, root);
|
|
104
|
+
|
|
105
|
+
const RE_THUMB = /Z4K|M4K|feature|web|thumb$/i;
|
|
106
|
+
const walkOpts = {
|
|
107
|
+
needStats: true,
|
|
108
|
+
entryFilter: (f) =>
|
|
109
|
+
f.stats.isFile()
|
|
110
|
+
&& !RE_THUMB.test(f.path)
|
|
111
|
+
&& f.stats.size > minFileSize
|
|
112
|
+
&& helper.isImageFile(f.path)
|
|
113
|
+
};
|
|
114
|
+
log.showGreen(logTag, `Walking files ...`);
|
|
115
|
+
let files = await mf.walk(root, walkOpts);
|
|
116
|
+
if (!files || files.length == 0) {
|
|
117
|
+
log.showYellow(logTag, "no files found, abort.");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
log.show(logTag, `total ${files.length} files found (all)`);
|
|
121
|
+
if (files.length == 0) {
|
|
122
|
+
log.showYellow("Nothing to do, abort.");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const confirmFiles = await inquirer.prompt([
|
|
126
|
+
{
|
|
127
|
+
type: "confirm",
|
|
128
|
+
name: "yes",
|
|
129
|
+
default: false,
|
|
130
|
+
message: chalk.bold.green(`Press y to continue processing...`),
|
|
131
|
+
},
|
|
132
|
+
]);
|
|
133
|
+
if (!confirmFiles.yes) {
|
|
134
|
+
log.showYellow("Will do nothing, aborted by user.");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const needBar = files.length > 9999 && !log.isVerbose();
|
|
138
|
+
log.showGreen(logTag, `preparing compress arguments...`);
|
|
139
|
+
let startMs = Date.now();
|
|
140
|
+
const addArgsFunc = async (f, i) => {
|
|
141
|
+
return {
|
|
142
|
+
...f,
|
|
143
|
+
total: files.length,
|
|
144
|
+
index: i,
|
|
145
|
+
quality,
|
|
146
|
+
override,
|
|
147
|
+
maxWidth,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
files = await Promise.all(files.map(addArgsFunc));
|
|
151
|
+
files.forEach((t, i) => {
|
|
152
|
+
t.bar1 = bar1;
|
|
153
|
+
t.needBar = needBar;
|
|
154
|
+
});
|
|
155
|
+
needBar && bar1.start(files.length, 0);
|
|
156
|
+
let tasks = await pMap(files, preCompress, { concurrency: cpus().length * 4 })
|
|
157
|
+
needBar && bar1.update(files.length);
|
|
158
|
+
needBar && bar1.stop();
|
|
159
|
+
log.info(logTag, "before filter: ", tasks.length);
|
|
160
|
+
const total = tasks.length;
|
|
161
|
+
tasks = tasks.filter((t) => t?.dst);
|
|
162
|
+
const skipped = total - tasks.length;
|
|
163
|
+
log.info(logTag, "after filter: ", tasks.length);
|
|
164
|
+
if (skipped > 0) {
|
|
165
|
+
log.showYellow(logTag, `${skipped} thumbs skipped`)
|
|
166
|
+
}
|
|
167
|
+
if (tasks.length == 0) {
|
|
168
|
+
log.showYellow("Nothing to do, abort.");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
tasks.forEach((t, i) => {
|
|
172
|
+
t.total = tasks.length;
|
|
173
|
+
t.index = i;
|
|
174
|
+
t.bar1 = null;
|
|
175
|
+
t.needBar = false;
|
|
176
|
+
});
|
|
177
|
+
log.show(logTag, `in ${helper.humanTime(startMs)} tasks:`)
|
|
178
|
+
tasks.slice(-1).forEach(t => {
|
|
179
|
+
log.show(helper._omit(t, "stats", "bar1"));
|
|
180
|
+
})
|
|
181
|
+
log.info(logTag, argv);
|
|
182
|
+
testMode && log.showYellow("++++++++++ TEST MODE (DRY RUN) ++++++++++")
|
|
183
|
+
|
|
184
|
+
if (purgeOnly) {
|
|
185
|
+
log.showYellow("+++++ PURGE ONLY (NO COMPRESS) +++++")
|
|
186
|
+
await purgeSrcFiles(tasks);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const answer = await inquirer.prompt([
|
|
190
|
+
{
|
|
191
|
+
type: "confirm",
|
|
192
|
+
name: "yes",
|
|
193
|
+
default: false,
|
|
194
|
+
message: chalk.bold.red(
|
|
195
|
+
`Are you sure to compress ${tasks.length} files? \n[Apply to files bigger than ${minFileSize / 1024}K, target long width is ${maxWidth}] \n${purgeSource ? "(Attention: you choose to delete original file!)" : "(Will keep original file)"}`
|
|
196
|
+
),
|
|
197
|
+
},
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
if (!answer.yes) {
|
|
201
|
+
log.showYellow("Will do nothing, aborted by user.");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (testMode) {
|
|
206
|
+
log.showYellow(logTag, `[DRY RUN], no thumbs generated.`)
|
|
207
|
+
} else {
|
|
208
|
+
startMs = Date.now();
|
|
209
|
+
log.showGreen(logTag, 'startAt', dayjs().format())
|
|
210
|
+
tasks.forEach(t => t.startMs = startMs);
|
|
211
|
+
tasks = await pMap(tasks, compressImage, { concurrency: cpus().length / 2 });
|
|
212
|
+
const okTasks = tasks.filter(t => t?.done);
|
|
213
|
+
const failedTasks = tasks.filter(t => t?.errorFlag && !t.done);
|
|
214
|
+
log.showGreen(logTag, `${okTasks.length} files compressed in ${helper.humanTime(startMs)}`)
|
|
215
|
+
log.showGreen(logTag, 'endAt', dayjs().format(), helper.humanTime(startMs))
|
|
216
|
+
if (failedTasks.length > 0) {
|
|
217
|
+
log.showYellow(logTag, `${okTasks.length} tasks are failed`);
|
|
218
|
+
const failedContent = failedTasks.map(t => t.src).join('\n');
|
|
219
|
+
const failedLogFile = path.join(root, `mediac_compress_failed_list_${dayjs().format("YYYYMMDDHHmmss")}.txt`);
|
|
220
|
+
await fs.writeFile(failedLogFile, failedContent);
|
|
221
|
+
const clickablePath = failedLogFile.split(path.sep).join("/");
|
|
222
|
+
log.showYellow(logTag, `failed filenames: file:///${clickablePath}`);
|
|
223
|
+
}
|
|
224
|
+
if (purgeSource) {
|
|
225
|
+
await purgeSrcFiles(tasks);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
let compressLastUpdatedAt = 0;
|
|
232
|
+
const bar1 = new cliProgress.SingleBar({ etaBuffer: 300 }, cliProgress.Presets.shades_classic);
|
|
233
|
+
// 文心一言注释 20231206
|
|
234
|
+
// 准备压缩图片的参数,并进行相应的处理
|
|
235
|
+
async function preCompress(f, options = {}) {
|
|
236
|
+
const logTag = 'PreCompress'
|
|
237
|
+
// log.debug("prepareCompressArgs options:", options); // 打印日志,显示选项参数
|
|
238
|
+
const maxWidth = options.maxWidth || 6000; // 获取最大宽度限制,默认为6000
|
|
239
|
+
let fileSrc = path.resolve(f.path); // 解析源文件路径
|
|
240
|
+
const [dir, base, ext] = helper.pathSplit(fileSrc); // 将路径分解为目录、基本名和扩展名
|
|
241
|
+
const fileDstTmp = path.join(dir, `_TMP_${base}.jpg`);
|
|
242
|
+
let fileDst = path.join(dir, `${base}_Z4K.jpg`); // 构建目标文件路径,添加压缩后的文件名后缀
|
|
243
|
+
fileSrc = path.resolve(fileSrc); // 解析源文件路径(再次确认)
|
|
244
|
+
fileDst = path.resolve(fileDst); // 解析目标文件路径(再次确认)
|
|
245
|
+
|
|
246
|
+
const timeNow = Date.now();
|
|
247
|
+
if (timeNow - compressLastUpdatedAt > 2 * 1000) {
|
|
248
|
+
f.needBar && f.bar1.update(f.index);
|
|
249
|
+
compressLastUpdatedAt = timeNow;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (await fs.pathExists(fileDst)) {
|
|
253
|
+
// 如果目标文件已存在,则进行相应的处理
|
|
254
|
+
log.info(logTag, "exists:", fileDst);
|
|
255
|
+
return {
|
|
256
|
+
...f,
|
|
257
|
+
width: 0,
|
|
258
|
+
height: 0,
|
|
259
|
+
src: fileSrc,
|
|
260
|
+
dst: fileDst,
|
|
261
|
+
tmpDst: fileDstTmp,
|
|
262
|
+
dstExists: true,
|
|
263
|
+
shouldSkip: true,
|
|
264
|
+
skipReason: 'DST EXISTS',
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const st = await fs.stat(fileSrc);
|
|
269
|
+
const m = await sharp(fileSrc).metadata();
|
|
270
|
+
try {
|
|
271
|
+
if (m?.exif) {
|
|
272
|
+
const md = exif(m.exif)?.Image;
|
|
273
|
+
if (md && (md.Copyright?.includes("mediac")
|
|
274
|
+
|| md.Software?.includes("mediac")
|
|
275
|
+
|| md.Artist?.includes("mediac"))) {
|
|
276
|
+
log.info(logTag, "skip:", fileDst);
|
|
277
|
+
return {
|
|
278
|
+
...f,
|
|
279
|
+
width: 0,
|
|
280
|
+
height: 0,
|
|
281
|
+
src: fileSrc,
|
|
282
|
+
dst: fileDst,
|
|
283
|
+
shouldSkip: true,
|
|
284
|
+
skipReason: 'MEDIAC MAKE',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch (error) {
|
|
289
|
+
log.warn(logTag, "exif", error.message, fileSrc);
|
|
290
|
+
log.fileLog(`ExifErr: <${fileSrc}> ${error.message}`, logTag);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const nw =
|
|
294
|
+
m.width > m.height ? maxWidth : Math.round((maxWidth * m.width) / m.height);
|
|
295
|
+
const nh = Math.round((nw * m.height) / m.width);
|
|
296
|
+
|
|
297
|
+
const dw = nw > m.width ? m.width : nw;
|
|
298
|
+
const dh = nh > m.height ? m.height : nh;
|
|
299
|
+
if (f.total < 9999) {
|
|
300
|
+
log.show(logTag, `${f.index}/${f.total}`,
|
|
301
|
+
helper.pathShort(fileSrc, 32),
|
|
302
|
+
`${m.width}x${m.height}=>${dw}x${dh} ${helper.humanSize(st.size)}`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
log.fileLog(`Pre: ${f.index}/${f.total} <${fileSrc}> ` +
|
|
306
|
+
`${dw}x${dh}) ${m.format} ${helper.humanSize(st.size)}`, logTag);
|
|
307
|
+
return {
|
|
308
|
+
...f,
|
|
309
|
+
width: dw,
|
|
310
|
+
height: dh,
|
|
311
|
+
src: fileSrc,
|
|
312
|
+
dst: fileDst,
|
|
313
|
+
tmpDst: fileDstTmp,
|
|
314
|
+
};
|
|
315
|
+
} catch (error) {
|
|
316
|
+
log.warn(logTag, "sharp", error.message, fileSrc);
|
|
317
|
+
log.fileLog(`SharpErr: ${f.index} <${fileSrc}> sharp:${error.message}`, logTag);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
async function purgeSrcFiles(results) {
|
|
323
|
+
const logTag = "Purge";
|
|
324
|
+
const toDelete = results.filter(t => t?.src && t.dstExists && t.dst);
|
|
325
|
+
const total = toDelete?.length ?? 0;
|
|
326
|
+
if (total <= 0) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const answer = await inquirer.prompt([
|
|
330
|
+
{
|
|
331
|
+
type: "confirm",
|
|
332
|
+
name: "yes",
|
|
333
|
+
default: false,
|
|
334
|
+
message: chalk.bold.red(
|
|
335
|
+
`Are you sure to delete ${total} original files?`
|
|
336
|
+
),
|
|
337
|
+
},
|
|
338
|
+
]);
|
|
339
|
+
if (!answer.yes) {
|
|
340
|
+
log.showYellow("Will do nothing, aborted by user.");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const deletecFunc = async (td, index) => {
|
|
344
|
+
const srcExists = await fs.pathExists(td.src);
|
|
345
|
+
const dstExists = await fs.pathExists(td.dst);
|
|
346
|
+
log.info(logTag, `Check S=${srcExists} D=${dstExists} ${helper.pathShort(td.src)}`)
|
|
347
|
+
// 确认文件存在,确保不会误删除
|
|
348
|
+
if (!(srcExists && dstExists)) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
await fs.pathExists(td.tmpDst) && await fs.remove(td.tmpDst);
|
|
352
|
+
await helper.safeRemove(td.src);
|
|
353
|
+
log.showYellow(logTag, `SafeDel: ${index}/${total} ${helper.pathShort(td.src)}`);
|
|
354
|
+
log.fileLog(`SafeDel: <${td.dst}>`, logTag);
|
|
355
|
+
return td.src;
|
|
356
|
+
}
|
|
357
|
+
const deleted = await pMap(toDelete, deletecFunc, { concurrency: cpus().length * 8 })
|
|
358
|
+
log.showCyan(logTag, `${deleted.filter(Boolean).length} files are safely removed`);
|
|
359
|
+
|
|
360
|
+
}
|
package/cmd/cmd_dcim.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* File: cmd_dcim.js
|
|
3
|
+
* Created: 2024-03-16 21:04:01
|
|
4
|
+
* Modified: 2024-03-23 11:51:18
|
|
5
|
+
* Author: mcxiaoke (github@mcxiaoke.com)
|
|
6
|
+
* License: Apache License 2.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import fs from 'fs-extra';
|
|
11
|
+
import inquirer from "inquirer";
|
|
12
|
+
import path from "path";
|
|
13
|
+
|
|
14
|
+
import { renameFiles } from "./cmd_shared.js";
|
|
15
|
+
|
|
16
|
+
import * as log from '../lib/debug.js';
|
|
17
|
+
import * as exif from '../lib/exif.js';
|
|
18
|
+
import * as helper from '../lib/helper.js';
|
|
19
|
+
|
|
20
|
+
const LOG_TAG = "DcimR";
|
|
21
|
+
|
|
22
|
+
export { aliases, builder, command, describe, handler };
|
|
23
|
+
|
|
24
|
+
const command = "dcimr <input> [options]"
|
|
25
|
+
const aliases = ["dm", "dcim"]
|
|
26
|
+
const describe = 'Rename media files by exif metadata eg. date'
|
|
27
|
+
|
|
28
|
+
const builder = function addOptions(ya, helpOrVersionSet) {
|
|
29
|
+
return ya.option("backup", {
|
|
30
|
+
// 备份原石文件
|
|
31
|
+
alias: "b",
|
|
32
|
+
type: "boolean",
|
|
33
|
+
default: false,
|
|
34
|
+
description: "backup original file before rename",
|
|
35
|
+
})
|
|
36
|
+
.option("fast", {
|
|
37
|
+
// 快速模式,使用文件修改时间,不解析EXIF
|
|
38
|
+
alias: "f",
|
|
39
|
+
type: "boolean",
|
|
40
|
+
description: "fast mode (use file modified time, no exif parse)",
|
|
41
|
+
})
|
|
42
|
+
.option("prefix", {
|
|
43
|
+
// 重命名后的文件前缀
|
|
44
|
+
alias: "p",
|
|
45
|
+
type: "string",
|
|
46
|
+
default: "IMG_/DSC_/VID_",
|
|
47
|
+
description: "custom filename prefix for raw/image/video files'",
|
|
48
|
+
})
|
|
49
|
+
.option("suffix", {
|
|
50
|
+
// 重命名后的后缀
|
|
51
|
+
alias: "s",
|
|
52
|
+
type: "string",
|
|
53
|
+
default: "",
|
|
54
|
+
description: "custom filename suffix",
|
|
55
|
+
})
|
|
56
|
+
.option("template", {
|
|
57
|
+
// 文件名模板,使用dayjs日期格式
|
|
58
|
+
alias: "t",
|
|
59
|
+
type: "string",
|
|
60
|
+
default: "YYYYMMDD_HHmmss",
|
|
61
|
+
description:
|
|
62
|
+
"filename date format template, see https://day.js.org/docs/en/display/format",
|
|
63
|
+
})
|
|
64
|
+
// 确认执行所有系统操作,非测试模式,如删除和重命名和移动操作
|
|
65
|
+
.option("doit", {
|
|
66
|
+
alias: "d",
|
|
67
|
+
type: "boolean",
|
|
68
|
+
default: false,
|
|
69
|
+
description: "execute os operations in real mode, not dry run",
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
const handler = async function cmdRename(argv) {
|
|
75
|
+
log.show(LOG_TAG, argv);
|
|
76
|
+
const root = path.resolve(argv.input);
|
|
77
|
+
if (!(await fs.pathExists(root))) {
|
|
78
|
+
log.error(`Invalid Input: '${root}'`);
|
|
79
|
+
throw new Error(`Invalid Input: '${root}'`)
|
|
80
|
+
}
|
|
81
|
+
const testMode = !argv.doit;
|
|
82
|
+
const fastMode = argv.fast || false;
|
|
83
|
+
// action: rename media file by exif date
|
|
84
|
+
const startMs = Date.now();
|
|
85
|
+
log.show(LOG_TAG, `Input: ${root}`);
|
|
86
|
+
let files = await exif.listMedia(root);
|
|
87
|
+
const fileCount = files.length;
|
|
88
|
+
log.show(LOG_TAG, `Total ${files.length} media files found`);
|
|
89
|
+
|
|
90
|
+
const confirmFiles = await inquirer.prompt([
|
|
91
|
+
{
|
|
92
|
+
type: "confirm",
|
|
93
|
+
name: "yes",
|
|
94
|
+
default: false,
|
|
95
|
+
message: chalk.bold.green(`Press y to continue processing...`),
|
|
96
|
+
},
|
|
97
|
+
]);
|
|
98
|
+
if (!confirmFiles.yes) {
|
|
99
|
+
log.showYellow("Will do nothing, aborted by user.");
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
log.show(LOG_TAG, `Processing files, reading EXIF data...`);
|
|
103
|
+
files = await exif.parseFiles(files, { fastMode });
|
|
104
|
+
log.show(
|
|
105
|
+
LOG_TAG,
|
|
106
|
+
`Total ${files.length} media files parsed`,
|
|
107
|
+
fastMode ? "(FastMode)" : ""
|
|
108
|
+
);
|
|
109
|
+
files = exif.buildNames(files);
|
|
110
|
+
const [validFiles, skippedBySize, skippedByDate] = exif.checkFiles(files);
|
|
111
|
+
files = validFiles;
|
|
112
|
+
if (fileCount - files.length > 0) {
|
|
113
|
+
log.warn(
|
|
114
|
+
LOG_TAG,
|
|
115
|
+
`Total ${fileCount - files.length} media files skipped`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
log.show(
|
|
119
|
+
LOG_TAG,
|
|
120
|
+
`Total ${fileCount} files processed in ${helper.humanTime(startMs)}`,
|
|
121
|
+
fastMode ? "(FastMode)" : ""
|
|
122
|
+
);
|
|
123
|
+
if (skippedBySize.length > 0) {
|
|
124
|
+
log.showYellow(
|
|
125
|
+
LOG_TAG,
|
|
126
|
+
`Total ${skippedBySize.length} media files are skipped by size`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (skippedByDate.length > 0) {
|
|
130
|
+
log.showYellow(
|
|
131
|
+
LOG_TAG,
|
|
132
|
+
`Total ${skippedByDate.length} media files are skipped by date`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (files.length == 0) {
|
|
136
|
+
log.showYellow(LOG_TAG, "Nothing to do, exit now.");
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
log.show(
|
|
140
|
+
LOG_TAG,
|
|
141
|
+
`Total ${files.length} media files ready to rename by exif`,
|
|
142
|
+
fastMode ? "(FastMode)" : ""
|
|
143
|
+
);
|
|
144
|
+
log.show(LOG_TAG, `task sample:`, files.slice(-2))
|
|
145
|
+
log.info(LOG_TAG, argv);
|
|
146
|
+
testMode && log.showYellow("++++++++++ TEST MODE (DRY RUN) ++++++++++")
|
|
147
|
+
const answer = await inquirer.prompt([
|
|
148
|
+
{
|
|
149
|
+
type: "confirm",
|
|
150
|
+
name: "yes",
|
|
151
|
+
default: false,
|
|
152
|
+
message: chalk.bold.red(
|
|
153
|
+
`Are you sure to rename ${files.length} files?` +
|
|
154
|
+
(fastMode ? " (FastMode)" : "")
|
|
155
|
+
),
|
|
156
|
+
},
|
|
157
|
+
]);
|
|
158
|
+
if (answer.yes) {
|
|
159
|
+
if (testMode) {
|
|
160
|
+
log.showYellow(LOG_TAG, `All ${files.length} files, NO file renamed in TEST MODE.`);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const results = await renameFiles(files);
|
|
164
|
+
log.showGreen(LOG_TAG, `All ${results.length} file were renamed.`,);
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
log.showYellow(LOG_TAG, "Will do nothing, aborted by user.");
|
|
168
|
+
}
|
|
169
|
+
}
|