@aesonford/renami 0.2.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/LICENSE +674 -0
- package/README.md +15 -0
- package/package.json +26 -0
- package/renami.mjs +2738 -0
package/renami.mjs
ADDED
|
@@ -0,0 +1,2738 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/core/types.ts
|
|
4
|
+
var DEFAULT_SETTINGS = {
|
|
5
|
+
pattern: "{name}",
|
|
6
|
+
sequence: {
|
|
7
|
+
sortBy: "dateTaken",
|
|
8
|
+
direction: "asc",
|
|
9
|
+
start: 1,
|
|
10
|
+
step: 1,
|
|
11
|
+
digits: 3,
|
|
12
|
+
restartPerFolder: false,
|
|
13
|
+
restartEvery: "never",
|
|
14
|
+
keepGroupsTogether: true
|
|
15
|
+
},
|
|
16
|
+
findReplace: [],
|
|
17
|
+
cleanup: {
|
|
18
|
+
caseMode: "none",
|
|
19
|
+
spacesToUnderscores: false,
|
|
20
|
+
lowercaseExtension: false,
|
|
21
|
+
stripDiacritics: false,
|
|
22
|
+
asciiOnly: false,
|
|
23
|
+
extensionRules: []
|
|
24
|
+
},
|
|
25
|
+
move: { destinationRoot: null },
|
|
26
|
+
dates: { setModified: false, setCreated: false, shiftMinutes: 0, useNameDate: true },
|
|
27
|
+
filter: {
|
|
28
|
+
includeSubfolders: false,
|
|
29
|
+
extensions: null,
|
|
30
|
+
mode: "files",
|
|
31
|
+
name: { text: "", regex: false },
|
|
32
|
+
minBytes: null,
|
|
33
|
+
maxBytes: null,
|
|
34
|
+
modifiedFrom: null,
|
|
35
|
+
modifiedTo: null
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
var isMoving = (kind) => kind === "rename" || kind === "move" || kind === "cross-drive-move";
|
|
39
|
+
var hasPlannedDates = (d) => d.modified !== void 0 || d.created !== void 0;
|
|
40
|
+
|
|
41
|
+
// src/core/scanner.ts
|
|
42
|
+
import { lstat, readdir, stat } from "node:fs/promises";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
|
|
45
|
+
// src/core/names.ts
|
|
46
|
+
var collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
|
|
47
|
+
function splitName(fileName2) {
|
|
48
|
+
const dot = fileName2.lastIndexOf(".");
|
|
49
|
+
if (dot <= 0 || dot === fileName2.length - 1) return { stem: fileName2, ext: "" };
|
|
50
|
+
return { stem: fileName2.slice(0, dot), ext: fileName2.slice(dot + 1) };
|
|
51
|
+
}
|
|
52
|
+
function nameKey(name) {
|
|
53
|
+
return name.normalize("NFC").toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
function naturalCompare(a, b) {
|
|
56
|
+
const c = collator.compare(a, b);
|
|
57
|
+
if (c !== 0) return c;
|
|
58
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
59
|
+
}
|
|
60
|
+
function lastSegment(p) {
|
|
61
|
+
const parts = p.split(/[\\/]+/).filter((s) => s !== "");
|
|
62
|
+
return parts[parts.length - 1] ?? "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/core/scanner.ts
|
|
66
|
+
var STAT_CONCURRENCY = 32;
|
|
67
|
+
var SYSTEM_FILES = /* @__PURE__ */ new Set([".ds_store", "thumbs.db", "desktop.ini"]);
|
|
68
|
+
var isSkipped = (name) => name.startsWith(".") || SYSTEM_FILES.has(name.toLowerCase());
|
|
69
|
+
var extOf = (file) => splitName(path.basename(file)).ext.toLowerCase();
|
|
70
|
+
var pad = (n2) => String(n2).padStart(2, "0");
|
|
71
|
+
function localDateString(ms) {
|
|
72
|
+
const d = new Date(ms);
|
|
73
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
74
|
+
}
|
|
75
|
+
function nameMatcher(filter) {
|
|
76
|
+
if (!filter || filter.text === "") return () => true;
|
|
77
|
+
if (filter.regex) {
|
|
78
|
+
try {
|
|
79
|
+
const re = new RegExp(filter.text, "i");
|
|
80
|
+
return (name) => re.test(name);
|
|
81
|
+
} catch {
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const needle = filter.text.toLowerCase();
|
|
85
|
+
return (name) => name.toLowerCase().includes(needle);
|
|
86
|
+
}
|
|
87
|
+
function passesFilters(entry, opts) {
|
|
88
|
+
if (!entry.isDir) {
|
|
89
|
+
if (opts.minBytes != null && entry.size < opts.minBytes) return false;
|
|
90
|
+
if (opts.maxBytes != null && entry.size > opts.maxBytes) return false;
|
|
91
|
+
}
|
|
92
|
+
if (opts.modifiedFrom || opts.modifiedTo) {
|
|
93
|
+
const day = localDateString(entry.mtimeMs);
|
|
94
|
+
if (opts.modifiedFrom && day < opts.modifiedFrom) return false;
|
|
95
|
+
if (opts.modifiedTo && day > opts.modifiedTo) return false;
|
|
96
|
+
}
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
async function toEntry(filePath, isDir) {
|
|
100
|
+
const st = await stat(filePath);
|
|
101
|
+
const base = path.basename(filePath);
|
|
102
|
+
const { stem, ext } = isDir ? { stem: base, ext: "" } : splitName(base);
|
|
103
|
+
return {
|
|
104
|
+
path: filePath,
|
|
105
|
+
dir: path.dirname(filePath),
|
|
106
|
+
stem,
|
|
107
|
+
ext,
|
|
108
|
+
size: st.size,
|
|
109
|
+
mtimeMs: st.mtimeMs,
|
|
110
|
+
birthtimeMs: Number.isFinite(st.birthtimeMs) && st.birthtimeMs > 0 ? st.birthtimeMs : null,
|
|
111
|
+
dev: st.dev,
|
|
112
|
+
isDir
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async function walk(dir, recurse, found, unreadable) {
|
|
116
|
+
let dirents;
|
|
117
|
+
try {
|
|
118
|
+
dirents = await readdir(dir, { withFileTypes: true });
|
|
119
|
+
} catch {
|
|
120
|
+
unreadable.push(dir);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
await Promise.all(
|
|
124
|
+
dirents.map(async (d) => {
|
|
125
|
+
if (isSkipped(d.name) || d.isSymbolicLink()) return;
|
|
126
|
+
const full = path.join(dir, d.name);
|
|
127
|
+
if (d.isFile()) found.add(full);
|
|
128
|
+
else if (d.isDirectory() && recurse) await walk(full, true, found, unreadable);
|
|
129
|
+
})
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
async function listSubfolders(dir, found, unreadable) {
|
|
133
|
+
let dirents;
|
|
134
|
+
try {
|
|
135
|
+
dirents = await readdir(dir, { withFileTypes: true });
|
|
136
|
+
} catch {
|
|
137
|
+
unreadable.push(dir);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
for (const d of dirents) {
|
|
141
|
+
if (isSkipped(d.name) || d.isSymbolicLink()) continue;
|
|
142
|
+
if (d.isDirectory()) found.add(path.join(dir, d.name));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async function scan(paths, opts, onProgress) {
|
|
146
|
+
const folders = opts.mode === "folders";
|
|
147
|
+
const found = /* @__PURE__ */ new Set();
|
|
148
|
+
const unreadable = [];
|
|
149
|
+
for (const raw of paths) {
|
|
150
|
+
const p = path.resolve(raw);
|
|
151
|
+
let st;
|
|
152
|
+
try {
|
|
153
|
+
st = await lstat(p);
|
|
154
|
+
} catch {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (st.isSymbolicLink() || isSkipped(path.basename(p))) continue;
|
|
158
|
+
if (folders) {
|
|
159
|
+
if (st.isDirectory()) await listSubfolders(p, found, unreadable);
|
|
160
|
+
} else if (st.isFile()) {
|
|
161
|
+
found.add(p);
|
|
162
|
+
} else if (st.isDirectory()) {
|
|
163
|
+
await walk(p, opts.includeSubfolders, found, unreadable);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const all = [...found].sort(naturalCompare);
|
|
167
|
+
const extensionsFound = folders ? [] : [...new Set(all.map(extOf))].sort();
|
|
168
|
+
const wanted = folders || opts.extensions === null ? null : new Set(opts.extensions.map((e) => e.toLowerCase()));
|
|
169
|
+
const matches = nameMatcher(opts.name);
|
|
170
|
+
const selected = all.filter((f) => (wanted === null || wanted.has(extOf(f))) && matches(path.basename(f)));
|
|
171
|
+
const stats = new Array(selected.length).fill(null);
|
|
172
|
+
let next = 0;
|
|
173
|
+
let done = 0;
|
|
174
|
+
const worker = async () => {
|
|
175
|
+
while (next < selected.length) {
|
|
176
|
+
const i = next++;
|
|
177
|
+
try {
|
|
178
|
+
const entry = await toEntry(selected[i], folders);
|
|
179
|
+
if (passesFilters(entry, opts)) stats[i] = entry;
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
182
|
+
done += 1;
|
|
183
|
+
onProgress?.(done, selected.length);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
await Promise.all(Array.from({ length: Math.min(STAT_CONCURRENCY, selected.length) }, worker));
|
|
187
|
+
const entries = stats.filter((e) => e !== null);
|
|
188
|
+
return { entries, extensionsFound, unreadableFolders: unreadable.sort(naturalCompare) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// src/core/metadata/reader.ts
|
|
192
|
+
import { availableParallelism } from "node:os";
|
|
193
|
+
import { ExifTool } from "exiftool-vendored";
|
|
194
|
+
|
|
195
|
+
// src/core/nameDate.ts
|
|
196
|
+
var YEAR_MIN = 2e3;
|
|
197
|
+
var YEAR_MAX = 2099;
|
|
198
|
+
var TIMESTAMP = /(?<!\d)(\d{4})([-_.:]?)(\d{2})\2(\d{2})(?:[ T_\-.]| at )(\d{2})[-_.:h]?(\d{2})[-_.:m]?(\d{2})(?:\d{3})?(?!\d)/;
|
|
199
|
+
var DATE_ONLY = /(?<!\d)(\d{4})([-_.]?)(\d{2})\2(\d{2})(?!\d)/;
|
|
200
|
+
var n = (s) => Number(s ?? NaN);
|
|
201
|
+
function validDate(year, month, day) {
|
|
202
|
+
return year >= YEAR_MIN && year <= YEAR_MAX && month >= 1 && month <= 12 && day >= 1 && day <= 31;
|
|
203
|
+
}
|
|
204
|
+
function dateFromName(stem) {
|
|
205
|
+
const full = TIMESTAMP.exec(stem);
|
|
206
|
+
if (full) {
|
|
207
|
+
const [year, month, day, hour, minute, second] = [n(full[1]), n(full[3]), n(full[4]), n(full[5]), n(full[6]), n(full[7])];
|
|
208
|
+
if (validDate(year, month, day) && hour <= 23 && minute <= 59 && second <= 59) {
|
|
209
|
+
return { year, month, day, hour, minute, second };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const date2 = DATE_ONLY.exec(stem);
|
|
213
|
+
if (date2) {
|
|
214
|
+
const [year, month, day] = [n(date2[1]), n(date2[3]), n(date2[4])];
|
|
215
|
+
if (validDate(year, month, day)) return { year, month, day, hour: 0, minute: 0, second: 0 };
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/core/wallclock.ts
|
|
221
|
+
var formatters = /* @__PURE__ */ new Map();
|
|
222
|
+
function formatterFor(timeZone) {
|
|
223
|
+
let f = formatters.get(timeZone);
|
|
224
|
+
if (!f) {
|
|
225
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
226
|
+
timeZone,
|
|
227
|
+
year: "numeric",
|
|
228
|
+
month: "2-digit",
|
|
229
|
+
day: "2-digit",
|
|
230
|
+
hour: "2-digit",
|
|
231
|
+
minute: "2-digit",
|
|
232
|
+
second: "2-digit",
|
|
233
|
+
hourCycle: "h23"
|
|
234
|
+
});
|
|
235
|
+
formatters.set(timeZone, f);
|
|
236
|
+
}
|
|
237
|
+
return f;
|
|
238
|
+
}
|
|
239
|
+
function systemTimeZone() {
|
|
240
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
241
|
+
}
|
|
242
|
+
function instantToWallClock(ms, timeZone) {
|
|
243
|
+
const parts = formatterFor(timeZone).formatToParts(new Date(ms));
|
|
244
|
+
const get = (type) => Number(parts.find((p) => p.type === type)?.value ?? NaN);
|
|
245
|
+
return {
|
|
246
|
+
year: get("year"),
|
|
247
|
+
month: get("month"),
|
|
248
|
+
day: get("day"),
|
|
249
|
+
hour: get("hour"),
|
|
250
|
+
minute: get("minute"),
|
|
251
|
+
second: get("second")
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function utcWallToZone(wall, timeZone) {
|
|
255
|
+
const ms = Date.UTC(wall.year, wall.month - 1, wall.day, wall.hour, wall.minute, wall.second);
|
|
256
|
+
const out = instantToWallClock(ms, timeZone);
|
|
257
|
+
if (wall.millisecond !== void 0) out.millisecond = wall.millisecond;
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
function wallClockToLocalDate(wc) {
|
|
261
|
+
return new Date(wc.year, wc.month - 1, wc.day, wc.hour, wc.minute, wc.second, wc.millisecond ?? 0);
|
|
262
|
+
}
|
|
263
|
+
function compareWallClock(a, b) {
|
|
264
|
+
return a.year - b.year || a.month - b.month || a.day - b.day || a.hour - b.hour || a.minute - b.minute || a.second - b.second || (a.millisecond ?? 0) - (b.millisecond ?? 0);
|
|
265
|
+
}
|
|
266
|
+
var EXIF_DATE = /^(\d{4})[:-](\d{2})[:-](\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?\s*(Z|[+-]\d{2}:?\d{2})?$/;
|
|
267
|
+
function parseExifDateTime(raw) {
|
|
268
|
+
if (typeof raw !== "string") return null;
|
|
269
|
+
const m = EXIF_DATE.exec(raw.trim());
|
|
270
|
+
if (!m) return null;
|
|
271
|
+
const n2 = (i) => Number(m[i]);
|
|
272
|
+
const wall = { year: n2(1), month: n2(2), day: n2(3), hour: n2(4), minute: n2(5), second: n2(6) };
|
|
273
|
+
if (wall.year === 0 || wall.month < 1 || wall.month > 12 || wall.day < 1 || wall.day > 31 || wall.hour > 23 || wall.minute > 59 || wall.second > 59) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
const fraction = m[7];
|
|
277
|
+
if (fraction !== void 0) wall.millisecond = Number(fraction.padEnd(3, "0").slice(0, 3));
|
|
278
|
+
const tz = m[8];
|
|
279
|
+
let offsetMinutes = null;
|
|
280
|
+
if (tz === "Z") {
|
|
281
|
+
offsetMinutes = 0;
|
|
282
|
+
} else if (tz) {
|
|
283
|
+
const sign = tz.startsWith("-") ? -1 : 1;
|
|
284
|
+
const digits = tz.slice(1).replace(":", "");
|
|
285
|
+
offsetMinutes = sign * (Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2, 4)));
|
|
286
|
+
}
|
|
287
|
+
return { wall, offsetMinutes };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/core/dates.ts
|
|
291
|
+
function resolveDate(meta, kind) {
|
|
292
|
+
if (kind === "date_taken" && meta.dateTaken) {
|
|
293
|
+
return { value: meta.dateTaken, source: "taken", fellBack: false };
|
|
294
|
+
}
|
|
295
|
+
if (kind === "date_taken" && meta.nameDate) {
|
|
296
|
+
return { value: meta.nameDate, source: "name", fellBack: true };
|
|
297
|
+
}
|
|
298
|
+
if (kind !== "modified" && meta.created) {
|
|
299
|
+
return { value: meta.created, source: "created", fellBack: kind !== "created" };
|
|
300
|
+
}
|
|
301
|
+
return { value: meta.modified, source: "modified", fellBack: kind !== "modified" };
|
|
302
|
+
}
|
|
303
|
+
function sortKeyToDateKind(key) {
|
|
304
|
+
switch (key) {
|
|
305
|
+
case "dateTaken":
|
|
306
|
+
return "date_taken";
|
|
307
|
+
case "created":
|
|
308
|
+
return "created";
|
|
309
|
+
case "modified":
|
|
310
|
+
return "modified";
|
|
311
|
+
case "name":
|
|
312
|
+
return null;
|
|
313
|
+
case "manual":
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function fallbackMessage(kind, source) {
|
|
318
|
+
const missing = kind === "date_taken" ? "No date taken" : "No created date";
|
|
319
|
+
const used = source === "name" ? "the date in its name" : source === "created" ? "created date" : "modified date";
|
|
320
|
+
return `${missing} in this file; used ${used}`;
|
|
321
|
+
}
|
|
322
|
+
function fsMetadata(entry, timeZone) {
|
|
323
|
+
const meta = { modified: instantToWallClock(entry.mtimeMs, timeZone) };
|
|
324
|
+
if (entry.birthtimeMs !== null && entry.birthtimeMs > 0) {
|
|
325
|
+
meta.created = instantToWallClock(entry.birthtimeMs, timeZone);
|
|
326
|
+
}
|
|
327
|
+
const nameDate = dateFromName(entry.stem);
|
|
328
|
+
if (nameDate) meta.nameDate = nameDate;
|
|
329
|
+
return meta;
|
|
330
|
+
}
|
|
331
|
+
function shiftWallClock(wc, minutes) {
|
|
332
|
+
const ms = Date.UTC(wc.year, wc.month - 1, wc.day, wc.hour, wc.minute, wc.second) + minutes * 6e4;
|
|
333
|
+
const d = new Date(ms);
|
|
334
|
+
const out = {
|
|
335
|
+
year: d.getUTCFullYear(),
|
|
336
|
+
month: d.getUTCMonth() + 1,
|
|
337
|
+
day: d.getUTCDate(),
|
|
338
|
+
hour: d.getUTCHours(),
|
|
339
|
+
minute: d.getUTCMinutes(),
|
|
340
|
+
second: d.getUTCSeconds()
|
|
341
|
+
};
|
|
342
|
+
if (wc.millisecond !== void 0) out.millisecond = wc.millisecond;
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
function effectiveMetadata(meta, adjust) {
|
|
346
|
+
if (adjust.shiftMinutes === 0 && adjust.useNameDate) return meta;
|
|
347
|
+
const out = { ...meta };
|
|
348
|
+
if (!adjust.useNameDate) delete out.nameDate;
|
|
349
|
+
if (adjust.shiftMinutes !== 0 && out.dateTaken) out.dateTaken = shiftWallClock(out.dateTaken, adjust.shiftMinutes);
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// src/core/metadata/normalize.ts
|
|
354
|
+
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set(["mov", "mp4", "m4v"]);
|
|
355
|
+
var AUDIO_EXTENSIONS = /* @__PURE__ */ new Set(["mp3", "flac", "m4a", "m4b"]);
|
|
356
|
+
function str(v) {
|
|
357
|
+
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
|
358
|
+
if (typeof v !== "string") return void 0;
|
|
359
|
+
const t = v.trim();
|
|
360
|
+
return t === "" ? void 0 : t;
|
|
361
|
+
}
|
|
362
|
+
function num(v) {
|
|
363
|
+
const n2 = typeof v === "number" ? v : typeof v === "string" ? Number.parseFloat(v) : NaN;
|
|
364
|
+
return Number.isFinite(n2) ? n2 : void 0;
|
|
365
|
+
}
|
|
366
|
+
function leadingInt(v) {
|
|
367
|
+
const n2 = typeof v === "number" ? Math.trunc(v) : typeof v === "string" ? Number.parseInt(v, 10) : NaN;
|
|
368
|
+
return Number.isFinite(n2) && n2 > 0 ? n2 : void 0;
|
|
369
|
+
}
|
|
370
|
+
function yearOf(v) {
|
|
371
|
+
if (v === void 0 || v === null) return void 0;
|
|
372
|
+
const m = /\d{4}/.exec(String(v));
|
|
373
|
+
return m ? Number(m[0]) : void 0;
|
|
374
|
+
}
|
|
375
|
+
function signed(value, ref, negativeRef) {
|
|
376
|
+
const n2 = num(value);
|
|
377
|
+
if (n2 === void 0) return void 0;
|
|
378
|
+
return n2 > 0 && String(ref ?? "").toUpperCase().startsWith(negativeRef) ? -n2 : n2;
|
|
379
|
+
}
|
|
380
|
+
function kbps(v) {
|
|
381
|
+
const n2 = num(v);
|
|
382
|
+
return n2 === void 0 ? void 0 : Math.round(n2 / 1e3);
|
|
383
|
+
}
|
|
384
|
+
function withSubSec(wall, subSec) {
|
|
385
|
+
if (!wall || wall.millisecond !== void 0) return wall;
|
|
386
|
+
const digits = typeof subSec === "number" ? String(subSec) : typeof subSec === "string" ? subSec.trim() : "";
|
|
387
|
+
if (!/^\d+$/.test(digits)) return wall;
|
|
388
|
+
return { ...wall, millisecond: Number(digits.padEnd(3, "0").slice(0, 3)) };
|
|
389
|
+
}
|
|
390
|
+
var asRecorded = (v) => parseExifDateTime(v)?.wall;
|
|
391
|
+
function fromUtc(v, timeZone) {
|
|
392
|
+
const parsed2 = parseExifDateTime(v);
|
|
393
|
+
if (!parsed2) return void 0;
|
|
394
|
+
return parsed2.offsetMinutes === null ? utcWallToZone(parsed2.wall, timeZone) : parsed2.wall;
|
|
395
|
+
}
|
|
396
|
+
function normalizeTags(raw, entry, timeZone) {
|
|
397
|
+
const meta = fsMetadata(entry, timeZone);
|
|
398
|
+
const ext = entry.ext.toLowerCase();
|
|
399
|
+
const isVideo = VIDEO_EXTENSIONS.has(ext);
|
|
400
|
+
const isAudio = AUDIO_EXTENSIONS.has(ext);
|
|
401
|
+
const fields = {
|
|
402
|
+
dateTaken: isAudio ? void 0 : isVideo ? asRecorded(raw.CreationDate) ?? fromUtc(raw.CreateDate, timeZone) : asRecorded(raw.SubSecDateTimeOriginal) ?? withSubSec(asRecorded(raw.DateTimeOriginal), raw.SubSecTimeOriginal) ?? asRecorded(raw.CreateDate),
|
|
403
|
+
cameraMake: str(raw.Make),
|
|
404
|
+
cameraModel: str(raw.Model),
|
|
405
|
+
lens: str(raw.LensModel) ?? str(raw.LensID) ?? str(raw.Lens),
|
|
406
|
+
iso: num(raw.ISO),
|
|
407
|
+
focalLength: num(raw.FocalLength),
|
|
408
|
+
width: num(raw.ImageWidth),
|
|
409
|
+
height: num(raw.ImageHeight),
|
|
410
|
+
gpsLat: signed(raw.GPSLatitude, raw.GPSLatitudeRef, "S"),
|
|
411
|
+
gpsLon: signed(raw.GPSLongitude, raw.GPSLongitudeRef, "W"),
|
|
412
|
+
durationSeconds: num(raw.Duration),
|
|
413
|
+
artist: str(raw.Artist),
|
|
414
|
+
albumArtist: str(raw.AlbumArtist) ?? str(raw.Albumartist) ?? str(raw.Band),
|
|
415
|
+
album: str(raw.Album),
|
|
416
|
+
title: str(raw.Title),
|
|
417
|
+
genre: str(raw.Genre),
|
|
418
|
+
track: leadingInt(raw.Track) ?? leadingInt(raw.TrackNumber),
|
|
419
|
+
disc: leadingInt(raw.DiscNumber) ?? leadingInt(raw.DiskNumber) ?? leadingInt(raw.PartOfSet),
|
|
420
|
+
year: isAudio ? yearOf(raw.Year) ?? yearOf(raw.RecordingTime) ?? yearOf(raw.ContentCreateDate) ?? yearOf(raw.Date) : void 0,
|
|
421
|
+
aperture: num(raw.FNumber) ?? num(raw.ApertureValue),
|
|
422
|
+
exposureSeconds: num(raw.ExposureTime),
|
|
423
|
+
frameRate: num(raw.VideoFrameRate),
|
|
424
|
+
bitrateKbps: kbps(raw.AvgBitrate) ?? kbps(raw.AudioBitrate),
|
|
425
|
+
composer: str(raw.Composer),
|
|
426
|
+
readError: str(raw.Error)
|
|
427
|
+
};
|
|
428
|
+
const out = { ...meta };
|
|
429
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
430
|
+
if (value !== void 0) out[key] = value;
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/core/metadata/reader.ts
|
|
436
|
+
var EMBEDDED_JPEG_TAGS = ["JpgFromRaw", "PreviewImage", "ThumbnailImage"];
|
|
437
|
+
var READ_ARGS = ["-fast", "-n", "-api", "QuickTimeUTC=0"];
|
|
438
|
+
var MetadataReader = class {
|
|
439
|
+
cache = /* @__PURE__ */ new Map();
|
|
440
|
+
exiftool;
|
|
441
|
+
timeZone;
|
|
442
|
+
constructor(opts = {}) {
|
|
443
|
+
this.exiftool = opts.exiftool ?? null;
|
|
444
|
+
this.timeZone = opts.timeZone ?? systemTimeZone();
|
|
445
|
+
}
|
|
446
|
+
tool() {
|
|
447
|
+
this.exiftool ??= new ExifTool({
|
|
448
|
+
maxProcs: Math.max(1, Math.min(4, availableParallelism() - 1)),
|
|
449
|
+
taskTimeoutMillis: 3e4
|
|
450
|
+
});
|
|
451
|
+
return this.exiftool;
|
|
452
|
+
}
|
|
453
|
+
async read(entry) {
|
|
454
|
+
if (entry.isDir) return fsMetadata(entry, this.timeZone);
|
|
455
|
+
const key = `${entry.size}:${entry.mtimeMs}`;
|
|
456
|
+
const hit = this.cache.get(entry.path);
|
|
457
|
+
if (hit && hit.key === key) return hit.meta;
|
|
458
|
+
const meta = await this.readUncached(entry);
|
|
459
|
+
this.cache.set(entry.path, { key, meta });
|
|
460
|
+
return meta;
|
|
461
|
+
}
|
|
462
|
+
async readUncached(entry) {
|
|
463
|
+
let lastError;
|
|
464
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
465
|
+
try {
|
|
466
|
+
const raw = await this.tool().readRaw(entry.path, { readArgs: READ_ARGS });
|
|
467
|
+
return normalizeTags(raw, entry, this.timeZone);
|
|
468
|
+
} catch (e) {
|
|
469
|
+
lastError = e;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const message2 = lastError instanceof Error ? lastError.message : String(lastError);
|
|
473
|
+
return { ...fsMetadata(entry, this.timeZone), readError: message2 };
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Reads every entry, `concurrency` at a time. If a callback throws, no more files start; the
|
|
477
|
+
* files already open finish, and then this rejects with the first error.
|
|
478
|
+
*/
|
|
479
|
+
async readAll(entries, onProgress, concurrency = 8, opts = {}) {
|
|
480
|
+
const result = /* @__PURE__ */ new Map();
|
|
481
|
+
let next = 0;
|
|
482
|
+
let done = 0;
|
|
483
|
+
const errors = [];
|
|
484
|
+
const worker = async () => {
|
|
485
|
+
try {
|
|
486
|
+
while (next < entries.length && errors.length === 0 && !opts.signal?.aborted) {
|
|
487
|
+
const entry = entries[next];
|
|
488
|
+
next += 1;
|
|
489
|
+
if (!entry) continue;
|
|
490
|
+
const meta = await this.read(entry);
|
|
491
|
+
result.set(entry.path, meta);
|
|
492
|
+
opts.onResult?.(entry.path, meta);
|
|
493
|
+
done += 1;
|
|
494
|
+
onProgress?.(done, entries.length);
|
|
495
|
+
}
|
|
496
|
+
} catch (e) {
|
|
497
|
+
errors.push(e);
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, entries.length) }, worker));
|
|
501
|
+
if (errors.length > 0) throw errors[0];
|
|
502
|
+
return result;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* The largest JPEG a RAW file carries, for the preview panel, with the RAW file's
|
|
506
|
+
* orientation. Null when it has none.
|
|
507
|
+
*/
|
|
508
|
+
async embeddedJpeg(path9) {
|
|
509
|
+
const tool = this.tool();
|
|
510
|
+
for (const tag of EMBEDDED_JPEG_TAGS) {
|
|
511
|
+
try {
|
|
512
|
+
const data = await tool.extractBinaryTagToBuffer(tag, path9);
|
|
513
|
+
if (data.length === 0) continue;
|
|
514
|
+
const raw = await tool.readRaw(path9, { readArgs: ["-n", "-Orientation"] }).catch(() => ({}));
|
|
515
|
+
const o = Number(raw.Orientation);
|
|
516
|
+
return { data, orientation: Number.isInteger(o) && o >= 1 && o <= 8 ? o : 1 };
|
|
517
|
+
} catch {
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
async end() {
|
|
523
|
+
await this.exiftool?.end();
|
|
524
|
+
this.exiftool = null;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Tells a broken ExifTool process apart from files ExifTool simply can't read: a run of
|
|
528
|
+
* `readError`s might mean a crashed/hung ExifTool, or it might just be a run of empty or
|
|
529
|
+
* missing files. Resolves true when the vendored ExifTool responds to `version()`, false when
|
|
530
|
+
* it throws.
|
|
531
|
+
*/
|
|
532
|
+
async isWorking() {
|
|
533
|
+
try {
|
|
534
|
+
await this.tool().version();
|
|
535
|
+
return true;
|
|
536
|
+
} catch {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// src/core/planner.ts
|
|
543
|
+
import nodePath from "node:path";
|
|
544
|
+
|
|
545
|
+
// src/core/cleanup.ts
|
|
546
|
+
var EDGE_START = /^[_\- .]+/;
|
|
547
|
+
var EDGE_END = /[_\- .]+$/;
|
|
548
|
+
var INVALID_CHARS = /[<>:"/\\|?*\u0000-\u001f]/g;
|
|
549
|
+
var REPEATED_SEPARATOR = /([_\- .])\1+/g;
|
|
550
|
+
var TRAILING_DOTS_SPACES = /[. ]+$/;
|
|
551
|
+
var RESERVED = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?=\.|$)/i;
|
|
552
|
+
var SPECIAL_LETTERS = {
|
|
553
|
+
"\xDF": "ss",
|
|
554
|
+
"\xE6": "ae",
|
|
555
|
+
"\xC6": "Ae",
|
|
556
|
+
"\u0153": "oe",
|
|
557
|
+
"\u0152": "Oe",
|
|
558
|
+
"\xF8": "o",
|
|
559
|
+
"\xD8": "O",
|
|
560
|
+
"\u0111": "d",
|
|
561
|
+
"\u0110": "D",
|
|
562
|
+
"\u0142": "l",
|
|
563
|
+
"\u0141": "L",
|
|
564
|
+
"\xFE": "th",
|
|
565
|
+
"\xDE": "Th",
|
|
566
|
+
"\xF0": "d",
|
|
567
|
+
"\xD0": "D",
|
|
568
|
+
"\u0131": "i"
|
|
569
|
+
};
|
|
570
|
+
var SPECIAL = /[ßæÆœŒøØđĐłŁþÞðÐı]/g;
|
|
571
|
+
function stripDiacritics(text2) {
|
|
572
|
+
return text2.normalize("NFD").replace(new RegExp("\\p{M}+", "gu"), "").replace(SPECIAL, (ch) => SPECIAL_LETTERS[ch] ?? ch);
|
|
573
|
+
}
|
|
574
|
+
function asciiOnly(text2) {
|
|
575
|
+
return stripDiacritics(text2).replace(/[^\x20-\x7e]/g, "_");
|
|
576
|
+
}
|
|
577
|
+
var WORD_SPLIT = /[\s_\-.]+/;
|
|
578
|
+
var capitalize = (w) => w.charAt(0).toUpperCase() + w.slice(1);
|
|
579
|
+
function applyCase(text2, mode) {
|
|
580
|
+
const words = text2.split(WORD_SPLIT).filter((w) => w !== "");
|
|
581
|
+
switch (mode) {
|
|
582
|
+
case "none":
|
|
583
|
+
return text2;
|
|
584
|
+
case "lower":
|
|
585
|
+
return text2.toLowerCase();
|
|
586
|
+
case "upper":
|
|
587
|
+
return text2.toUpperCase();
|
|
588
|
+
case "title":
|
|
589
|
+
return text2.toLowerCase().replace(new RegExp("(^|[\\s_\\-.])(\\p{L})", "gu"), (_m, sep, ch) => sep + ch.toUpperCase());
|
|
590
|
+
case "sentence":
|
|
591
|
+
return text2.toLowerCase().replace(new RegExp("\\p{L}", "u"), (ch) => ch.toUpperCase());
|
|
592
|
+
case "snake":
|
|
593
|
+
return words.length === 0 ? text2 : words.map((w) => w.toLowerCase()).join("_");
|
|
594
|
+
case "kebab":
|
|
595
|
+
return words.length === 0 ? text2 : words.map((w) => w.toLowerCase()).join("-");
|
|
596
|
+
case "camel":
|
|
597
|
+
return words.length === 0 ? text2 : words.map((w, i) => i === 0 ? w.toLowerCase() : capitalize(w.toLowerCase())).join("");
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function utf8Bytes(s) {
|
|
601
|
+
return Buffer.byteLength(s, "utf8");
|
|
602
|
+
}
|
|
603
|
+
function cleanSegment(parts, opts) {
|
|
604
|
+
const first = parts.findIndex((p) => p.text !== "");
|
|
605
|
+
if (first === -1) return "";
|
|
606
|
+
let last = parts.length - 1;
|
|
607
|
+
while (last > first && parts[last]?.text === "") last -= 1;
|
|
608
|
+
let text2 = parts.slice(first, last + 1).map((p) => p.text).join("");
|
|
609
|
+
if (parts.slice(0, first).some((p) => p.token)) text2 = text2.replace(EDGE_START, "");
|
|
610
|
+
if (parts.slice(last + 1).some((p) => p.token)) text2 = text2.replace(EDGE_END, "");
|
|
611
|
+
if (opts.asciiOnly) text2 = asciiOnly(text2);
|
|
612
|
+
else if (opts.stripDiacritics) text2 = stripDiacritics(text2);
|
|
613
|
+
text2 = applyCase(text2, opts.caseMode);
|
|
614
|
+
if (opts.spacesToUnderscores) text2 = text2.replace(/ /g, "_");
|
|
615
|
+
text2 = text2.replace(INVALID_CHARS, "_");
|
|
616
|
+
text2 = text2.replace(REPEATED_SEPARATOR, "$1");
|
|
617
|
+
text2 = text2.replace(TRAILING_DOTS_SPACES, "");
|
|
618
|
+
text2 = text2.normalize("NFC");
|
|
619
|
+
text2 = text2.replace(RESERVED, "$1_");
|
|
620
|
+
return text2;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// src/core/extensions.ts
|
|
624
|
+
function normalizeExtension(text2) {
|
|
625
|
+
return text2.trim().replace(/^\.+/, "").toLowerCase();
|
|
626
|
+
}
|
|
627
|
+
function applyExtensionRules(ext, rules, lowercase) {
|
|
628
|
+
const key = ext.toLowerCase();
|
|
629
|
+
const rule = rules.find((r) => {
|
|
630
|
+
const from = normalizeExtension(r.from);
|
|
631
|
+
return from !== "" && from === key;
|
|
632
|
+
});
|
|
633
|
+
if (rule) return normalizeExtension(rule.to);
|
|
634
|
+
return lowercase ? key : ext;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// src/core/findReplace.ts
|
|
638
|
+
function escapeRegExp(s) {
|
|
639
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
640
|
+
}
|
|
641
|
+
function validateRules(rules) {
|
|
642
|
+
for (const [i, rule] of rules.entries()) {
|
|
643
|
+
if (!rule.regex || rule.find === "") continue;
|
|
644
|
+
try {
|
|
645
|
+
new RegExp(rule.find);
|
|
646
|
+
} catch (e) {
|
|
647
|
+
return `Find & replace rule ${i + 1}: ${e.message}`;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
function applyFindReplace(stem, rules) {
|
|
653
|
+
let out = stem;
|
|
654
|
+
for (const rule of rules) {
|
|
655
|
+
if (rule.find === "") continue;
|
|
656
|
+
const flags = rule.matchCase ? "g" : "gi";
|
|
657
|
+
if (rule.regex) {
|
|
658
|
+
out = out.replace(new RegExp(rule.find, flags), rule.replace);
|
|
659
|
+
} else {
|
|
660
|
+
out = out.replace(new RegExp(escapeRegExp(rule.find), flags), () => rule.replace);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
665
|
+
function rulesFor(rules, scope) {
|
|
666
|
+
return rules.filter((r) => (r.scope ?? "original") === scope);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/core/groups.ts
|
|
670
|
+
var TIERS = [
|
|
671
|
+
["nef", "cr2", "cr3", "arw", "dng"],
|
|
672
|
+
["heic", "jpg", "jpeg"],
|
|
673
|
+
["mov", "mp4", "m4v"]
|
|
674
|
+
];
|
|
675
|
+
function extensionTier(ext) {
|
|
676
|
+
const e = ext.toLowerCase();
|
|
677
|
+
const i = TIERS.findIndex((tier) => tier.includes(e));
|
|
678
|
+
return i === -1 ? TIERS.length : i;
|
|
679
|
+
}
|
|
680
|
+
function byTier(a, b) {
|
|
681
|
+
const ea = a.ext.toLowerCase();
|
|
682
|
+
const eb = b.ext.toLowerCase();
|
|
683
|
+
return extensionTier(ea) - extensionTier(eb) || (ea < eb ? -1 : ea > eb ? 1 : 0);
|
|
684
|
+
}
|
|
685
|
+
function choosePrimary(members, getMeta) {
|
|
686
|
+
const ordered = [...members].sort(byTier);
|
|
687
|
+
const first = ordered[0];
|
|
688
|
+
if (!first) throw new Error("choosePrimary called with no members");
|
|
689
|
+
return ordered.find((m) => getMeta(m).dateTaken !== void 0) ?? first;
|
|
690
|
+
}
|
|
691
|
+
function buildGroups(entries, getMeta, keepTogether) {
|
|
692
|
+
if (!keepTogether) return entries.map((e) => ({ id: e.path, members: [e], primary: e }));
|
|
693
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
694
|
+
for (const e of entries) {
|
|
695
|
+
const key = `${e.dir}\0${nameKey(e.stem)}`;
|
|
696
|
+
const list = byKey.get(key);
|
|
697
|
+
if (list) list.push(e);
|
|
698
|
+
else byKey.set(key, [e]);
|
|
699
|
+
}
|
|
700
|
+
return [...byKey.values()].map((members) => {
|
|
701
|
+
const primary = choosePrimary(members, getMeta);
|
|
702
|
+
return {
|
|
703
|
+
id: members.length === 1 ? primary.path : `group:${primary.path}`,
|
|
704
|
+
members: [...members].sort(byTier),
|
|
705
|
+
primary
|
|
706
|
+
};
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// src/core/sequencer.ts
|
|
711
|
+
var fileName = (g) => g.primary.ext ? `${g.primary.stem}.${g.primary.ext}` : g.primary.stem;
|
|
712
|
+
function sortGroups(groups, getMeta, sortBy, direction = "asc", manualOrder = []) {
|
|
713
|
+
const kind = sortKeyToDateKind(sortBy);
|
|
714
|
+
const rank = new Map(manualOrder.map((p, i) => [p, i]));
|
|
715
|
+
const rankOf = (g) => Math.min(...g.members.map((m) => rank.get(m.path) ?? Infinity));
|
|
716
|
+
const keyed = groups.map((g) => ({
|
|
717
|
+
g,
|
|
718
|
+
date: kind ? resolveDate(getMeta(g.primary), kind).value : null,
|
|
719
|
+
rank: sortBy === "manual" ? rankOf(g) : 0
|
|
720
|
+
}));
|
|
721
|
+
const compare = (a, b) => {
|
|
722
|
+
if (a.date && b.date) {
|
|
723
|
+
const c = compareWallClock(a.date, b.date);
|
|
724
|
+
if (c !== 0) return c;
|
|
725
|
+
} else if (sortBy === "name") {
|
|
726
|
+
const c = naturalCompare(fileName(a.g), fileName(b.g));
|
|
727
|
+
if (c !== 0) return c;
|
|
728
|
+
} else if (sortBy === "manual") {
|
|
729
|
+
if (a.rank !== b.rank) return a.rank < b.rank ? -1 : 1;
|
|
730
|
+
}
|
|
731
|
+
return naturalCompare(a.g.primary.path, b.g.primary.path);
|
|
732
|
+
};
|
|
733
|
+
const sign = direction === "desc" ? -1 : 1;
|
|
734
|
+
keyed.sort((a, b) => sign * compare(a, b));
|
|
735
|
+
return keyed.map((k) => k.g);
|
|
736
|
+
}
|
|
737
|
+
function period(date2, every) {
|
|
738
|
+
if (date2 === null || every === "never") return "";
|
|
739
|
+
if (every === "year") return String(date2.year);
|
|
740
|
+
if (every === "month") return `${date2.year}-${date2.month}`;
|
|
741
|
+
return `${date2.year}-${date2.month}-${date2.day}`;
|
|
742
|
+
}
|
|
743
|
+
function assignSequence(sorted, opts, dateOf) {
|
|
744
|
+
const step = opts.step ?? 1;
|
|
745
|
+
const every = opts.restartEvery ?? "never";
|
|
746
|
+
const next = /* @__PURE__ */ new Map();
|
|
747
|
+
const result = /* @__PURE__ */ new Map();
|
|
748
|
+
for (const g of sorted) {
|
|
749
|
+
const folder = opts.restartPerFolder ? g.primary.dir : "";
|
|
750
|
+
const bucket = `${folder}\0${every === "never" ? "" : period(dateOf?.(g) ?? null, every)}`;
|
|
751
|
+
const n2 = next.get(bucket) ?? opts.start;
|
|
752
|
+
result.set(g.id, n2);
|
|
753
|
+
next.set(bucket, n2 + step);
|
|
754
|
+
}
|
|
755
|
+
return result;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// src/core/template/tokens.ts
|
|
759
|
+
var text = (label, get) => ({
|
|
760
|
+
kind: "text",
|
|
761
|
+
label,
|
|
762
|
+
get
|
|
763
|
+
});
|
|
764
|
+
var num2 = (label, digitsFormat, get, defaultDigits = () => 0) => ({ kind: "number", label, digitsFormat, get, defaultDigits });
|
|
765
|
+
var date = (kind, label) => ({ kind: "date", date: kind, label });
|
|
766
|
+
function formatDuration(seconds) {
|
|
767
|
+
const total = Math.round(seconds);
|
|
768
|
+
const h = Math.floor(total / 3600);
|
|
769
|
+
const m = Math.floor(total % 3600 / 60);
|
|
770
|
+
const s = String(total % 60).padStart(2, "0");
|
|
771
|
+
return h > 0 ? `${h}h${String(m).padStart(2, "0")}m${s}s` : `${m}m${s}s`;
|
|
772
|
+
}
|
|
773
|
+
var UNITS = ["KB", "MB", "GB", "TB"];
|
|
774
|
+
function formatSize(bytes) {
|
|
775
|
+
if (bytes < 1024) return `${Math.round(bytes)}B`;
|
|
776
|
+
let value = bytes / 1024;
|
|
777
|
+
let i = 0;
|
|
778
|
+
while (value >= 1024 && i < UNITS.length - 1) {
|
|
779
|
+
value /= 1024;
|
|
780
|
+
i += 1;
|
|
781
|
+
}
|
|
782
|
+
const rounded = value >= 100 ? Math.round(value) : Math.round(value * 10) / 10;
|
|
783
|
+
return `${rounded}${UNITS[i]}`;
|
|
784
|
+
}
|
|
785
|
+
var trimNumber = (n2, decimals = 2) => String(Math.round(n2 * 10 ** decimals) / 10 ** decimals);
|
|
786
|
+
function formatExposure(seconds) {
|
|
787
|
+
return seconds >= 1 ? `${trimNumber(seconds)}s` : `1-${Math.round(1 / seconds)}`;
|
|
788
|
+
}
|
|
789
|
+
function parentFolder(dir) {
|
|
790
|
+
const parts = dir.split(/[\\/]+/).filter((s) => s !== "");
|
|
791
|
+
return parts.length >= 2 ? parts[parts.length - 2] : void 0;
|
|
792
|
+
}
|
|
793
|
+
var TOKENS = {
|
|
794
|
+
name: text("name", (c) => c.name),
|
|
795
|
+
folder: text("folder name", (c) => lastSegment(c.entry.dir)),
|
|
796
|
+
date_taken: date("date_taken", "date taken"),
|
|
797
|
+
created: date("created", "created date"),
|
|
798
|
+
modified: date("modified", "modified date"),
|
|
799
|
+
seq: num2("sequence number", true, (c) => c.seq, (c) => c.defaultDigits),
|
|
800
|
+
camera_make: text("camera make", (c) => c.meta.cameraMake),
|
|
801
|
+
camera_model: text("camera model", (c) => c.meta.cameraModel),
|
|
802
|
+
lens: text("lens", (c) => c.meta.lens),
|
|
803
|
+
iso: num2("ISO", false, (c) => c.meta.iso),
|
|
804
|
+
focal_length: text("focal length", (c) => c.meta.focalLength === void 0 ? void 0 : `${Math.round(c.meta.focalLength)}mm`),
|
|
805
|
+
width: num2("width", false, (c) => c.meta.width),
|
|
806
|
+
height: num2("height", false, (c) => c.meta.height),
|
|
807
|
+
gps_lat: text("GPS latitude", (c) => c.meta.gpsLat?.toFixed(4)),
|
|
808
|
+
gps_lon: text("GPS longitude", (c) => c.meta.gpsLon?.toFixed(4)),
|
|
809
|
+
duration: text("duration", (c) => c.meta.durationSeconds === void 0 ? void 0 : formatDuration(c.meta.durationSeconds)),
|
|
810
|
+
artist: text("artist", (c) => c.meta.artist),
|
|
811
|
+
album_artist: text("album artist", (c) => c.meta.albumArtist),
|
|
812
|
+
album: text("album", (c) => c.meta.album),
|
|
813
|
+
title: text("title", (c) => c.meta.title),
|
|
814
|
+
genre: text("genre", (c) => c.meta.genre),
|
|
815
|
+
track: num2("track number", true, (c) => c.meta.track),
|
|
816
|
+
disc: num2("disc number", true, (c) => c.meta.disc),
|
|
817
|
+
year: num2("year", false, (c) => c.meta.year),
|
|
818
|
+
ext: text("extension", (c) => c.entry.ext || void 0),
|
|
819
|
+
parent: text("parent folder name", (c) => parentFolder(c.entry.dir)),
|
|
820
|
+
size: text("size", (c) => formatSize(c.entry.size)),
|
|
821
|
+
size_bytes: num2("size in bytes", false, (c) => c.entry.size),
|
|
822
|
+
crc32: text("CRC32", (c) => c.meta.crc32),
|
|
823
|
+
md5: text("MD5", (c) => c.meta.md5),
|
|
824
|
+
aperture: text("aperture", (c) => c.meta.aperture === void 0 ? void 0 : `f${trimNumber(c.meta.aperture, 1)}`),
|
|
825
|
+
shutter: text("shutter speed", (c) => c.meta.exposureSeconds === void 0 || c.meta.exposureSeconds <= 0 ? void 0 : formatExposure(c.meta.exposureSeconds)),
|
|
826
|
+
fps: text("frame rate", (c) => c.meta.frameRate === void 0 ? void 0 : trimNumber(c.meta.frameRate)),
|
|
827
|
+
bitrate: num2("bitrate in kbps", false, (c) => c.meta.bitrateKbps),
|
|
828
|
+
composer: text("composer", (c) => c.meta.composer)
|
|
829
|
+
};
|
|
830
|
+
function isTokenName(s) {
|
|
831
|
+
return Object.hasOwn(TOKENS, s);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// src/core/template/parse.ts
|
|
835
|
+
var fail = (error2, index) => ({ ok: false, error: error2, index });
|
|
836
|
+
function parseToken(body, index) {
|
|
837
|
+
const bar = body.indexOf("|");
|
|
838
|
+
const head = bar === -1 ? body : body.slice(0, bar);
|
|
839
|
+
const fallback = bar === -1 ? null : body.slice(bar + 1);
|
|
840
|
+
const colon = head.indexOf(":");
|
|
841
|
+
const name = colon === -1 ? head : head.slice(0, colon);
|
|
842
|
+
const format = colon === -1 ? null : head.slice(colon + 1);
|
|
843
|
+
if (name === "") return fail('Empty token "{}"', index);
|
|
844
|
+
if (!isTokenName(name)) return fail(`Unknown token "{${name}}"`, index);
|
|
845
|
+
const def = TOKENS[name];
|
|
846
|
+
if (format !== null) {
|
|
847
|
+
if (def.kind === "date") {
|
|
848
|
+
if (format === "") return fail(`Missing date format after "{${name}:"`, index);
|
|
849
|
+
} else if (def.kind === "number" && def.digitsFormat) {
|
|
850
|
+
const digits = Number(format);
|
|
851
|
+
if (!/^\d{1,2}$/.test(format) || digits < 1 || digits > 10) {
|
|
852
|
+
return fail(`"{${name}:${format}}" needs a number of digits from 1 to 10`, index);
|
|
853
|
+
}
|
|
854
|
+
} else {
|
|
855
|
+
return fail(`"{${name}}" doesn't take a format`, index);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return { ok: true, node: { type: "token", name, format, fallback } };
|
|
859
|
+
}
|
|
860
|
+
function scanPattern(pattern) {
|
|
861
|
+
if (pattern.trim() === "") return fail("The pattern is empty", 0);
|
|
862
|
+
const nodes = [];
|
|
863
|
+
const spans = [];
|
|
864
|
+
let text2 = "";
|
|
865
|
+
const flush = () => {
|
|
866
|
+
if (text2 !== "") {
|
|
867
|
+
nodes.push({ type: "text", value: text2 });
|
|
868
|
+
text2 = "";
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
let i = 0;
|
|
872
|
+
while (i < pattern.length) {
|
|
873
|
+
const ch = pattern.charAt(i);
|
|
874
|
+
const next = pattern.charAt(i + 1);
|
|
875
|
+
if (ch === "{" && next === "{") {
|
|
876
|
+
text2 += "{";
|
|
877
|
+
i += 2;
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
if (ch === "}" && next === "}") {
|
|
881
|
+
text2 += "}";
|
|
882
|
+
i += 2;
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
if (ch === "}") {
|
|
886
|
+
return fail(`Unexpected "}" at position ${i + 1}. Use "}}" for a literal brace.`, i);
|
|
887
|
+
}
|
|
888
|
+
if (ch === "/" || ch === "\\") {
|
|
889
|
+
flush();
|
|
890
|
+
nodes.push({ type: "separator" });
|
|
891
|
+
spans.push({ kind: "separator", start: i, end: i + 1 });
|
|
892
|
+
i += 1;
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
if (ch === "{") {
|
|
896
|
+
const close = pattern.indexOf("}", i + 1);
|
|
897
|
+
if (close === -1) return fail(`Unclosed "{" at position ${i + 1}`, i);
|
|
898
|
+
const parsed2 = parseToken(pattern.slice(i + 1, close), i);
|
|
899
|
+
if (!parsed2.ok) return parsed2;
|
|
900
|
+
flush();
|
|
901
|
+
const { name, format, fallback } = parsed2.node;
|
|
902
|
+
nodes.push(parsed2.node);
|
|
903
|
+
spans.push({ kind: "token", start: i, end: close + 1, name, format, fallback });
|
|
904
|
+
i = close + 1;
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
text2 += ch;
|
|
908
|
+
i += 1;
|
|
909
|
+
}
|
|
910
|
+
flush();
|
|
911
|
+
return { ok: true, nodes, spans };
|
|
912
|
+
}
|
|
913
|
+
function parsePattern(pattern) {
|
|
914
|
+
const r = scanPattern(pattern);
|
|
915
|
+
return r.ok ? { ok: true, nodes: r.nodes } : r;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// src/core/dateFormat.ts
|
|
919
|
+
var DEFAULT_DATE_FORMAT = "YYYY-MM-DD";
|
|
920
|
+
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
921
|
+
var DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
922
|
+
var CODES = ["YYYY", "MMM", "SSS", "ddd", "YY", "MM", "DD", "HH", "hh", "mm", "ss", "A"];
|
|
923
|
+
var pad2 = (n2, width = 2) => String(n2).padStart(width, "0");
|
|
924
|
+
function dayOfWeek(wc) {
|
|
925
|
+
return new Date(Date.UTC(wc.year, wc.month - 1, wc.day)).getUTCDay();
|
|
926
|
+
}
|
|
927
|
+
function renderCode(code, wc) {
|
|
928
|
+
switch (code) {
|
|
929
|
+
case "YYYY":
|
|
930
|
+
return pad2(wc.year, 4);
|
|
931
|
+
case "YY":
|
|
932
|
+
return pad2(wc.year % 100);
|
|
933
|
+
case "MMM":
|
|
934
|
+
return MONTHS[wc.month - 1] ?? "";
|
|
935
|
+
case "MM":
|
|
936
|
+
return pad2(wc.month);
|
|
937
|
+
case "DD":
|
|
938
|
+
return pad2(wc.day);
|
|
939
|
+
case "ddd":
|
|
940
|
+
return DAYS[dayOfWeek(wc)] ?? "";
|
|
941
|
+
case "HH":
|
|
942
|
+
return pad2(wc.hour);
|
|
943
|
+
case "hh":
|
|
944
|
+
return pad2(wc.hour % 12 || 12);
|
|
945
|
+
case "A":
|
|
946
|
+
return wc.hour < 12 ? "AM" : "PM";
|
|
947
|
+
case "mm":
|
|
948
|
+
return pad2(wc.minute);
|
|
949
|
+
case "ss":
|
|
950
|
+
return pad2(wc.second);
|
|
951
|
+
case "SSS":
|
|
952
|
+
return pad2(wc.millisecond ?? 0, 3);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
function formatWallClock(wc, format) {
|
|
956
|
+
let out = "";
|
|
957
|
+
let i = 0;
|
|
958
|
+
while (i < format.length) {
|
|
959
|
+
const code = CODES.find((c) => format.startsWith(c, i));
|
|
960
|
+
if (code) {
|
|
961
|
+
out += renderCode(code, wc);
|
|
962
|
+
i += code.length;
|
|
963
|
+
} else {
|
|
964
|
+
out += format.charAt(i);
|
|
965
|
+
i += 1;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return out;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/core/template/render.ts
|
|
972
|
+
function renderPattern(nodes, ctx) {
|
|
973
|
+
let current = [];
|
|
974
|
+
const segments = [current];
|
|
975
|
+
const flags = [];
|
|
976
|
+
let firstDate = null;
|
|
977
|
+
const addFlag = (flag) => {
|
|
978
|
+
if (!flags.some((f) => f.code === flag.code && f.message === flag.message)) flags.push(flag);
|
|
979
|
+
};
|
|
980
|
+
for (const node of nodes) {
|
|
981
|
+
if (node.type === "text") {
|
|
982
|
+
current.push({ text: node.value, token: false });
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
if (node.type === "separator") {
|
|
986
|
+
current = [];
|
|
987
|
+
segments.push(current);
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
const def = TOKENS[node.name];
|
|
991
|
+
if (def.kind === "date") {
|
|
992
|
+
const r = resolveDate(ctx.meta, def.date);
|
|
993
|
+
if (r.fellBack) {
|
|
994
|
+
addFlag({ level: "warning", code: "fallback-date", message: fallbackMessage(def.date, r.source) });
|
|
995
|
+
}
|
|
996
|
+
firstDate ??= { value: r.value, source: r.source };
|
|
997
|
+
current.push({ text: formatWallClock(r.value, node.format ?? DEFAULT_DATE_FORMAT), token: true });
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
let value;
|
|
1001
|
+
if (def.kind === "number") {
|
|
1002
|
+
const n2 = def.get(ctx);
|
|
1003
|
+
if (n2 !== void 0 && Number.isFinite(n2)) {
|
|
1004
|
+
const digits = node.format !== null ? Number(node.format) : def.defaultDigits(ctx);
|
|
1005
|
+
value = String(Math.trunc(n2)).padStart(digits, "0");
|
|
1006
|
+
}
|
|
1007
|
+
} else {
|
|
1008
|
+
const s = def.get(ctx);
|
|
1009
|
+
value = s === void 0 || s.trim() === "" ? void 0 : s;
|
|
1010
|
+
}
|
|
1011
|
+
if (value === void 0) {
|
|
1012
|
+
if (node.fallback !== null) {
|
|
1013
|
+
value = node.fallback;
|
|
1014
|
+
} else {
|
|
1015
|
+
addFlag({ level: "warning", code: "missing-value", message: `No ${def.label} for this file` });
|
|
1016
|
+
value = "";
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
current.push({ text: value, token: true });
|
|
1020
|
+
}
|
|
1021
|
+
return { segments, flags, firstDate };
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// src/core/planner.ts
|
|
1025
|
+
var MAX_SEGMENT_BYTES = 255;
|
|
1026
|
+
var WINDOWS_MAX_PATH = 260;
|
|
1027
|
+
var withExt = (stem, ext) => ext ? `${stem}.${ext}` : stem;
|
|
1028
|
+
var fileNameOf = (e) => withExt(e.stem, e.ext);
|
|
1029
|
+
var error = (code, message2) => ({ level: "error", code, message: message2 });
|
|
1030
|
+
var warning = (code, message2) => ({ level: "warning", code, message: message2 });
|
|
1031
|
+
var planCounter = 0;
|
|
1032
|
+
var newPlanId = () => `plan-${Date.now().toString(36)}-${planCounter += 1}`;
|
|
1033
|
+
function buildPlan(input) {
|
|
1034
|
+
const { settings, fs, platform } = input;
|
|
1035
|
+
const p = input.path ?? nodePath;
|
|
1036
|
+
const timeZone = input.timeZone ?? systemTimeZone();
|
|
1037
|
+
const id = input.planId ?? newPlanId();
|
|
1038
|
+
const metaCache = /* @__PURE__ */ new Map();
|
|
1039
|
+
const getMeta = (e) => {
|
|
1040
|
+
let meta = metaCache.get(e);
|
|
1041
|
+
if (!meta) {
|
|
1042
|
+
meta = effectiveMetadata(input.metadata.get(e.path) ?? fsMetadata(e, timeZone), settings.dates);
|
|
1043
|
+
metaCache.set(e, meta);
|
|
1044
|
+
}
|
|
1045
|
+
return meta;
|
|
1046
|
+
};
|
|
1047
|
+
const excluded = input.excluded ?? /* @__PURE__ */ new Set();
|
|
1048
|
+
const groups = buildGroups(input.entries, getMeta, settings.sequence.keepGroupsTogether);
|
|
1049
|
+
const sorted = sortGroups(groups, getMeta, settings.sequence.sortBy, settings.sequence.direction, input.manualOrder ?? []);
|
|
1050
|
+
const planned = /* @__PURE__ */ new Map();
|
|
1051
|
+
for (const g of sorted) {
|
|
1052
|
+
const members = g.members.filter((m) => !excluded.has(m.path));
|
|
1053
|
+
if (members.length === g.members.length) planned.set(g, g);
|
|
1054
|
+
else if (members.length > 0) {
|
|
1055
|
+
const primary = members.includes(g.primary) ? g.primary : choosePrimary(members, getMeta);
|
|
1056
|
+
planned.set(g, { id: members.length === 1 ? primary.path : g.id, members, primary });
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const sortKind = sortKeyToDateKind(settings.sequence.sortBy);
|
|
1060
|
+
const dateOf = (g) => resolveDate(getMeta(g.primary), sortKind ?? "date_taken").value;
|
|
1061
|
+
const seqs = assignSequence([...planned.values()], settings.sequence, dateOf);
|
|
1062
|
+
const originalRules = rulesFor(settings.findReplace, "original");
|
|
1063
|
+
const resultRules = rulesFor(settings.findReplace, "result");
|
|
1064
|
+
const cleanupOpts = settings.cleanup;
|
|
1065
|
+
const excludedItem = (entry) => ({
|
|
1066
|
+
source: entry,
|
|
1067
|
+
target: entry.path,
|
|
1068
|
+
kind: "excluded",
|
|
1069
|
+
flags: [],
|
|
1070
|
+
seq: 0,
|
|
1071
|
+
groupId: null,
|
|
1072
|
+
dateUsed: null,
|
|
1073
|
+
stem: entry.stem,
|
|
1074
|
+
ext: entry.ext,
|
|
1075
|
+
setDates: {}
|
|
1076
|
+
});
|
|
1077
|
+
const allErrors = (flag) => sorted.flatMap((g) => {
|
|
1078
|
+
const pg = planned.get(g);
|
|
1079
|
+
return g.members.map(
|
|
1080
|
+
(entry) => !pg || excluded.has(entry.path) ? excludedItem(entry) : {
|
|
1081
|
+
source: entry,
|
|
1082
|
+
target: entry.path,
|
|
1083
|
+
kind: "error",
|
|
1084
|
+
flags: [flag],
|
|
1085
|
+
seq: seqs.get(pg.id) ?? 0,
|
|
1086
|
+
groupId: pg.members.length > 1 ? pg.id : null,
|
|
1087
|
+
dateUsed: null,
|
|
1088
|
+
stem: entry.stem,
|
|
1089
|
+
ext: entry.ext,
|
|
1090
|
+
setDates: {}
|
|
1091
|
+
}
|
|
1092
|
+
);
|
|
1093
|
+
});
|
|
1094
|
+
const errorCount = (items2) => items2.filter((i) => i.kind === "error").length;
|
|
1095
|
+
const destinationRoot = settings.move.destinationRoot;
|
|
1096
|
+
if (destinationRoot !== null && !p.isAbsolute(destinationRoot)) {
|
|
1097
|
+
const items2 = allErrors(error("destination-unwritable", "The destination folder must be a full path"));
|
|
1098
|
+
return { id, items: items2, patternError: null, errorCount: errorCount(items2), settings };
|
|
1099
|
+
}
|
|
1100
|
+
const resolvedRoot = destinationRoot !== null ? p.resolve(destinationRoot) : null;
|
|
1101
|
+
const parsed2 = parsePattern(settings.pattern);
|
|
1102
|
+
const rulesError = validateRules(settings.findReplace);
|
|
1103
|
+
if (!parsed2.ok || rulesError !== null) {
|
|
1104
|
+
const message2 = !parsed2.ok ? parsed2.error : rulesError ?? "";
|
|
1105
|
+
const items2 = allErrors(error("pattern-error", message2));
|
|
1106
|
+
return { id, items: items2, patternError: message2, errorCount: errorCount(items2), settings };
|
|
1107
|
+
}
|
|
1108
|
+
const drafts = sorted.flatMap((g) => {
|
|
1109
|
+
const pg = planned.get(g);
|
|
1110
|
+
const included = pg ? draftGroup(pg, parsed2.nodes, seqs.get(pg.id) ?? 0) : [];
|
|
1111
|
+
return g.members.map((entry) => included.find((d) => d.entry === entry) ?? excludedDraft(entry, g));
|
|
1112
|
+
});
|
|
1113
|
+
const items = resolveConflicts(drafts);
|
|
1114
|
+
return { id, items, patternError: null, errorCount: errorCount(items), settings };
|
|
1115
|
+
function excludedDraft(entry, group) {
|
|
1116
|
+
return {
|
|
1117
|
+
entry,
|
|
1118
|
+
group,
|
|
1119
|
+
seq: 0,
|
|
1120
|
+
targetDir: entry.dir,
|
|
1121
|
+
stem: entry.stem,
|
|
1122
|
+
ext: entry.ext,
|
|
1123
|
+
flags: [],
|
|
1124
|
+
error: false,
|
|
1125
|
+
excluded: true,
|
|
1126
|
+
dateUsed: null,
|
|
1127
|
+
setDates: {}
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
function draftGroup(group, nodes, seq) {
|
|
1131
|
+
const meta = getMeta(group.primary);
|
|
1132
|
+
const name = applyFindReplace(group.primary.stem, originalRules);
|
|
1133
|
+
const rendered = renderPattern(nodes, {
|
|
1134
|
+
entry: group.primary,
|
|
1135
|
+
meta,
|
|
1136
|
+
name,
|
|
1137
|
+
seq,
|
|
1138
|
+
defaultDigits: settings.sequence.digits
|
|
1139
|
+
});
|
|
1140
|
+
const cleaned = rendered.segments.map((s) => cleanSegment(s, cleanupOpts));
|
|
1141
|
+
const folders = cleaned.slice(0, -1);
|
|
1142
|
+
let stem = cleaned[cleaned.length - 1] ?? "";
|
|
1143
|
+
const override = group.members.map((m) => input.overrides?.get(m.path)).find((o) => o !== void 0 && o.trim() !== "");
|
|
1144
|
+
const groupFlags = override === void 0 ? [...rendered.flags] : [];
|
|
1145
|
+
if (override !== void 0) {
|
|
1146
|
+
stem = cleanSegment([{ text: override.trim(), token: false }], cleanupOpts);
|
|
1147
|
+
groupFlags.push({ level: "info", code: "edited", message: "Name typed by hand" });
|
|
1148
|
+
} else if (resultRules.length > 0) {
|
|
1149
|
+
stem = cleanSegment([{ text: applyFindReplace(stem, resultRules), token: false }], cleanupOpts);
|
|
1150
|
+
}
|
|
1151
|
+
const baseDir = resolvedRoot ?? group.primary.dir;
|
|
1152
|
+
const targetDir = folders.length > 0 ? p.join(baseDir, ...folders) : baseDir;
|
|
1153
|
+
if (stem === "") groupFlags.push(error("empty-segment", "The new name is empty"));
|
|
1154
|
+
else if (folders.includes("")) groupFlags.push(error("empty-segment", "A folder name in the pattern is empty"));
|
|
1155
|
+
for (const folder of folders) {
|
|
1156
|
+
if (utf8Bytes(folder) > MAX_SEGMENT_BYTES) {
|
|
1157
|
+
groupFlags.push(error("segment-too-long", `Folder name "${folder.slice(0, 24)}\u2026" is longer than 255 bytes`));
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
let dateUsed = rendered.firstDate;
|
|
1161
|
+
if (!dateUsed && sortKind) {
|
|
1162
|
+
const r = resolveDate(meta, sortKind);
|
|
1163
|
+
dateUsed = { value: r.value, source: r.source };
|
|
1164
|
+
}
|
|
1165
|
+
const setDates = {};
|
|
1166
|
+
const setCreated = settings.dates.setCreated && platform !== "linux";
|
|
1167
|
+
const wantsDates = settings.dates.setModified || setCreated;
|
|
1168
|
+
if (wantsDates && meta.dateTaken) {
|
|
1169
|
+
if (settings.dates.setModified) setDates.modified = meta.dateTaken;
|
|
1170
|
+
if (setCreated) setDates.created = meta.dateTaken;
|
|
1171
|
+
} else if (wantsDates && !group.primary.isDir) {
|
|
1172
|
+
groupFlags.push(warning("no-date-taken", "No date taken in this file; its dates are left unchanged"));
|
|
1173
|
+
}
|
|
1174
|
+
return group.members.map((entry) => {
|
|
1175
|
+
const flags = [...groupFlags];
|
|
1176
|
+
const memberMeta = getMeta(entry);
|
|
1177
|
+
if (memberMeta.readError) {
|
|
1178
|
+
flags.push(warning("metadata-unreadable", `Couldn't read metadata: ${memberMeta.readError}`));
|
|
1179
|
+
}
|
|
1180
|
+
if (group.members.length > 1) {
|
|
1181
|
+
const others = group.members.filter((m) => m !== entry).map(fileNameOf).join(", ");
|
|
1182
|
+
flags.push({ level: "info", code: "paired", message: `Paired with ${others}` });
|
|
1183
|
+
}
|
|
1184
|
+
const ext = entry.isDir ? "" : applyExtensionRules(entry.ext, settings.cleanup.extensionRules, settings.cleanup.lowercaseExtension);
|
|
1185
|
+
if (entry.isDir) {
|
|
1186
|
+
const self = nameKey(entry.path);
|
|
1187
|
+
const into = nameKey(targetDir);
|
|
1188
|
+
if (into === self || into.startsWith(`${self}/`) || into.startsWith(`${self}\\`)) {
|
|
1189
|
+
flags.push(error("destination-unwritable", "A folder can't be moved into itself"));
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (stem !== "" && utf8Bytes(withExt(stem, ext)) > MAX_SEGMENT_BYTES) {
|
|
1193
|
+
flags.push(error("segment-too-long", "The new name is longer than 255 bytes"));
|
|
1194
|
+
}
|
|
1195
|
+
const staysPut = targetDir === entry.dir && withExt(stem, ext) === fileNameOf(entry);
|
|
1196
|
+
if (!staysPut && !fs.isWritableDir(fs.nearestExisting(targetDir))) {
|
|
1197
|
+
flags.push(error("destination-unwritable", `Can't write to ${fs.nearestExisting(targetDir)}`));
|
|
1198
|
+
}
|
|
1199
|
+
return {
|
|
1200
|
+
entry,
|
|
1201
|
+
group,
|
|
1202
|
+
seq,
|
|
1203
|
+
targetDir,
|
|
1204
|
+
stem,
|
|
1205
|
+
ext,
|
|
1206
|
+
flags,
|
|
1207
|
+
error: flags.some((f) => f.level === "error"),
|
|
1208
|
+
excluded: false,
|
|
1209
|
+
dateUsed,
|
|
1210
|
+
setDates: { ...setDates }
|
|
1211
|
+
};
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
function resolveConflicts(all) {
|
|
1215
|
+
const vacated = /* @__PURE__ */ new Map();
|
|
1216
|
+
for (const d of all) {
|
|
1217
|
+
if (d.error || d.excluded) continue;
|
|
1218
|
+
const key = nameKey(d.entry.dir);
|
|
1219
|
+
const set = vacated.get(key) ?? /* @__PURE__ */ new Set();
|
|
1220
|
+
set.add(nameKey(fileNameOf(d.entry)));
|
|
1221
|
+
vacated.set(key, set);
|
|
1222
|
+
}
|
|
1223
|
+
const occupiedCache = /* @__PURE__ */ new Map();
|
|
1224
|
+
const occupied = (dir) => {
|
|
1225
|
+
const key = nameKey(dir);
|
|
1226
|
+
let set = occupiedCache.get(key);
|
|
1227
|
+
if (!set) {
|
|
1228
|
+
set = new Set((fs.listDir(dir) ?? []).map(nameKey));
|
|
1229
|
+
for (const k of vacated.get(key) ?? []) set.delete(k);
|
|
1230
|
+
occupiedCache.set(key, set);
|
|
1231
|
+
}
|
|
1232
|
+
return set;
|
|
1233
|
+
};
|
|
1234
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
1235
|
+
const claimedIn = (dir) => {
|
|
1236
|
+
const key = nameKey(dir);
|
|
1237
|
+
const set = claimed.get(key) ?? /* @__PURE__ */ new Set();
|
|
1238
|
+
claimed.set(key, set);
|
|
1239
|
+
return set;
|
|
1240
|
+
};
|
|
1241
|
+
const nextSuffix = /* @__PURE__ */ new Map();
|
|
1242
|
+
const units = [];
|
|
1243
|
+
let open;
|
|
1244
|
+
for (const d of all) {
|
|
1245
|
+
if (d.excluded) units.push([d]);
|
|
1246
|
+
else if (!d.error && open && open[0]?.group === d.group) open.push(d);
|
|
1247
|
+
else {
|
|
1248
|
+
const unit = [d];
|
|
1249
|
+
units.push(unit);
|
|
1250
|
+
open = d.error ? void 0 : unit;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const splitUnits = units.flatMap((u) => {
|
|
1254
|
+
const keys = u.map((d) => nameKey(withExt(d.stem, d.ext)));
|
|
1255
|
+
return new Set(keys).size === keys.length ? [u] : u.map((d) => [d]);
|
|
1256
|
+
});
|
|
1257
|
+
const items2 = [];
|
|
1258
|
+
for (const unit of splitUnits) {
|
|
1259
|
+
const first = unit[0];
|
|
1260
|
+
if (!first) continue;
|
|
1261
|
+
if (first.excluded) {
|
|
1262
|
+
items2.push(excludedItem(first.entry));
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
if (first.error) {
|
|
1266
|
+
items2.push(toItem(first, first.entry.path, "error", first.flags, first.stem));
|
|
1267
|
+
continue;
|
|
1268
|
+
}
|
|
1269
|
+
const baseKey = JSON.stringify(unit.map((d) => [nameKey(d.targetDir), nameKey(d.stem), nameKey(d.ext)]));
|
|
1270
|
+
let n2 = nextSuffix.get(baseKey) ?? 1;
|
|
1271
|
+
let names = [];
|
|
1272
|
+
for (; ; n2 += 1) {
|
|
1273
|
+
const suffix2 = n2 === 1 ? "" : `_${n2}`;
|
|
1274
|
+
names = unit.map((d) => withExt(d.stem + suffix2, d.ext));
|
|
1275
|
+
const free = unit.every((d, i) => {
|
|
1276
|
+
const k = nameKey(names[i] ?? "");
|
|
1277
|
+
return !occupied(d.targetDir).has(k) && !claimedIn(d.targetDir).has(k);
|
|
1278
|
+
});
|
|
1279
|
+
if (free) break;
|
|
1280
|
+
}
|
|
1281
|
+
nextSuffix.set(baseKey, n2 + 1);
|
|
1282
|
+
const suffix = n2 === 1 ? "" : `_${n2}`;
|
|
1283
|
+
unit.forEach((d, i) => {
|
|
1284
|
+
const stem = `${d.stem}${suffix}`;
|
|
1285
|
+
const name = names[i] ?? "";
|
|
1286
|
+
claimedIn(d.targetDir).add(nameKey(name));
|
|
1287
|
+
const target = p.join(d.targetDir, name);
|
|
1288
|
+
const flags = [...d.flags];
|
|
1289
|
+
if (n2 > 1) flags.push(warning("suffix-added", `Added _${n2} because the name was already taken`));
|
|
1290
|
+
if (platform === "win32" && target.length >= WINDOWS_MAX_PATH) {
|
|
1291
|
+
flags.push(error("path-too-long", "The full path is longer than Windows allows (260 characters)"));
|
|
1292
|
+
items2.push(toItem(d, d.entry.path, "error", flags, stem));
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (target === d.entry.path) {
|
|
1296
|
+
items2.push(toItem(d, target, "unchanged", flags, stem));
|
|
1297
|
+
} else if (nameKey(d.targetDir) === nameKey(d.entry.dir)) {
|
|
1298
|
+
items2.push(toItem(d, target, "rename", flags, stem));
|
|
1299
|
+
} else if (fs.deviceOf(fs.nearestExisting(d.targetDir)) !== d.entry.dev) {
|
|
1300
|
+
if (d.entry.isDir) {
|
|
1301
|
+
flags.push(error("folder-cross-drive", "Folders can't be moved to another drive"));
|
|
1302
|
+
items2.push(toItem(d, d.entry.path, "error", flags, stem));
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
flags.push(warning("cross-drive", "Moves to another drive: copied, checked, then the original is deleted. The copy gets a new created date."));
|
|
1306
|
+
items2.push(toItem(d, target, "cross-drive-move", flags, stem));
|
|
1307
|
+
} else {
|
|
1308
|
+
items2.push(toItem(d, target, "move", flags, stem));
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
if (excluded.size === 0) return items2;
|
|
1313
|
+
const order = new Map(all.map((d, i) => [d.entry.path, i]));
|
|
1314
|
+
return items2.sort((x, y) => (order.get(x.source.path) ?? 0) - (order.get(y.source.path) ?? 0));
|
|
1315
|
+
}
|
|
1316
|
+
function toItem(d, target, kind, flags, stem) {
|
|
1317
|
+
return {
|
|
1318
|
+
source: d.entry,
|
|
1319
|
+
target,
|
|
1320
|
+
kind,
|
|
1321
|
+
flags,
|
|
1322
|
+
seq: d.seq,
|
|
1323
|
+
groupId: d.group.members.length > 1 ? d.group.id : null,
|
|
1324
|
+
dateUsed: d.dateUsed,
|
|
1325
|
+
stem,
|
|
1326
|
+
ext: d.ext,
|
|
1327
|
+
setDates: kind === "error" ? {} : d.setDates
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// src/core/fsview.ts
|
|
1333
|
+
import { accessSync, constants, readdirSync, statSync } from "node:fs";
|
|
1334
|
+
import path2 from "node:path";
|
|
1335
|
+
function cached(fn) {
|
|
1336
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1337
|
+
return (key) => {
|
|
1338
|
+
if (!cache.has(key)) cache.set(key, fn(key));
|
|
1339
|
+
return cache.get(key);
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
function createFsView() {
|
|
1343
|
+
return {
|
|
1344
|
+
listDir: cached((dir) => {
|
|
1345
|
+
try {
|
|
1346
|
+
return readdirSync(dir);
|
|
1347
|
+
} catch {
|
|
1348
|
+
return null;
|
|
1349
|
+
}
|
|
1350
|
+
}),
|
|
1351
|
+
nearestExisting: cached((p) => {
|
|
1352
|
+
let current = path2.resolve(p);
|
|
1353
|
+
for (; ; ) {
|
|
1354
|
+
try {
|
|
1355
|
+
statSync(current);
|
|
1356
|
+
return current;
|
|
1357
|
+
} catch {
|
|
1358
|
+
const parent = path2.dirname(current);
|
|
1359
|
+
if (parent === current) return current;
|
|
1360
|
+
current = parent;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}),
|
|
1364
|
+
deviceOf: cached((existingPath) => statSync(existingPath).dev),
|
|
1365
|
+
isWritableDir: cached((dir) => {
|
|
1366
|
+
try {
|
|
1367
|
+
accessSync(dir, constants.W_OK);
|
|
1368
|
+
return true;
|
|
1369
|
+
} catch {
|
|
1370
|
+
return false;
|
|
1371
|
+
}
|
|
1372
|
+
})
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// src/core/executor/execute.ts
|
|
1377
|
+
import { randomUUID } from "node:crypto";
|
|
1378
|
+
import { stat as stat3, utimes as utimes2 } from "node:fs/promises";
|
|
1379
|
+
|
|
1380
|
+
// src/core/executor/birthtime.ts
|
|
1381
|
+
import { execFile } from "node:child_process";
|
|
1382
|
+
import { promisify } from "node:util";
|
|
1383
|
+
|
|
1384
|
+
// src/core/executor/moves.ts
|
|
1385
|
+
import { constants as constants2 } from "node:fs";
|
|
1386
|
+
import { copyFile, lstat as lstat2, mkdir, rename, rmdir, stat as stat2, unlink, utimes } from "node:fs/promises";
|
|
1387
|
+
import path3 from "node:path";
|
|
1388
|
+
var CancelledError = class extends Error {
|
|
1389
|
+
constructor() {
|
|
1390
|
+
super("Cancelled");
|
|
1391
|
+
this.name = "CancelledError";
|
|
1392
|
+
}
|
|
1393
|
+
};
|
|
1394
|
+
async function exists(p) {
|
|
1395
|
+
try {
|
|
1396
|
+
await lstat2(p);
|
|
1397
|
+
return true;
|
|
1398
|
+
} catch (e) {
|
|
1399
|
+
const code = e.code;
|
|
1400
|
+
if (code === "ENOENT" || code === "ENOTDIR") return false;
|
|
1401
|
+
throw e;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
var tempName = (batchId, index, suffix = "tmp") => `.renami-${batchId}-${index}.${suffix}`;
|
|
1405
|
+
function checkCancelled(ctx) {
|
|
1406
|
+
if (ctx.signal?.aborted) throw new CancelledError();
|
|
1407
|
+
}
|
|
1408
|
+
async function ensureDir(dir, ctx, ensured) {
|
|
1409
|
+
if (ensured.has(dir)) return;
|
|
1410
|
+
const missing = [];
|
|
1411
|
+
let current = dir;
|
|
1412
|
+
while (!await exists(current)) {
|
|
1413
|
+
missing.push(current);
|
|
1414
|
+
const parent = path3.dirname(current);
|
|
1415
|
+
if (parent === current) break;
|
|
1416
|
+
current = parent;
|
|
1417
|
+
}
|
|
1418
|
+
for (const d of missing.reverse()) {
|
|
1419
|
+
await mkdir(d);
|
|
1420
|
+
ctx.journal.push({ type: "mkdir", dir: d });
|
|
1421
|
+
ctx.createdDirs.push(d);
|
|
1422
|
+
}
|
|
1423
|
+
ensured.add(dir);
|
|
1424
|
+
}
|
|
1425
|
+
async function copyAcross(source, target, staging, ctx) {
|
|
1426
|
+
const st = await stat2(source);
|
|
1427
|
+
await copyFile(source, staging, constants2.COPYFILE_EXCL);
|
|
1428
|
+
ctx.journal.push({ type: "copy", to: staging });
|
|
1429
|
+
await utimes(staging, st.atime, st.mtime);
|
|
1430
|
+
if ((await stat2(staging)).size !== st.size) throw new Error(`Copy of ${source} is incomplete`);
|
|
1431
|
+
if (await exists(target)) throw new Error(`Target already exists: ${target}`);
|
|
1432
|
+
await rename(staging, target);
|
|
1433
|
+
ctx.journal.push({ type: "rename", from: staging, to: target });
|
|
1434
|
+
await unlink(source);
|
|
1435
|
+
ctx.journal.push({ type: "delete-after-copy", source, copy: target });
|
|
1436
|
+
}
|
|
1437
|
+
async function runMoves(ops, ctx) {
|
|
1438
|
+
const temps = [];
|
|
1439
|
+
const ensured = /* @__PURE__ */ new Set();
|
|
1440
|
+
for (const [i, op] of ops.entries()) {
|
|
1441
|
+
checkCancelled(ctx);
|
|
1442
|
+
const tmp = path3.join(path3.dirname(op.from), tempName(ctx.batchId, i));
|
|
1443
|
+
if (await exists(tmp)) throw new Error(`Temporary name already exists: ${tmp}`);
|
|
1444
|
+
await rename(op.from, tmp);
|
|
1445
|
+
ctx.journal.push({ type: "rename", from: op.from, to: tmp });
|
|
1446
|
+
temps.push(tmp);
|
|
1447
|
+
ctx.onStep?.();
|
|
1448
|
+
}
|
|
1449
|
+
for (const [i, op] of ops.entries()) {
|
|
1450
|
+
checkCancelled(ctx);
|
|
1451
|
+
const tmp = temps[i];
|
|
1452
|
+
if (tmp === void 0) throw new Error("internal: missing temp name");
|
|
1453
|
+
await ensureDir(path3.dirname(op.to), ctx, ensured);
|
|
1454
|
+
if (await exists(op.to)) throw new Error(`Target already exists: ${op.to}`);
|
|
1455
|
+
if (op.crossDrive) {
|
|
1456
|
+
await copyAcross(tmp, op.to, path3.join(path3.dirname(op.to), tempName(ctx.batchId, i, "copy")), ctx);
|
|
1457
|
+
} else {
|
|
1458
|
+
await rename(tmp, op.to);
|
|
1459
|
+
ctx.journal.push({ type: "rename", from: tmp, to: op.to });
|
|
1460
|
+
}
|
|
1461
|
+
ctx.onStep?.();
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
function originOf(index, p, journal) {
|
|
1465
|
+
let current = p;
|
|
1466
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
1467
|
+
const step = journal[i];
|
|
1468
|
+
if (step?.type === "rename" && step.to === current) current = step.from;
|
|
1469
|
+
}
|
|
1470
|
+
return current;
|
|
1471
|
+
}
|
|
1472
|
+
async function rollback(journal, restoreTimes) {
|
|
1473
|
+
const stranded = [];
|
|
1474
|
+
const datesNotRestored = [];
|
|
1475
|
+
const dead = /* @__PURE__ */ new Set();
|
|
1476
|
+
for (let i = journal.length - 1; i >= 0; i -= 1) {
|
|
1477
|
+
const step = journal[i];
|
|
1478
|
+
if (!step) continue;
|
|
1479
|
+
try {
|
|
1480
|
+
switch (step.type) {
|
|
1481
|
+
case "rename":
|
|
1482
|
+
if (dead.has(step.to)) {
|
|
1483
|
+
dead.add(step.from);
|
|
1484
|
+
break;
|
|
1485
|
+
}
|
|
1486
|
+
if (await exists(step.from) || !await exists(step.to)) throw new Error("cannot restore");
|
|
1487
|
+
await rename(step.to, step.from);
|
|
1488
|
+
break;
|
|
1489
|
+
case "mkdir":
|
|
1490
|
+
await rmdir(step.dir).catch(() => void 0);
|
|
1491
|
+
break;
|
|
1492
|
+
case "copy":
|
|
1493
|
+
if (!dead.has(step.to)) {
|
|
1494
|
+
await unlink(step.to).catch(() => void 0);
|
|
1495
|
+
}
|
|
1496
|
+
break;
|
|
1497
|
+
case "delete-after-copy": {
|
|
1498
|
+
if (dead.has(step.copy)) {
|
|
1499
|
+
dead.add(step.source);
|
|
1500
|
+
break;
|
|
1501
|
+
}
|
|
1502
|
+
await copyFile(step.copy, step.source, constants2.COPYFILE_EXCL);
|
|
1503
|
+
const st = await stat2(step.copy);
|
|
1504
|
+
await utimes(step.source, st.atime, st.mtime);
|
|
1505
|
+
datesNotRestored.push(originOf(i, step.source, journal));
|
|
1506
|
+
break;
|
|
1507
|
+
}
|
|
1508
|
+
case "times":
|
|
1509
|
+
await restoreTimes(step.path, step.before);
|
|
1510
|
+
break;
|
|
1511
|
+
}
|
|
1512
|
+
} catch {
|
|
1513
|
+
if (step.type === "rename") {
|
|
1514
|
+
stranded.push({ original: originOf(i, step.from, journal), current: step.to });
|
|
1515
|
+
dead.add(step.from);
|
|
1516
|
+
} else if (step.type === "delete-after-copy") {
|
|
1517
|
+
stranded.push({ original: originOf(i, step.source, journal), current: step.copy });
|
|
1518
|
+
dead.add(step.copy);
|
|
1519
|
+
dead.add(step.source);
|
|
1520
|
+
} else if (step.type === "times") {
|
|
1521
|
+
datesNotRestored.push(step.path);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
const result = {
|
|
1526
|
+
complete: stranded.length === 0 && datesNotRestored.length === 0,
|
|
1527
|
+
stranded
|
|
1528
|
+
};
|
|
1529
|
+
if (datesNotRestored.length > 0) result.datesNotRestored = datesNotRestored;
|
|
1530
|
+
return result;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// src/core/executor/birthtime.ts
|
|
1534
|
+
var run = promisify(execFile);
|
|
1535
|
+
var MAC_SCRIPT = `
|
|
1536
|
+
ObjC.import('Foundation');
|
|
1537
|
+
function run(argv) {
|
|
1538
|
+
const fm = $.NSFileManager.defaultManager;
|
|
1539
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
1540
|
+
const date = $.NSDate.dateWithTimeIntervalSince1970(Number(argv[i + 1]));
|
|
1541
|
+
const attrs = $.NSDictionary.dictionaryWithObjectForKey(date, $.NSFileCreationDate);
|
|
1542
|
+
if (!fm.setAttributesOfItemAtPathError(attrs, argv[i], null)) {
|
|
1543
|
+
throw new Error('Could not set the created date of ' + argv[i]);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return 'ok';
|
|
1547
|
+
}`;
|
|
1548
|
+
function chunks(items, size) {
|
|
1549
|
+
const out = [];
|
|
1550
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
|
|
1551
|
+
return out;
|
|
1552
|
+
}
|
|
1553
|
+
async function runTool(cmd, args) {
|
|
1554
|
+
try {
|
|
1555
|
+
await run(cmd, args);
|
|
1556
|
+
} catch (e) {
|
|
1557
|
+
const err = e;
|
|
1558
|
+
throw new Error((err.stderr ?? "").trim() || "The created-date tool failed");
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
var WINDOWS_JSON_BUDGET = 7e3;
|
|
1562
|
+
function windowsPayload(chunk) {
|
|
1563
|
+
return JSON.stringify(chunk.map((it) => ({ p: it.path, t: Math.round(it.timeMs) })));
|
|
1564
|
+
}
|
|
1565
|
+
function windowsChunks(items) {
|
|
1566
|
+
const out = [];
|
|
1567
|
+
let current = [];
|
|
1568
|
+
for (const item of items) {
|
|
1569
|
+
const candidate = [...current, item];
|
|
1570
|
+
if (current.length > 0 && Buffer.byteLength(windowsPayload(candidate), "utf8") > WINDOWS_JSON_BUDGET) {
|
|
1571
|
+
out.push(current);
|
|
1572
|
+
current = [item];
|
|
1573
|
+
} else {
|
|
1574
|
+
current = candidate;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
if (current.length > 0) out.push(current);
|
|
1578
|
+
return out;
|
|
1579
|
+
}
|
|
1580
|
+
function windowsScript(chunk) {
|
|
1581
|
+
const payload = Buffer.from(windowsPayload(chunk), "utf8").toString("base64");
|
|
1582
|
+
return `$ErrorActionPreference = 'Stop'; $items = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payload}')) | ConvertFrom-Json; foreach ($i in $items) { [System.IO.File]::SetCreationTimeUtc($i.p, [DateTimeOffset]::FromUnixTimeMilliseconds([int64]$i.t).UtcDateTime) }`;
|
|
1583
|
+
}
|
|
1584
|
+
function createBirthtimeSetter(platform) {
|
|
1585
|
+
if (platform === "darwin") {
|
|
1586
|
+
return {
|
|
1587
|
+
supported: true,
|
|
1588
|
+
async set(items, signal) {
|
|
1589
|
+
for (const chunk of chunks(items, 200)) {
|
|
1590
|
+
if (signal?.aborted) throw new CancelledError();
|
|
1591
|
+
const args = chunk.flatMap((it) => [it.path, String(it.timeMs / 1e3)]);
|
|
1592
|
+
await runTool("osascript", ["-l", "JavaScript", "-e", MAC_SCRIPT, ...args]);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
if (platform === "win32") {
|
|
1598
|
+
return {
|
|
1599
|
+
supported: true,
|
|
1600
|
+
async set(items, signal) {
|
|
1601
|
+
for (const chunk of windowsChunks(items)) {
|
|
1602
|
+
if (signal?.aborted) throw new CancelledError();
|
|
1603
|
+
const encoded = Buffer.from(windowsScript(chunk), "utf16le").toString("base64");
|
|
1604
|
+
await runTool("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
return {
|
|
1610
|
+
supported: false,
|
|
1611
|
+
async set() {
|
|
1612
|
+
throw new Error("Setting created dates is not supported on Linux");
|
|
1613
|
+
}
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// src/core/executor/execute.ts
|
|
1618
|
+
function makeTimesRestorer(birthtime) {
|
|
1619
|
+
return async (p, before) => {
|
|
1620
|
+
const now = await timesOf(p);
|
|
1621
|
+
if (Math.abs(now.mtimeMs - before.mtimeMs) >= 1) {
|
|
1622
|
+
await utimes2(p, before.atimeMs / 1e3, before.mtimeMs / 1e3);
|
|
1623
|
+
}
|
|
1624
|
+
if (before.birthtimeMs !== null && birthtime.supported) {
|
|
1625
|
+
const after = await timesOf(p);
|
|
1626
|
+
if (after.birthtimeMs === null || Math.abs(after.birthtimeMs - before.birthtimeMs) >= 1) {
|
|
1627
|
+
await birthtime.set([{ path: p, timeMs: before.birthtimeMs }]);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
async function timesOf(p) {
|
|
1633
|
+
const st = await stat3(p);
|
|
1634
|
+
return { atimeMs: st.atimeMs, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs > 0 ? st.birthtimeMs : null };
|
|
1635
|
+
}
|
|
1636
|
+
async function executePlan(plan, opts = {}) {
|
|
1637
|
+
const noRollback = { complete: true, stranded: [] };
|
|
1638
|
+
if (plan.errorCount > 0 || plan.patternError !== null) {
|
|
1639
|
+
return { status: "failed", error: "The plan has errors. Fix or exclude those files first.", rollback: noRollback };
|
|
1640
|
+
}
|
|
1641
|
+
const birthtime = opts.birthtime ?? createBirthtimeSetter(process.platform);
|
|
1642
|
+
const batchId = opts.batchId ?? randomUUID().slice(0, 8);
|
|
1643
|
+
const moving = plan.items.filter((i) => isMoving(i.kind));
|
|
1644
|
+
const dating = plan.items.filter((i) => hasPlannedDates(i.setDates));
|
|
1645
|
+
const touched = /* @__PURE__ */ new Set([...moving, ...dating]);
|
|
1646
|
+
const changed = [];
|
|
1647
|
+
try {
|
|
1648
|
+
for (const item of touched) {
|
|
1649
|
+
try {
|
|
1650
|
+
const st = await stat3(item.source.path);
|
|
1651
|
+
if (st.size !== item.source.size || st.mtimeMs !== item.source.mtimeMs) changed.push(item.source.path);
|
|
1652
|
+
} catch {
|
|
1653
|
+
changed.push(item.source.path);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
const leaving = new Set(moving.map((i) => nameKey(i.source.path)));
|
|
1657
|
+
for (const item of moving) {
|
|
1658
|
+
if (await exists(item.target) && !leaving.has(nameKey(item.target))) changed.push(item.target);
|
|
1659
|
+
}
|
|
1660
|
+
} catch (e) {
|
|
1661
|
+
return { status: "failed", error: e instanceof Error ? e.message : String(e), rollback: noRollback };
|
|
1662
|
+
}
|
|
1663
|
+
if (changed.length > 0) return { status: "stale", changed };
|
|
1664
|
+
const journal = [];
|
|
1665
|
+
const createdDirs = [];
|
|
1666
|
+
const total = moving.length * 2 + dating.length;
|
|
1667
|
+
let done = 0;
|
|
1668
|
+
const tick = () => {
|
|
1669
|
+
done += 1;
|
|
1670
|
+
opts.onProgress?.(done, total);
|
|
1671
|
+
};
|
|
1672
|
+
const ctx = { batchId, journal, createdDirs, onStep: tick };
|
|
1673
|
+
if (opts.signal) ctx.signal = opts.signal;
|
|
1674
|
+
const originalTimes = /* @__PURE__ */ new Map();
|
|
1675
|
+
try {
|
|
1676
|
+
await runMoves(
|
|
1677
|
+
moving.map((i) => ({ from: i.source.path, to: i.target, crossDrive: i.kind === "cross-drive-move" })),
|
|
1678
|
+
ctx
|
|
1679
|
+
);
|
|
1680
|
+
const created = [];
|
|
1681
|
+
for (const item of dating) {
|
|
1682
|
+
if (opts.signal?.aborted) throw new CancelledError();
|
|
1683
|
+
const before = await timesOf(item.target);
|
|
1684
|
+
journal.push({ type: "times", path: item.target, before });
|
|
1685
|
+
originalTimes.set(item.target, before);
|
|
1686
|
+
if (item.setDates.created) {
|
|
1687
|
+
created.push({ path: item.target, timeMs: wallClockToLocalDate(item.setDates.created).getTime() });
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
if (created.length > 0) await birthtime.set(created, opts.signal);
|
|
1691
|
+
for (const item of dating) {
|
|
1692
|
+
if (opts.signal?.aborted) throw new CancelledError();
|
|
1693
|
+
const before = originalTimes.get(item.target);
|
|
1694
|
+
if (item.setDates.modified && before) {
|
|
1695
|
+
await utimes2(item.target, before.atimeMs / 1e3, wallClockToLocalDate(item.setDates.modified));
|
|
1696
|
+
}
|
|
1697
|
+
tick();
|
|
1698
|
+
}
|
|
1699
|
+
const files = [];
|
|
1700
|
+
for (const item of plan.items) {
|
|
1701
|
+
if (!touched.has(item)) continue;
|
|
1702
|
+
const st = await stat3(item.target);
|
|
1703
|
+
files.push({
|
|
1704
|
+
from: item.source.path,
|
|
1705
|
+
to: item.target,
|
|
1706
|
+
sizeAfter: st.size,
|
|
1707
|
+
mtimeMsAfter: st.mtimeMs,
|
|
1708
|
+
originalTimes: originalTimes.get(item.target) ?? null
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
return { status: "done", record: { id: batchId, files, createdDirs, finishedAt: Date.now() } };
|
|
1712
|
+
} catch (e) {
|
|
1713
|
+
const report = await rollback(journal, makeTimesRestorer(birthtime));
|
|
1714
|
+
if (e instanceof CancelledError) return { status: "cancelled", error: null, rollback: report };
|
|
1715
|
+
return { status: "failed", error: e instanceof Error ? e.message : String(e), rollback: report };
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// src/core/hash.ts
|
|
1720
|
+
import { createHash } from "node:crypto";
|
|
1721
|
+
import { createReadStream } from "node:fs";
|
|
1722
|
+
import { crc32 } from "node:zlib";
|
|
1723
|
+
function crc32Update(crc, chunk) {
|
|
1724
|
+
return crc32(chunk, crc);
|
|
1725
|
+
}
|
|
1726
|
+
var hex8 = (n2) => n2.toString(16).padStart(8, "0");
|
|
1727
|
+
async function hashFile(filePath, signal) {
|
|
1728
|
+
if (signal?.aborted) throw new Error("Cancelled");
|
|
1729
|
+
const md5 = createHash("md5");
|
|
1730
|
+
let crc = 0;
|
|
1731
|
+
const stream = createReadStream(filePath, { highWaterMark: 1024 * 1024 });
|
|
1732
|
+
try {
|
|
1733
|
+
for await (const chunk of stream) {
|
|
1734
|
+
if (signal?.aborted) throw new Error("Cancelled");
|
|
1735
|
+
const bytes = chunk;
|
|
1736
|
+
md5.update(bytes);
|
|
1737
|
+
crc = crc32Update(crc, bytes);
|
|
1738
|
+
}
|
|
1739
|
+
} finally {
|
|
1740
|
+
stream.destroy();
|
|
1741
|
+
}
|
|
1742
|
+
return { crc32: hex8(crc), md5: md5.digest("hex") };
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// src/core/history.ts
|
|
1746
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1747
|
+
import { rmdir as rmdir2, stat as stat4, utimes as utimes3 } from "node:fs/promises";
|
|
1748
|
+
import path4 from "node:path";
|
|
1749
|
+
async function crossesDrive(from, to) {
|
|
1750
|
+
const source = await stat4(from);
|
|
1751
|
+
let dir = path4.dirname(to);
|
|
1752
|
+
while (!await exists(dir)) {
|
|
1753
|
+
const parent = path4.dirname(dir);
|
|
1754
|
+
if (parent === dir) break;
|
|
1755
|
+
dir = parent;
|
|
1756
|
+
}
|
|
1757
|
+
return (await stat4(dir)).dev !== source.dev;
|
|
1758
|
+
}
|
|
1759
|
+
var History = class {
|
|
1760
|
+
stack = [];
|
|
1761
|
+
running = false;
|
|
1762
|
+
push(record) {
|
|
1763
|
+
this.stack.push(record);
|
|
1764
|
+
}
|
|
1765
|
+
canUndo() {
|
|
1766
|
+
return this.stack.length > 0;
|
|
1767
|
+
}
|
|
1768
|
+
peek() {
|
|
1769
|
+
return this.stack[this.stack.length - 1];
|
|
1770
|
+
}
|
|
1771
|
+
get size() {
|
|
1772
|
+
return this.stack.length;
|
|
1773
|
+
}
|
|
1774
|
+
async undo(opts = {}) {
|
|
1775
|
+
if (this.running) {
|
|
1776
|
+
return { status: "failed", restored: 0, skipped: [], error: "An undo is already running", rollback: null };
|
|
1777
|
+
}
|
|
1778
|
+
this.running = true;
|
|
1779
|
+
try {
|
|
1780
|
+
const record = this.peek();
|
|
1781
|
+
if (!record) return { status: "nothing-to-undo", restored: 0, skipped: [], error: null, rollback: null };
|
|
1782
|
+
const birthtime = opts.birthtime ?? createBirthtimeSetter(process.platform);
|
|
1783
|
+
const restoreTimes = makeTimesRestorer(birthtime);
|
|
1784
|
+
const skipped = [];
|
|
1785
|
+
let restorable;
|
|
1786
|
+
let ops;
|
|
1787
|
+
try {
|
|
1788
|
+
const unchanged = [];
|
|
1789
|
+
for (const f of record.files) {
|
|
1790
|
+
try {
|
|
1791
|
+
const st = await stat4(f.to);
|
|
1792
|
+
if (st.size !== f.sizeAfter || st.mtimeMs !== f.mtimeMsAfter) {
|
|
1793
|
+
skipped.push({ path: f.to, reason: "Changed since the rename" });
|
|
1794
|
+
continue;
|
|
1795
|
+
}
|
|
1796
|
+
} catch (e) {
|
|
1797
|
+
const code = e.code;
|
|
1798
|
+
const reason = code === "ENOENT" || code === "ENOTDIR" ? "No longer at its new location" : `Couldn't check it: ${e instanceof Error ? e.message : String(e)}`;
|
|
1799
|
+
skipped.push({ path: f.to, reason });
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
unchanged.push(f);
|
|
1803
|
+
}
|
|
1804
|
+
restorable = unchanged;
|
|
1805
|
+
for (; ; ) {
|
|
1806
|
+
const leaving = new Set(restorable.filter((f) => f.from !== f.to).map((f) => nameKey(f.to)));
|
|
1807
|
+
const kept = [];
|
|
1808
|
+
for (const f of restorable) {
|
|
1809
|
+
if (f.from !== f.to && await exists(f.from) && !leaving.has(nameKey(f.from))) {
|
|
1810
|
+
skipped.push({ path: f.to, reason: "Its original name is taken by another file" });
|
|
1811
|
+
} else {
|
|
1812
|
+
kept.push(f);
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
if (kept.length === restorable.length) break;
|
|
1816
|
+
restorable = kept;
|
|
1817
|
+
}
|
|
1818
|
+
ops = [];
|
|
1819
|
+
for (const f of restorable) {
|
|
1820
|
+
if (f.from !== f.to) ops.push({ from: f.to, to: f.from, crossDrive: await crossesDrive(f.to, f.from) });
|
|
1821
|
+
}
|
|
1822
|
+
} catch (e) {
|
|
1823
|
+
return {
|
|
1824
|
+
status: "failed",
|
|
1825
|
+
restored: 0,
|
|
1826
|
+
skipped,
|
|
1827
|
+
error: e instanceof Error ? e.message : String(e),
|
|
1828
|
+
rollback: { complete: true, stranded: [] }
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
const journal = [];
|
|
1832
|
+
const ctx = { batchId: `undo-${record.id}-${randomUUID2().slice(0, 8)}`, journal, createdDirs: [] };
|
|
1833
|
+
if (opts.signal) ctx.signal = opts.signal;
|
|
1834
|
+
try {
|
|
1835
|
+
if (opts.signal?.aborted) throw new CancelledError();
|
|
1836
|
+
await runMoves(ops, ctx);
|
|
1837
|
+
const created = [];
|
|
1838
|
+
for (const f of restorable) {
|
|
1839
|
+
if (!f.originalTimes) continue;
|
|
1840
|
+
if (opts.signal?.aborted) throw new CancelledError();
|
|
1841
|
+
const now = await timesOf(f.from);
|
|
1842
|
+
journal.push({ type: "times", path: f.from, before: now });
|
|
1843
|
+
if (Math.abs(now.mtimeMs - f.originalTimes.mtimeMs) >= 1) {
|
|
1844
|
+
await utimes3(f.from, f.originalTimes.atimeMs / 1e3, f.originalTimes.mtimeMs / 1e3);
|
|
1845
|
+
}
|
|
1846
|
+
const wanted = f.originalTimes.birthtimeMs;
|
|
1847
|
+
if (wanted !== null && birthtime.supported) {
|
|
1848
|
+
const after = await timesOf(f.from);
|
|
1849
|
+
if (after.birthtimeMs === null || Math.abs(after.birthtimeMs - wanted) >= 1) {
|
|
1850
|
+
created.push({ path: f.from, timeMs: wanted });
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
if (created.length > 0) await birthtime.set(created, opts.signal);
|
|
1855
|
+
} catch (e) {
|
|
1856
|
+
const report = await rollback(journal, restoreTimes);
|
|
1857
|
+
if (e instanceof CancelledError) {
|
|
1858
|
+
return { status: "cancelled", restored: 0, skipped, error: null, rollback: report };
|
|
1859
|
+
}
|
|
1860
|
+
return { status: "failed", restored: 0, skipped, error: e instanceof Error ? e.message : String(e), rollback: report };
|
|
1861
|
+
}
|
|
1862
|
+
for (const dir of [...record.createdDirs].sort((a, b) => b.length - a.length)) {
|
|
1863
|
+
await rmdir2(dir).catch(() => void 0);
|
|
1864
|
+
}
|
|
1865
|
+
const i = this.stack.lastIndexOf(record);
|
|
1866
|
+
if (i !== -1) this.stack.splice(i, 1);
|
|
1867
|
+
return { status: "done", restored: restorable.length, skipped, error: null, rollback: null };
|
|
1868
|
+
} finally {
|
|
1869
|
+
this.running = false;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
};
|
|
1873
|
+
|
|
1874
|
+
// src/core/presets.ts
|
|
1875
|
+
var isObject = (v) => typeof v === "object" && v !== null;
|
|
1876
|
+
var bool = (v, fallback) => typeof v === "boolean" ? v : fallback;
|
|
1877
|
+
var oneOf = (v, options, fallback) => options.includes(v) ? v : fallback;
|
|
1878
|
+
var int = (v, fallback, min, max) => {
|
|
1879
|
+
if (!Number.isInteger(v)) return fallback;
|
|
1880
|
+
const n2 = v;
|
|
1881
|
+
if (min !== void 0 && n2 < min) return fallback;
|
|
1882
|
+
if (max !== void 0 && n2 > max) return fallback;
|
|
1883
|
+
return n2;
|
|
1884
|
+
};
|
|
1885
|
+
var stringOrNull = (v, fallback) => typeof v === "string" || v === null ? v : fallback;
|
|
1886
|
+
var stringArray = (v, fallback) => {
|
|
1887
|
+
if (Array.isArray(v) && v.every((e) => typeof e === "string")) return v;
|
|
1888
|
+
return v === null ? null : fallback;
|
|
1889
|
+
};
|
|
1890
|
+
var DATE_ONLY2 = /^\d{4}-\d{2}-\d{2}$/;
|
|
1891
|
+
var dateOrNull = (v) => typeof v === "string" && DATE_ONLY2.test(v) ? v : null;
|
|
1892
|
+
var intOrNull = (v, min) => Number.isInteger(v) && v >= min ? v : null;
|
|
1893
|
+
function withDefaults(partial) {
|
|
1894
|
+
const p = isObject(partial) ? partial : {};
|
|
1895
|
+
const seq = isObject(p.sequence) ? p.sequence : {};
|
|
1896
|
+
const cleanup = isObject(p.cleanup) ? p.cleanup : {};
|
|
1897
|
+
const move = isObject(p.move) ? p.move : {};
|
|
1898
|
+
const dates = isObject(p.dates) ? p.dates : {};
|
|
1899
|
+
return {
|
|
1900
|
+
pattern: typeof p.pattern === "string" ? p.pattern : DEFAULT_SETTINGS.pattern,
|
|
1901
|
+
sequence: {
|
|
1902
|
+
sortBy: oneOf(seq.sortBy, ["dateTaken", "created", "modified", "name", "manual"], DEFAULT_SETTINGS.sequence.sortBy),
|
|
1903
|
+
direction: oneOf(seq.direction, ["asc", "desc"], DEFAULT_SETTINGS.sequence.direction),
|
|
1904
|
+
start: int(seq.start, DEFAULT_SETTINGS.sequence.start, 0, 999999999),
|
|
1905
|
+
step: int(seq.step, DEFAULT_SETTINGS.sequence.step, 1, 1e6),
|
|
1906
|
+
digits: int(seq.digits, DEFAULT_SETTINGS.sequence.digits, 1, 10),
|
|
1907
|
+
restartPerFolder: bool(seq.restartPerFolder, DEFAULT_SETTINGS.sequence.restartPerFolder),
|
|
1908
|
+
restartEvery: oneOf(seq.restartEvery, ["never", "day", "month", "year"], DEFAULT_SETTINGS.sequence.restartEvery),
|
|
1909
|
+
keepGroupsTogether: bool(seq.keepGroupsTogether, DEFAULT_SETTINGS.sequence.keepGroupsTogether)
|
|
1910
|
+
},
|
|
1911
|
+
findReplace: Array.isArray(p.findReplace) ? p.findReplace.filter(
|
|
1912
|
+
(item) => isObject(item) && typeof item.find === "string" && typeof item.replace === "string" && typeof item.regex === "boolean" && typeof item.matchCase === "boolean"
|
|
1913
|
+
).map((item) => {
|
|
1914
|
+
const rule = {
|
|
1915
|
+
find: item.find,
|
|
1916
|
+
replace: item.replace,
|
|
1917
|
+
regex: item.regex,
|
|
1918
|
+
matchCase: item.matchCase
|
|
1919
|
+
};
|
|
1920
|
+
if (item.scope === "original" || item.scope === "result") rule.scope = item.scope;
|
|
1921
|
+
return rule;
|
|
1922
|
+
}) : [],
|
|
1923
|
+
cleanup: {
|
|
1924
|
+
caseMode: oneOf(
|
|
1925
|
+
cleanup.caseMode,
|
|
1926
|
+
["none", "lower", "upper", "title", "sentence", "snake", "kebab", "camel"],
|
|
1927
|
+
DEFAULT_SETTINGS.cleanup.caseMode
|
|
1928
|
+
),
|
|
1929
|
+
spacesToUnderscores: bool(cleanup.spacesToUnderscores, DEFAULT_SETTINGS.cleanup.spacesToUnderscores),
|
|
1930
|
+
lowercaseExtension: bool(cleanup.lowercaseExtension, DEFAULT_SETTINGS.cleanup.lowercaseExtension),
|
|
1931
|
+
stripDiacritics: bool(cleanup.stripDiacritics, DEFAULT_SETTINGS.cleanup.stripDiacritics),
|
|
1932
|
+
asciiOnly: bool(cleanup.asciiOnly, DEFAULT_SETTINGS.cleanup.asciiOnly),
|
|
1933
|
+
extensionRules: Array.isArray(cleanup.extensionRules) ? cleanup.extensionRules.filter((r) => isObject(r) && typeof r.from === "string" && typeof r.to === "string").map((r) => ({ from: normalizeExtension(r.from), to: normalizeExtension(r.to) })).filter((r) => r.from !== "") : []
|
|
1934
|
+
},
|
|
1935
|
+
move: {
|
|
1936
|
+
destinationRoot: stringOrNull(move.destinationRoot, DEFAULT_SETTINGS.move.destinationRoot)
|
|
1937
|
+
},
|
|
1938
|
+
dates: {
|
|
1939
|
+
setModified: bool(dates.setModified, DEFAULT_SETTINGS.dates.setModified),
|
|
1940
|
+
setCreated: bool(dates.setCreated, DEFAULT_SETTINGS.dates.setCreated),
|
|
1941
|
+
shiftMinutes: int(dates.shiftMinutes, DEFAULT_SETTINGS.dates.shiftMinutes, -1e6, 1e6),
|
|
1942
|
+
useNameDate: bool(dates.useNameDate, DEFAULT_SETTINGS.dates.useNameDate)
|
|
1943
|
+
},
|
|
1944
|
+
filter: filterWithDefaults(p.filter)
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
function filterWithDefaults(v) {
|
|
1948
|
+
const filter = isObject(v) ? v : {};
|
|
1949
|
+
return {
|
|
1950
|
+
includeSubfolders: bool(filter.includeSubfolders, DEFAULT_SETTINGS.filter.includeSubfolders),
|
|
1951
|
+
extensions: stringArray(filter.extensions, DEFAULT_SETTINGS.filter.extensions),
|
|
1952
|
+
mode: oneOf(filter.mode, ["files", "folders"], DEFAULT_SETTINGS.filter.mode),
|
|
1953
|
+
name: {
|
|
1954
|
+
text: isObject(filter.name) && typeof filter.name.text === "string" ? filter.name.text : "",
|
|
1955
|
+
regex: isObject(filter.name) ? bool(filter.name.regex, false) : false
|
|
1956
|
+
},
|
|
1957
|
+
minBytes: intOrNull(filter.minBytes, 0),
|
|
1958
|
+
maxBytes: intOrNull(filter.maxBytes, 0),
|
|
1959
|
+
modifiedFrom: dateOrNull(filter.modifiedFrom),
|
|
1960
|
+
modifiedTo: dateOrNull(filter.modifiedTo)
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// src/core/pipeline.ts
|
|
1965
|
+
var EXIFTOOL_FAILURE_LIMIT = 5;
|
|
1966
|
+
var HASH_CONCURRENCY = 4;
|
|
1967
|
+
var READ_CONCURRENCY = 8;
|
|
1968
|
+
function patternUsesHash(pattern) {
|
|
1969
|
+
const parsed2 = parsePattern(pattern);
|
|
1970
|
+
return parsed2.ok && parsed2.nodes.some((n2) => n2.type === "token" && (n2.name === "crc32" || n2.name === "md5"));
|
|
1971
|
+
}
|
|
1972
|
+
async function readMetadata(entries, reader, opts = {}) {
|
|
1973
|
+
const stop = new AbortController();
|
|
1974
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, stop.signal]) : stop.signal;
|
|
1975
|
+
let failuresInARow = 0;
|
|
1976
|
+
let exiftoolFailed = false;
|
|
1977
|
+
let probing = false;
|
|
1978
|
+
let probe = null;
|
|
1979
|
+
let threw = false;
|
|
1980
|
+
let error2;
|
|
1981
|
+
const onResult = (p, meta) => {
|
|
1982
|
+
if (threw || opts.signal?.aborted) return;
|
|
1983
|
+
failuresInARow = meta.readError === void 0 ? 0 : failuresInARow + 1;
|
|
1984
|
+
if (failuresInARow >= EXIFTOOL_FAILURE_LIMIT && !exiftoolFailed && !probing) {
|
|
1985
|
+
probing = true;
|
|
1986
|
+
probe = reader.isWorking().then((working) => {
|
|
1987
|
+
probing = false;
|
|
1988
|
+
if (opts.signal?.aborted) return;
|
|
1989
|
+
if (working) {
|
|
1990
|
+
failuresInARow = 0;
|
|
1991
|
+
} else {
|
|
1992
|
+
exiftoolFailed = true;
|
|
1993
|
+
stop.abort();
|
|
1994
|
+
}
|
|
1995
|
+
});
|
|
1996
|
+
}
|
|
1997
|
+
try {
|
|
1998
|
+
opts.onResult?.(p, meta, { exiftoolFailed });
|
|
1999
|
+
} catch (e) {
|
|
2000
|
+
threw = true;
|
|
2001
|
+
error2 = e;
|
|
2002
|
+
throw e;
|
|
2003
|
+
}
|
|
2004
|
+
};
|
|
2005
|
+
try {
|
|
2006
|
+
await reader.readAll(entries, void 0, READ_CONCURRENCY, { signal, onResult });
|
|
2007
|
+
} catch (e) {
|
|
2008
|
+
if (!threw) {
|
|
2009
|
+
threw = true;
|
|
2010
|
+
error2 = e;
|
|
2011
|
+
}
|
|
2012
|
+
stop.abort();
|
|
2013
|
+
}
|
|
2014
|
+
if (probe) await probe;
|
|
2015
|
+
return threw ? { exiftoolFailed, error: error2 } : { exiftoolFailed };
|
|
2016
|
+
}
|
|
2017
|
+
var sizeKey = (e) => `${e.size}:${e.mtimeMs}`;
|
|
2018
|
+
var HashCache = class {
|
|
2019
|
+
constructor(timeZone) {
|
|
2020
|
+
this.timeZone = timeZone;
|
|
2021
|
+
}
|
|
2022
|
+
timeZone;
|
|
2023
|
+
hashes = /* @__PURE__ */ new Map();
|
|
2024
|
+
get size() {
|
|
2025
|
+
return this.hashes.size;
|
|
2026
|
+
}
|
|
2027
|
+
/** Hashes the files that have no current hash, HASH_CONCURRENCY at a time. Skips folders. */
|
|
2028
|
+
async hashMissing(entries, signal) {
|
|
2029
|
+
const todo = entries.filter((e) => !e.isDir && this.hashes.get(e.path)?.key !== sizeKey(e));
|
|
2030
|
+
let next = 0;
|
|
2031
|
+
const worker = async () => {
|
|
2032
|
+
while (next < todo.length) {
|
|
2033
|
+
if (signal?.aborted) return;
|
|
2034
|
+
const entry = todo[next];
|
|
2035
|
+
next += 1;
|
|
2036
|
+
if (!entry) continue;
|
|
2037
|
+
try {
|
|
2038
|
+
const h = await hashFile(entry.path, signal);
|
|
2039
|
+
this.hashes.set(entry.path, { key: sizeKey(entry), ...h });
|
|
2040
|
+
} catch {
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
};
|
|
2044
|
+
await Promise.all(Array.from({ length: Math.min(HASH_CONCURRENCY, todo.length) }, worker));
|
|
2045
|
+
}
|
|
2046
|
+
/** The file's metadata with its current hashes, or undefined when it has none. */
|
|
2047
|
+
withHash(entry, metadata) {
|
|
2048
|
+
const h = this.hashes.get(entry.path);
|
|
2049
|
+
if (!h || h.key !== sizeKey(entry)) return void 0;
|
|
2050
|
+
return { ...metadata.get(entry.path) ?? fsMetadata(entry, this.timeZone), crc32: h.crc32, md5: h.md5 };
|
|
2051
|
+
}
|
|
2052
|
+
/** The metadata map with current hashes merged in; the map itself when there are none. */
|
|
2053
|
+
mergeInto(metadata, entries) {
|
|
2054
|
+
if (this.hashes.size === 0) return metadata;
|
|
2055
|
+
const out = new Map(metadata);
|
|
2056
|
+
for (const e of entries) {
|
|
2057
|
+
const meta = this.withHash(e, metadata);
|
|
2058
|
+
if (meta) out.set(e.path, meta);
|
|
2059
|
+
}
|
|
2060
|
+
return out;
|
|
2061
|
+
}
|
|
2062
|
+
};
|
|
2063
|
+
|
|
2064
|
+
// src/cli/args.ts
|
|
2065
|
+
import { readFile } from "node:fs/promises";
|
|
2066
|
+
import path5 from "node:path";
|
|
2067
|
+
import { parseArgs } from "node:util";
|
|
2068
|
+
var UsageError = class extends Error {
|
|
2069
|
+
constructor(message2) {
|
|
2070
|
+
super(message2);
|
|
2071
|
+
this.name = "UsageError";
|
|
2072
|
+
}
|
|
2073
|
+
};
|
|
2074
|
+
var SORTS = ["dateTaken", "created", "modified", "name"];
|
|
2075
|
+
var RESTARTS = ["never", "day", "month", "year"];
|
|
2076
|
+
var CASES = ["none", "lower", "upper", "title", "sentence", "snake", "kebab", "camel"];
|
|
2077
|
+
var RENAME_OPTIONS = {
|
|
2078
|
+
settings: { type: "string" },
|
|
2079
|
+
pattern: { type: "string", short: "p" },
|
|
2080
|
+
sort: { type: "string" },
|
|
2081
|
+
desc: { type: "boolean" },
|
|
2082
|
+
start: { type: "string" },
|
|
2083
|
+
step: { type: "string" },
|
|
2084
|
+
digits: { type: "string" },
|
|
2085
|
+
restart: { type: "string" },
|
|
2086
|
+
"restart-per-folder": { type: "boolean" },
|
|
2087
|
+
replace: { type: "string", multiple: true },
|
|
2088
|
+
regex: { type: "boolean" },
|
|
2089
|
+
"match-case": { type: "boolean" },
|
|
2090
|
+
case: { type: "string" },
|
|
2091
|
+
"spaces-to-underscores": { type: "boolean" },
|
|
2092
|
+
ascii: { type: "boolean" },
|
|
2093
|
+
"strip-accents": { type: "boolean" },
|
|
2094
|
+
ext: { type: "string", multiple: true },
|
|
2095
|
+
dest: { type: "string" },
|
|
2096
|
+
shift: { type: "string" },
|
|
2097
|
+
"use-name-date": { type: "boolean" },
|
|
2098
|
+
"set-modified": { type: "boolean" },
|
|
2099
|
+
"set-created": { type: "boolean" },
|
|
2100
|
+
recursive: { type: "boolean", short: "r" },
|
|
2101
|
+
"only-ext": { type: "string" },
|
|
2102
|
+
folders: { type: "boolean" },
|
|
2103
|
+
name: { type: "string" },
|
|
2104
|
+
apply: { type: "boolean" },
|
|
2105
|
+
journal: { type: "string" },
|
|
2106
|
+
json: { type: "boolean" },
|
|
2107
|
+
quiet: { type: "boolean", short: "q" },
|
|
2108
|
+
help: { type: "boolean", short: "h" }
|
|
2109
|
+
};
|
|
2110
|
+
var UNDO_OPTIONS = { help: { type: "boolean", short: "h" } };
|
|
2111
|
+
function parsed(run2) {
|
|
2112
|
+
try {
|
|
2113
|
+
return run2();
|
|
2114
|
+
} catch (e) {
|
|
2115
|
+
const code = e.code;
|
|
2116
|
+
if (typeof code === "string" && code.startsWith("ERR_PARSE_ARGS")) {
|
|
2117
|
+
throw new UsageError(e.message.replace(/\s*\n\s*/g, " "));
|
|
2118
|
+
}
|
|
2119
|
+
throw e;
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
var parseRenameArgs = (args) => parsed(() => parseArgs({ args: [...args], options: RENAME_OPTIONS, allowPositionals: true, strict: true }));
|
|
2123
|
+
async function parseCommand(argv, cwd, readText = (file) => readFile(file, "utf8")) {
|
|
2124
|
+
const [first, ...rest] = argv;
|
|
2125
|
+
if (first === void 0) return { kind: "help" };
|
|
2126
|
+
if (first === "rename") return parseRename(rest, cwd, readText);
|
|
2127
|
+
if (first === "undo") return parseUndo(rest, cwd);
|
|
2128
|
+
if (first === "tokens") {
|
|
2129
|
+
if (rest[0] !== void 0) throw new UsageError(`tokens takes no arguments, got "${rest[0]}"`);
|
|
2130
|
+
return { kind: "tokens" };
|
|
2131
|
+
}
|
|
2132
|
+
if (argv.length === 1 && (first === "--help" || first === "-h")) return { kind: "help" };
|
|
2133
|
+
if (argv.length === 1 && (first === "--version" || first === "-v")) return { kind: "version" };
|
|
2134
|
+
const flag = argv.find((a) => a.startsWith("-"));
|
|
2135
|
+
if (flag !== void 0) {
|
|
2136
|
+
throw new UsageError(`"${flag}" needs a command; to rename from the terminal use "renami rename <paths\u2026> ${flag}"`);
|
|
2137
|
+
}
|
|
2138
|
+
return { kind: "open", paths: argv.map((p) => path5.resolve(cwd, p)) };
|
|
2139
|
+
}
|
|
2140
|
+
function parseUndo(args, cwd) {
|
|
2141
|
+
const { values, positionals } = parsed(
|
|
2142
|
+
() => parseArgs({ args: [...args], options: UNDO_OPTIONS, allowPositionals: true, strict: true })
|
|
2143
|
+
);
|
|
2144
|
+
if (values.help) return { kind: "help" };
|
|
2145
|
+
const [journal] = positionals;
|
|
2146
|
+
if (journal === void 0 || positionals.length !== 1) {
|
|
2147
|
+
throw new UsageError("undo needs exactly one journal file: renami undo <journal.json>");
|
|
2148
|
+
}
|
|
2149
|
+
return { kind: "undo", journal: path5.resolve(cwd, journal) };
|
|
2150
|
+
}
|
|
2151
|
+
async function parseRename(args, cwd, readText) {
|
|
2152
|
+
const { values, positionals } = parseRenameArgs(args);
|
|
2153
|
+
if (values.help) return { kind: "help" };
|
|
2154
|
+
if (positionals.length === 0) throw new UsageError("rename needs at least one file or folder");
|
|
2155
|
+
if (values.journal !== void 0 && !values.apply) throw new UsageError("--journal only works with --apply");
|
|
2156
|
+
const settings = values.settings === void 0 ? withDefaults({}) : await readSettings(path5.resolve(cwd, values.settings), readText);
|
|
2157
|
+
applyFlags(settings, values, cwd);
|
|
2158
|
+
return {
|
|
2159
|
+
kind: "rename",
|
|
2160
|
+
paths: positionals.map((p) => path5.resolve(cwd, p)),
|
|
2161
|
+
settings,
|
|
2162
|
+
options: {
|
|
2163
|
+
apply: values.apply === true,
|
|
2164
|
+
journal: values.journal === void 0 ? null : path5.resolve(cwd, values.journal),
|
|
2165
|
+
json: values.json === true,
|
|
2166
|
+
quiet: values.quiet === true
|
|
2167
|
+
}
|
|
2168
|
+
};
|
|
2169
|
+
}
|
|
2170
|
+
async function readSettings(file, readText) {
|
|
2171
|
+
let data;
|
|
2172
|
+
try {
|
|
2173
|
+
data = JSON.parse(await readText(file));
|
|
2174
|
+
} catch (e) {
|
|
2175
|
+
throw new UsageError(`can't read the settings file ${file}: ${e.message}`);
|
|
2176
|
+
}
|
|
2177
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
2178
|
+
throw new UsageError(`the settings file ${file} must hold a JSON object`);
|
|
2179
|
+
}
|
|
2180
|
+
const settings = withDefaults(data);
|
|
2181
|
+
if (settings.sequence.sortBy === "manual") throw new UsageError('sortBy "manual" only works in the desktop app');
|
|
2182
|
+
return settings;
|
|
2183
|
+
}
|
|
2184
|
+
function applyFlags(s, v, cwd) {
|
|
2185
|
+
if (v.pattern !== void 0) s.pattern = v.pattern;
|
|
2186
|
+
if (v.sort !== void 0) s.sequence.sortBy = choice("--sort", v.sort, SORTS);
|
|
2187
|
+
if (v.desc) s.sequence.direction = "desc";
|
|
2188
|
+
if (v.start !== void 0) s.sequence.start = integer("--start", v.start, 0, 999999999);
|
|
2189
|
+
if (v.step !== void 0) s.sequence.step = integer("--step", v.step, 1, 1e6);
|
|
2190
|
+
if (v.digits !== void 0) s.sequence.digits = integer("--digits", v.digits, 1, 10);
|
|
2191
|
+
if (v.restart !== void 0) s.sequence.restartEvery = choice("--restart", v.restart, RESTARTS);
|
|
2192
|
+
if (v["restart-per-folder"]) s.sequence.restartPerFolder = true;
|
|
2193
|
+
for (const pair of v.replace ?? []) {
|
|
2194
|
+
const [find, replace] = split("--replace", pair, "FIND=REPL");
|
|
2195
|
+
s.findReplace.push({ find, replace, regex: v.regex === true, matchCase: v["match-case"] === true });
|
|
2196
|
+
}
|
|
2197
|
+
if (v.case !== void 0) s.cleanup.caseMode = choice("--case", v.case, CASES);
|
|
2198
|
+
if (v["spaces-to-underscores"]) s.cleanup.spacesToUnderscores = true;
|
|
2199
|
+
if (v.ascii) s.cleanup.asciiOnly = true;
|
|
2200
|
+
if (v["strip-accents"]) s.cleanup.stripDiacritics = true;
|
|
2201
|
+
for (const pair of v.ext ?? []) {
|
|
2202
|
+
const [from, to] = split("--ext", pair, "FROM=TO");
|
|
2203
|
+
s.cleanup.extensionRules.push({ from: normalizeExtension(from), to: normalizeExtension(to) });
|
|
2204
|
+
}
|
|
2205
|
+
if (v.dest !== void 0) s.move.destinationRoot = path5.resolve(cwd, v.dest);
|
|
2206
|
+
if (v.shift !== void 0) s.dates.shiftMinutes = integer("--shift", v.shift, -1e6, 1e6);
|
|
2207
|
+
if (v["use-name-date"]) s.dates.useNameDate = true;
|
|
2208
|
+
if (v["set-modified"]) s.dates.setModified = true;
|
|
2209
|
+
if (v["set-created"]) s.dates.setCreated = true;
|
|
2210
|
+
if (v.recursive) s.filter.includeSubfolders = true;
|
|
2211
|
+
if (v["only-ext"] !== void 0) {
|
|
2212
|
+
const extensions = v["only-ext"].split(",").map(normalizeExtension).filter((e) => e !== "");
|
|
2213
|
+
if (extensions.length === 0) throw new UsageError("--only-ext needs at least one extension");
|
|
2214
|
+
s.filter.extensions = extensions;
|
|
2215
|
+
}
|
|
2216
|
+
if (v.folders) s.filter.mode = "folders";
|
|
2217
|
+
if (v.name !== void 0) s.filter.name = { text: v.name, regex: false };
|
|
2218
|
+
}
|
|
2219
|
+
function split(flag, value, shape) {
|
|
2220
|
+
const i = value.indexOf("=");
|
|
2221
|
+
if (i <= 0) throw new UsageError(`${flag} needs ${shape}, got "${value}"`);
|
|
2222
|
+
return [value.slice(0, i), value.slice(i + 1)];
|
|
2223
|
+
}
|
|
2224
|
+
function integer(flag, value, min, max) {
|
|
2225
|
+
const n2 = Number(value);
|
|
2226
|
+
if (!/^[+-]?\d+$/.test(value.trim()) || n2 < min || n2 > max) {
|
|
2227
|
+
throw new UsageError(`${flag} needs a whole number from ${min} to ${max}, got "${value}"`);
|
|
2228
|
+
}
|
|
2229
|
+
return n2;
|
|
2230
|
+
}
|
|
2231
|
+
function choice(flag, value, options) {
|
|
2232
|
+
const match = options.find((o) => o === value);
|
|
2233
|
+
if (match === void 0) throw new UsageError(`${flag} must be one of ${options.join(", ")}; got "${value}"`);
|
|
2234
|
+
return match;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// src/cli/exit.ts
|
|
2238
|
+
var EXIT = {
|
|
2239
|
+
ok: 0,
|
|
2240
|
+
usage: 1,
|
|
2241
|
+
patternError: 2,
|
|
2242
|
+
planErrors: 3,
|
|
2243
|
+
stale: 4,
|
|
2244
|
+
failed: 5,
|
|
2245
|
+
undoSkipped: 6,
|
|
2246
|
+
appNotFound: 7
|
|
2247
|
+
};
|
|
2248
|
+
|
|
2249
|
+
// src/cli/launch.ts
|
|
2250
|
+
import { execFile as execFile2, spawn } from "node:child_process";
|
|
2251
|
+
import { access } from "node:fs/promises";
|
|
2252
|
+
import path6 from "node:path";
|
|
2253
|
+
import { promisify as promisify2 } from "node:util";
|
|
2254
|
+
var WINDOWS_INSTALL_KEY = "Software\\09c3bcba-8eb4-57fd-a1c8-89a5efb9a1ed";
|
|
2255
|
+
var LINUX_APP = "/opt/Renami/renami-app";
|
|
2256
|
+
function parseRegValue(stdout) {
|
|
2257
|
+
return /InstallLocation\s+REG_\w+\s+(.+?)\s*$/m.exec(stdout)?.[1] ?? null;
|
|
2258
|
+
}
|
|
2259
|
+
async function windowsApp(deps) {
|
|
2260
|
+
for (const root of ["HKCU", "HKLM"]) {
|
|
2261
|
+
const dir = await deps.installLocation(root, WINDOWS_INSTALL_KEY);
|
|
2262
|
+
const file = dir ? path6.win32.join(dir, "Renami.exe") : null;
|
|
2263
|
+
if (file && await deps.exists(file)) return file;
|
|
2264
|
+
}
|
|
2265
|
+
const { LOCALAPPDATA, ProgramFiles } = deps.env;
|
|
2266
|
+
const defaults = [
|
|
2267
|
+
LOCALAPPDATA && path6.win32.join(LOCALAPPDATA, "Programs", "Renami", "Renami.exe"),
|
|
2268
|
+
ProgramFiles && path6.win32.join(ProgramFiles, "Renami", "Renami.exe")
|
|
2269
|
+
];
|
|
2270
|
+
for (const file of defaults) if (file && await deps.exists(file)) return file;
|
|
2271
|
+
return null;
|
|
2272
|
+
}
|
|
2273
|
+
async function openInApp(paths, deps) {
|
|
2274
|
+
if (deps.platform === "darwin") return await deps.run("open", ["-a", "Renami", ...paths]) === 0;
|
|
2275
|
+
const app = deps.platform === "win32" ? await windowsApp(deps) : await deps.exists(LINUX_APP) ? LINUX_APP : null;
|
|
2276
|
+
return app !== null && deps.start(app, [...paths]);
|
|
2277
|
+
}
|
|
2278
|
+
var execFileAsync = promisify2(execFile2);
|
|
2279
|
+
function systemLaunchDeps() {
|
|
2280
|
+
return {
|
|
2281
|
+
platform: process.platform,
|
|
2282
|
+
env: process.env,
|
|
2283
|
+
exists: (file) => access(file).then(
|
|
2284
|
+
() => true,
|
|
2285
|
+
() => false
|
|
2286
|
+
),
|
|
2287
|
+
installLocation: async (root, key) => {
|
|
2288
|
+
try {
|
|
2289
|
+
const { stdout } = await execFileAsync("reg", ["query", `${root}\\${key}`, "/v", "InstallLocation"], {
|
|
2290
|
+
windowsHide: true
|
|
2291
|
+
});
|
|
2292
|
+
return parseRegValue(stdout);
|
|
2293
|
+
} catch {
|
|
2294
|
+
return null;
|
|
2295
|
+
}
|
|
2296
|
+
},
|
|
2297
|
+
start: (file, args) => new Promise((resolve) => {
|
|
2298
|
+
const child = spawn(file, args, { detached: true, stdio: "ignore" });
|
|
2299
|
+
child.once("error", () => resolve(false));
|
|
2300
|
+
child.once("spawn", () => {
|
|
2301
|
+
child.unref();
|
|
2302
|
+
resolve(true);
|
|
2303
|
+
});
|
|
2304
|
+
}),
|
|
2305
|
+
run: (file, args) => new Promise((resolve) => {
|
|
2306
|
+
const child = spawn(file, args, { stdio: "ignore" });
|
|
2307
|
+
child.once("error", () => resolve(null));
|
|
2308
|
+
child.once("exit", (code) => resolve(code));
|
|
2309
|
+
})
|
|
2310
|
+
};
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// src/cli/run.ts
|
|
2314
|
+
import { lstat as lstat3, rename as rename3, unlink as unlink3, writeFile as writeFile2 } from "node:fs/promises";
|
|
2315
|
+
import path8 from "node:path";
|
|
2316
|
+
|
|
2317
|
+
// src/cli/help.ts
|
|
2318
|
+
var HELP = `Rename files in bulk from a pattern.
|
|
2319
|
+
|
|
2320
|
+
Usage:
|
|
2321
|
+
renami rename <paths\u2026> [options] Show what would be renamed (a dry run)
|
|
2322
|
+
renami rename <paths\u2026> --apply Rename the files
|
|
2323
|
+
renami undo <journal.json> Undo a rename recorded with --journal
|
|
2324
|
+
renami tokens List the tokens a pattern can use
|
|
2325
|
+
renami <paths\u2026> Open the Renami desktop app with these files
|
|
2326
|
+
renami --help | --version
|
|
2327
|
+
|
|
2328
|
+
Options for rename:
|
|
2329
|
+
-p, --pattern <pattern> The new name, e.g. "Trip_{date_taken:YYYY-MM-DD}_{seq:3}"
|
|
2330
|
+
--settings <file.json> Settings saved as JSON; the flags below override them
|
|
2331
|
+
|
|
2332
|
+
Numbering
|
|
2333
|
+
--sort dateTaken|created|modified|name --desc
|
|
2334
|
+
--start N --step N --digits N
|
|
2335
|
+
--restart never|day|month|year --restart-per-folder
|
|
2336
|
+
|
|
2337
|
+
Find & replace (in the current name)
|
|
2338
|
+
--replace FIND=REPL Repeatable; plain text unless --regex
|
|
2339
|
+
--regex --match-case
|
|
2340
|
+
|
|
2341
|
+
Clean up
|
|
2342
|
+
--case none|lower|upper|title|sentence|snake|kebab|camel
|
|
2343
|
+
--spaces-to-underscores --ascii --strip-accents
|
|
2344
|
+
--ext FROM=TO Map an extension, e.g. --ext jpeg=jpg (repeatable)
|
|
2345
|
+
|
|
2346
|
+
Move and dates
|
|
2347
|
+
--dest <folder> Move the renamed files into this folder
|
|
2348
|
+
--shift=<\xB1minutes> Correct a wrong camera clock, e.g. --shift=-60
|
|
2349
|
+
--use-name-date Use a date in the file name when there is no date taken
|
|
2350
|
+
--set-modified --set-created Set the file's dates to the date used
|
|
2351
|
+
|
|
2352
|
+
Which files
|
|
2353
|
+
-r, --recursive Include subfolders
|
|
2354
|
+
--only-ext jpg,heic Only these extensions
|
|
2355
|
+
--folders Rename the folders inside the given folders
|
|
2356
|
+
--name <text> Only names containing this text
|
|
2357
|
+
|
|
2358
|
+
Output and safety
|
|
2359
|
+
--apply Rename (without it, nothing on disk changes)
|
|
2360
|
+
--journal <file.json> With --apply: record the batch so "renami undo" can reverse it
|
|
2361
|
+
--json Print the plan and the result as JSON
|
|
2362
|
+
-q, --quiet Print only errors
|
|
2363
|
+
|
|
2364
|
+
Exit codes:
|
|
2365
|
+
0 Done, or a dry run found no problems
|
|
2366
|
+
1 Usage error
|
|
2367
|
+
2 Invalid pattern
|
|
2368
|
+
3 Some files can't be renamed; nothing was renamed
|
|
2369
|
+
4 Files changed between the plan and the rename; nothing was renamed
|
|
2370
|
+
5 The rename or undo failed or was cancelled, or the journal couldn't be written
|
|
2371
|
+
6 The undo skipped some files
|
|
2372
|
+
7 The desktop app was not found
|
|
2373
|
+
`;
|
|
2374
|
+
function tokensText() {
|
|
2375
|
+
const lines = Object.entries(TOKENS).map(([name, def]) => ` ${`{${name}}`.padEnd(18)}${def.label}`);
|
|
2376
|
+
return `${lines.join("\n")}
|
|
2377
|
+
|
|
2378
|
+
Dates take a format: {date_taken:YYYY-MM-DD}. Numbers take digits: {seq:3}.
|
|
2379
|
+
`;
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
// src/cli/journal.ts
|
|
2383
|
+
import { readFile as readFile2, rename as rename2, unlink as unlink2, writeFile } from "node:fs/promises";
|
|
2384
|
+
var JOURNAL_VERSION = 1;
|
|
2385
|
+
var JournalError = class extends Error {
|
|
2386
|
+
constructor(message2) {
|
|
2387
|
+
super(message2);
|
|
2388
|
+
this.name = "JournalError";
|
|
2389
|
+
}
|
|
2390
|
+
};
|
|
2391
|
+
async function writeJournal(file, record) {
|
|
2392
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
2393
|
+
const body = { version: JOURNAL_VERSION, tool: "renami", record };
|
|
2394
|
+
try {
|
|
2395
|
+
await writeFile(tmp, `${JSON.stringify(body, null, 2)}
|
|
2396
|
+
`, { encoding: "utf8", flag: "wx" });
|
|
2397
|
+
await rename2(tmp, file);
|
|
2398
|
+
} catch (e) {
|
|
2399
|
+
await unlink2(tmp).catch(() => void 0);
|
|
2400
|
+
throw e;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
async function readJournal(file) {
|
|
2404
|
+
let data;
|
|
2405
|
+
try {
|
|
2406
|
+
data = JSON.parse(await readFile2(file, "utf8"));
|
|
2407
|
+
} catch (e) {
|
|
2408
|
+
throw new JournalError(`can't read the journal ${file}: ${e.message}`);
|
|
2409
|
+
}
|
|
2410
|
+
if (!isObject2(data) || data.tool !== "renami") throw new JournalError(`${file} is not a renami journal`);
|
|
2411
|
+
if (data.version !== JOURNAL_VERSION) {
|
|
2412
|
+
throw new JournalError(`${file} is a version ${String(data.version)} journal; this renami reads version ${JOURNAL_VERSION}`);
|
|
2413
|
+
}
|
|
2414
|
+
if (!isRecord(data.record)) throw new JournalError(`${file} is damaged: its batch record is incomplete`);
|
|
2415
|
+
return data.record;
|
|
2416
|
+
}
|
|
2417
|
+
var isObject2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2418
|
+
var isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
2419
|
+
var isTimes = (v) => v === null || isObject2(v) && isNumber(v.atimeMs) && isNumber(v.mtimeMs) && (v.birthtimeMs === null || isNumber(v.birthtimeMs));
|
|
2420
|
+
var isFile = (v) => isObject2(v) && typeof v.from === "string" && typeof v.to === "string" && isNumber(v.sizeAfter) && isNumber(v.mtimeMsAfter) && isTimes(v.originalTimes);
|
|
2421
|
+
var isRecord = (v) => isObject2(v) && typeof v.id === "string" && Array.isArray(v.files) && v.files.every(isFile) && Array.isArray(v.createdDirs) && v.createdDirs.every((d) => typeof d === "string") && isNumber(v.finishedAt);
|
|
2422
|
+
|
|
2423
|
+
// src/cli/render.ts
|
|
2424
|
+
import path7 from "node:path";
|
|
2425
|
+
var plural = (n2, word) => `${n2} ${word}${n2 === 1 ? "" : "s"}`;
|
|
2426
|
+
function shown(p, cwd) {
|
|
2427
|
+
const rel = path7.relative(cwd, p);
|
|
2428
|
+
return rel !== "" && !rel.startsWith("..") && !path7.isAbsolute(rel) ? rel : p;
|
|
2429
|
+
}
|
|
2430
|
+
var wallClockText = (w) => formatWallClock(w, "YYYY-MM-DDTHH:mm:ss");
|
|
2431
|
+
function summarize(plan) {
|
|
2432
|
+
let toRename = 0;
|
|
2433
|
+
let unchanged = 0;
|
|
2434
|
+
let warnings = 0;
|
|
2435
|
+
for (const item of plan.items) {
|
|
2436
|
+
if (isMoving(item.kind)) toRename += 1;
|
|
2437
|
+
else if (item.kind === "unchanged") unchanged += 1;
|
|
2438
|
+
if (item.flags.some((f) => f.level === "warning")) warnings += 1;
|
|
2439
|
+
}
|
|
2440
|
+
return { total: plan.items.length, toRename, unchanged, warnings, errors: plan.errorCount };
|
|
2441
|
+
}
|
|
2442
|
+
function summaryLine(s) {
|
|
2443
|
+
const parts = [`${s.toRename} to rename`, `${s.unchanged} unchanged`];
|
|
2444
|
+
if (s.warnings > 0) parts.push(plural(s.warnings, "warning"));
|
|
2445
|
+
parts.push(plural(s.errors, "error"));
|
|
2446
|
+
return parts.join(", ");
|
|
2447
|
+
}
|
|
2448
|
+
function planText(plan, cwd) {
|
|
2449
|
+
const lines = [];
|
|
2450
|
+
for (const item of plan.items) {
|
|
2451
|
+
const from = shown(item.source.path, cwd);
|
|
2452
|
+
if (isMoving(item.kind)) lines.push(`${from} -> ${shown(item.target, cwd)}`);
|
|
2453
|
+
else if (item.kind === "error") lines.push(`${from} (not renamed)`);
|
|
2454
|
+
else lines.push(`${from} (unchanged)`);
|
|
2455
|
+
for (const f of item.flags) if (f.level !== "info") lines.push(` ${f.level}: ${f.message}`);
|
|
2456
|
+
}
|
|
2457
|
+
lines.push(summaryLine(summarize(plan)));
|
|
2458
|
+
return `${lines.join("\n")}
|
|
2459
|
+
`;
|
|
2460
|
+
}
|
|
2461
|
+
function planJson(plan, result) {
|
|
2462
|
+
const body = {
|
|
2463
|
+
plan: plan.items.map((i) => ({
|
|
2464
|
+
source: i.source.path,
|
|
2465
|
+
target: i.target,
|
|
2466
|
+
kind: i.kind,
|
|
2467
|
+
flags: i.flags.map(({ level, code, message: message2 }) => ({ level, code, message: message2 })),
|
|
2468
|
+
dateUsed: i.dateUsed ? { value: wallClockText(i.dateUsed.value), source: i.dateUsed.source } : null
|
|
2469
|
+
})),
|
|
2470
|
+
patternError: plan.patternError,
|
|
2471
|
+
summary: summarize(plan),
|
|
2472
|
+
...result ? { result } : {}
|
|
2473
|
+
};
|
|
2474
|
+
return `${JSON.stringify(body, null, 2)}
|
|
2475
|
+
`;
|
|
2476
|
+
}
|
|
2477
|
+
function rollbackText(report, cwd) {
|
|
2478
|
+
if (report.complete) return "Every file was put back.";
|
|
2479
|
+
const lines = report.stranded.map((s) => ` ${shown(s.current, cwd)} (was ${shown(s.original, cwd)})`);
|
|
2480
|
+
return `These files could not be put back:
|
|
2481
|
+
${lines.join("\n")}`;
|
|
2482
|
+
}
|
|
2483
|
+
function resultText(result, cwd) {
|
|
2484
|
+
switch (result.status) {
|
|
2485
|
+
case "done": {
|
|
2486
|
+
const dates = result.datesChanged > 0 ? `, set dates on ${plural(result.datesChanged, "file")}` : "";
|
|
2487
|
+
const undo = result.journal ? `
|
|
2488
|
+
Undo with: renami undo "${shown(result.journal, cwd)}"` : "";
|
|
2489
|
+
return `Renamed ${plural(result.renamed, "file")}${dates}.${undo}
|
|
2490
|
+
`;
|
|
2491
|
+
}
|
|
2492
|
+
case "stale":
|
|
2493
|
+
return `Nothing was renamed: these files changed after the plan was made. Run it again.
|
|
2494
|
+
${result.changed.map((p) => ` ${shown(p, cwd)}`).join("\n")}
|
|
2495
|
+
`;
|
|
2496
|
+
case "cancelled":
|
|
2497
|
+
return `Cancelled. ${rollbackText(result.rollback, cwd)}
|
|
2498
|
+
`;
|
|
2499
|
+
case "failed":
|
|
2500
|
+
return `The rename failed: ${result.error ?? "unknown error"}. ${rollbackText(result.rollback, cwd)}
|
|
2501
|
+
`;
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// src/cli/run.ts
|
|
2506
|
+
var exists2 = (p) => lstat3(p).then(
|
|
2507
|
+
() => true,
|
|
2508
|
+
() => false
|
|
2509
|
+
);
|
|
2510
|
+
var CANCELLED = "cancelled; nothing was renamed";
|
|
2511
|
+
var message = (e) => e instanceof Error ? e.message : String(e);
|
|
2512
|
+
var fail2 = (ctx, text2) => {
|
|
2513
|
+
ctx.stderr.write(`renami: ${text2}
|
|
2514
|
+
`);
|
|
2515
|
+
};
|
|
2516
|
+
async function runCommand(command, ctx) {
|
|
2517
|
+
switch (command.kind) {
|
|
2518
|
+
case "help":
|
|
2519
|
+
ctx.stdout.write(HELP);
|
|
2520
|
+
return EXIT.ok;
|
|
2521
|
+
case "version":
|
|
2522
|
+
ctx.stdout.write(`${ctx.version}
|
|
2523
|
+
`);
|
|
2524
|
+
return EXIT.ok;
|
|
2525
|
+
case "tokens":
|
|
2526
|
+
ctx.stdout.write(tokensText());
|
|
2527
|
+
return EXIT.ok;
|
|
2528
|
+
case "open":
|
|
2529
|
+
return runOpen(command.paths, ctx);
|
|
2530
|
+
case "undo":
|
|
2531
|
+
return runUndo(command.journal, ctx);
|
|
2532
|
+
case "rename": {
|
|
2533
|
+
const reader = ctx.createReader();
|
|
2534
|
+
try {
|
|
2535
|
+
return await runRename(command, reader, ctx);
|
|
2536
|
+
} finally {
|
|
2537
|
+
await reader.end();
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
async function runOpen(paths, ctx) {
|
|
2543
|
+
if (await openInApp(paths, ctx.launch)) return EXIT.ok;
|
|
2544
|
+
const appImage = ctx.launch.platform === "linux" ? " (an AppImage cannot be found this way; start it with the paths instead)" : "";
|
|
2545
|
+
fail2(ctx, `Renami desktop app not found${appImage}; to rename from the terminal use "renami rename <paths\u2026>"`);
|
|
2546
|
+
return EXIT.appNotFound;
|
|
2547
|
+
}
|
|
2548
|
+
var progressOf = (ctx, label) => (done, total) => {
|
|
2549
|
+
if (done === total || done % 25 === 0) ctx.progress?.(`${label} ${done}/${total}`);
|
|
2550
|
+
};
|
|
2551
|
+
async function runRename({ paths, settings, options }, reader, ctx) {
|
|
2552
|
+
const found = await Promise.all(paths.map(exists2));
|
|
2553
|
+
const missing = paths.find((_, i) => !found[i]);
|
|
2554
|
+
if (missing !== void 0) {
|
|
2555
|
+
fail2(ctx, `no such file or folder: ${missing}`);
|
|
2556
|
+
return EXIT.usage;
|
|
2557
|
+
}
|
|
2558
|
+
if (options.journal !== null) {
|
|
2559
|
+
if (await exists2(options.journal)) {
|
|
2560
|
+
fail2(ctx, `the journal ${options.journal} already exists; pick a new file name`);
|
|
2561
|
+
return EXIT.usage;
|
|
2562
|
+
}
|
|
2563
|
+
const probe = `${options.journal}.${process.pid}.check`;
|
|
2564
|
+
try {
|
|
2565
|
+
await writeFile2(probe, "", { flag: "wx" });
|
|
2566
|
+
await unlink3(probe);
|
|
2567
|
+
} catch (e) {
|
|
2568
|
+
await unlink3(probe).catch(() => void 0);
|
|
2569
|
+
const dir = path8.dirname(options.journal);
|
|
2570
|
+
const missingDir = e.code === "ENOENT";
|
|
2571
|
+
fail2(ctx, missingDir ? `the journal's folder doesn't exist: ${dir}` : `can't write the journal in ${dir}: ${message(e)}`);
|
|
2572
|
+
return EXIT.usage;
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
const timeZone = ctx.timeZone ?? systemTimeZone();
|
|
2576
|
+
const { entries, unreadableFolders } = await scan(paths, settings.filter);
|
|
2577
|
+
for (const dir of unreadableFolders) fail2(ctx, `couldn't read the folder ${dir}; its files are left out`);
|
|
2578
|
+
const metadata = /* @__PURE__ */ new Map();
|
|
2579
|
+
const readProgress = progressOf(ctx, "Reading file details");
|
|
2580
|
+
const read = await readMetadata(entries, reader, {
|
|
2581
|
+
signal: ctx.signal,
|
|
2582
|
+
onResult: (p, meta) => {
|
|
2583
|
+
metadata.set(p, meta);
|
|
2584
|
+
readProgress(metadata.size, entries.length);
|
|
2585
|
+
}
|
|
2586
|
+
});
|
|
2587
|
+
ctx.progress?.(null);
|
|
2588
|
+
if ("error" in read) {
|
|
2589
|
+
fail2(ctx, `reading file details stopped: ${message(read.error)}`);
|
|
2590
|
+
return EXIT.failed;
|
|
2591
|
+
}
|
|
2592
|
+
if (ctx.signal.aborted) {
|
|
2593
|
+
fail2(ctx, CANCELLED);
|
|
2594
|
+
return EXIT.failed;
|
|
2595
|
+
}
|
|
2596
|
+
if (read.exiftoolFailed) fail2(ctx, "ExifTool isn't working, so files without details use their file system dates");
|
|
2597
|
+
let allMetadata = metadata;
|
|
2598
|
+
if (patternUsesHash(settings.pattern)) {
|
|
2599
|
+
const hashes = new HashCache(timeZone);
|
|
2600
|
+
await hashes.hashMissing(entries, ctx.signal);
|
|
2601
|
+
if (ctx.signal.aborted) {
|
|
2602
|
+
fail2(ctx, CANCELLED);
|
|
2603
|
+
return EXIT.failed;
|
|
2604
|
+
}
|
|
2605
|
+
allMetadata = hashes.mergeInto(metadata, entries);
|
|
2606
|
+
}
|
|
2607
|
+
const plan = buildPlan({ entries, metadata: allMetadata, settings, fs: createFsView(), platform: ctx.platform, timeZone });
|
|
2608
|
+
const showJson = (result2) => {
|
|
2609
|
+
if (options.json) ctx.stdout.write(planJson(plan, result2));
|
|
2610
|
+
};
|
|
2611
|
+
if (plan.patternError !== null) {
|
|
2612
|
+
showJson();
|
|
2613
|
+
fail2(ctx, plan.patternError);
|
|
2614
|
+
return EXIT.patternError;
|
|
2615
|
+
}
|
|
2616
|
+
if (!options.json && !options.quiet) {
|
|
2617
|
+
ctx.stdout.write(entries.length === 0 ? "Nothing to rename: no files matched.\n" : planText(plan, ctx.cwd));
|
|
2618
|
+
}
|
|
2619
|
+
if (plan.errorCount > 0) {
|
|
2620
|
+
showJson();
|
|
2621
|
+
fail2(ctx, `${plural(plan.errorCount, "file")} can't be renamed${options.apply ? "; nothing was renamed" : ""}`);
|
|
2622
|
+
return EXIT.planErrors;
|
|
2623
|
+
}
|
|
2624
|
+
if (!options.apply) {
|
|
2625
|
+
showJson();
|
|
2626
|
+
return EXIT.ok;
|
|
2627
|
+
}
|
|
2628
|
+
const batch = await executePlan(plan, {
|
|
2629
|
+
signal: ctx.signal,
|
|
2630
|
+
onProgress: progressOf(ctx, "Renaming"),
|
|
2631
|
+
birthtime: ctx.birthtime
|
|
2632
|
+
});
|
|
2633
|
+
ctx.progress?.(null);
|
|
2634
|
+
let result;
|
|
2635
|
+
let journalFailed = false;
|
|
2636
|
+
if (batch.status === "done") {
|
|
2637
|
+
const { files } = batch.record;
|
|
2638
|
+
let journal = null;
|
|
2639
|
+
if (options.journal !== null && files.length === 0) {
|
|
2640
|
+
fail2(ctx, "nothing changed, so no journal was written");
|
|
2641
|
+
} else if (options.journal !== null) {
|
|
2642
|
+
try {
|
|
2643
|
+
await writeJournal(options.journal, batch.record);
|
|
2644
|
+
journal = options.journal;
|
|
2645
|
+
} catch (e) {
|
|
2646
|
+
journalFailed = true;
|
|
2647
|
+
fail2(ctx, `the files were renamed, but the journal couldn't be written: ${message(e)}`);
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
result = {
|
|
2651
|
+
status: "done",
|
|
2652
|
+
renamed: files.filter((f) => f.from !== f.to).length,
|
|
2653
|
+
datesChanged: files.filter((f) => f.originalTimes !== null).length,
|
|
2654
|
+
journal
|
|
2655
|
+
};
|
|
2656
|
+
} else if (batch.status === "stale") {
|
|
2657
|
+
result = { status: "stale", changed: batch.changed };
|
|
2658
|
+
} else {
|
|
2659
|
+
result = { status: batch.status, error: batch.error, rollback: batch.rollback };
|
|
2660
|
+
}
|
|
2661
|
+
showJson(result);
|
|
2662
|
+
if (!options.json) {
|
|
2663
|
+
if (result.status !== "done") ctx.stderr.write(`renami: ${resultText(result, ctx.cwd)}`);
|
|
2664
|
+
else if (!options.quiet) ctx.stdout.write(resultText(result, ctx.cwd));
|
|
2665
|
+
}
|
|
2666
|
+
if (result.status === "done") return journalFailed ? EXIT.failed : EXIT.ok;
|
|
2667
|
+
return result.status === "stale" ? EXIT.stale : EXIT.failed;
|
|
2668
|
+
}
|
|
2669
|
+
async function runUndo(journal, ctx) {
|
|
2670
|
+
if (!await exists2(journal)) {
|
|
2671
|
+
fail2(ctx, await exists2(`${journal}.undone`) ? `${journal} was already undone` : `no such journal: ${journal}`);
|
|
2672
|
+
return EXIT.usage;
|
|
2673
|
+
}
|
|
2674
|
+
let record;
|
|
2675
|
+
try {
|
|
2676
|
+
record = await readJournal(journal);
|
|
2677
|
+
} catch (e) {
|
|
2678
|
+
if (!(e instanceof JournalError)) throw e;
|
|
2679
|
+
fail2(ctx, e.message);
|
|
2680
|
+
return EXIT.usage;
|
|
2681
|
+
}
|
|
2682
|
+
const history = new History();
|
|
2683
|
+
history.push(record);
|
|
2684
|
+
const result = await history.undo({ signal: ctx.signal, birthtime: ctx.birthtime });
|
|
2685
|
+
for (const s of result.skipped) fail2(ctx, `skipped ${shown(s.path, ctx.cwd)}: ${s.reason}`);
|
|
2686
|
+
if (result.status === "done") {
|
|
2687
|
+
await rename3(journal, `${journal}.undone`);
|
|
2688
|
+
ctx.stdout.write(`Put back ${plural(result.restored, "file")}.
|
|
2689
|
+
`);
|
|
2690
|
+
return result.skipped.length > 0 ? EXIT.undoSkipped : EXIT.ok;
|
|
2691
|
+
}
|
|
2692
|
+
const what = result.status === "cancelled" ? "undo cancelled" : `undo failed: ${result.error ?? "unknown error"}`;
|
|
2693
|
+
fail2(ctx, result.rollback ? `${what}. ${rollbackText(result.rollback, ctx.cwd)}` : what);
|
|
2694
|
+
return EXIT.failed;
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
// src/cli/main.ts
|
|
2698
|
+
var VERSION = "0.2.2";
|
|
2699
|
+
async function main() {
|
|
2700
|
+
const controller = new AbortController();
|
|
2701
|
+
process.on("SIGINT", () => {
|
|
2702
|
+
if (controller.signal.aborted) process.exit(130);
|
|
2703
|
+
process.stderr.write("\nrenami: stopping; press Ctrl-C again to quit at once\n");
|
|
2704
|
+
controller.abort();
|
|
2705
|
+
});
|
|
2706
|
+
let command;
|
|
2707
|
+
try {
|
|
2708
|
+
command = await parseCommand(process.argv.slice(2), process.cwd());
|
|
2709
|
+
} catch (e) {
|
|
2710
|
+
if (!(e instanceof UsageError)) throw e;
|
|
2711
|
+
process.stderr.write(`renami: ${e.message}
|
|
2712
|
+
renami: run "renami --help" for usage
|
|
2713
|
+
`);
|
|
2714
|
+
return EXIT.usage;
|
|
2715
|
+
}
|
|
2716
|
+
const tty = process.stderr.isTTY;
|
|
2717
|
+
return runCommand(command, {
|
|
2718
|
+
stdout: process.stdout,
|
|
2719
|
+
stderr: process.stderr,
|
|
2720
|
+
signal: controller.signal,
|
|
2721
|
+
cwd: process.cwd(),
|
|
2722
|
+
platform: process.platform,
|
|
2723
|
+
version: VERSION,
|
|
2724
|
+
createReader: () => new MetadataReader(),
|
|
2725
|
+
launch: systemLaunchDeps(),
|
|
2726
|
+
progress: tty ? (text2) => process.stderr.write(text2 === null ? "\r\x1B[K" : `\r${text2}\x1B[K`) : void 0
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
main().then(
|
|
2730
|
+
(code) => {
|
|
2731
|
+
process.exitCode = code;
|
|
2732
|
+
},
|
|
2733
|
+
(e) => {
|
|
2734
|
+
process.stderr.write(`renami: ${e instanceof Error ? e.message : String(e)}
|
|
2735
|
+
`);
|
|
2736
|
+
process.exitCode = EXIT.failed;
|
|
2737
|
+
}
|
|
2738
|
+
);
|