auto-image-converter 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,493 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs/promises";
3
+ import crypto from "crypto";
4
+ import { ConvertImages } from "../ConvertImages.js";
5
+ import { FileManager } from "../FileManager.js";
6
+ import { MarkerFile } from "../MarkerFile.js";
7
+
8
+ export class ConvertationStep {
9
+ constructor(config) {
10
+ this.config = config;
11
+ this.supportedExtensions = this.#parseExtensions(
12
+ config.converted ||
13
+ "*.{png,jpg,jpeg,webp,avif,tiff}"
14
+ );
15
+ }
16
+
17
+ // Парсим расширения из паттерна converted
18
+ #parseExtensions(pattern) {
19
+ const match = pattern.match(/\*\.\{(.+?)\}/);
20
+ if (match) {
21
+ return match[1]
22
+ .split(",")
23
+ .map((ext) => `.${ext.trim()}`);
24
+ }
25
+
26
+ // Если паттерн типа "*.png"
27
+ const simpleMatch = pattern.match(/\*\.(\w+)/);
28
+ if (simpleMatch) {
29
+ return [`.${simpleMatch[1]}`];
30
+ }
31
+
32
+ // Fallback
33
+ return [
34
+ ".jpg",
35
+ ".jpeg",
36
+ ".png",
37
+ ".webp",
38
+ ".avif",
39
+ ".tiff",
40
+ ];
41
+ }
42
+
43
+ // Основной метод - конвертация одного файла
44
+ async execute(filePath) {
45
+ const initialPath = filePath; // сохраняем начальный путь
46
+ let skipOriginalCheck = false; // флаг что мы только что переименовали .processed → .original
47
+ let wasRenamed = false; // флаг что файл был переименован
48
+
49
+ try {
50
+ // 1. Проверка маркеров (пропускаем обработанные файлы)
51
+ if (!this.config.force) {
52
+ const marker = new MarkerFile(filePath);
53
+
54
+ // Проверяем файлы с маркером .processed
55
+ if (marker.isMarkProcessed()) {
56
+ const currentFormat = path
57
+ .extname(filePath)
58
+ .slice(1)
59
+ .toLowerCase();
60
+ const targetFormat = (
61
+ this.config.format || "webp"
62
+ ).toLowerCase();
63
+
64
+ // Если формат совпадает - пропускаем
65
+ if (currentFormat === targetFormat) {
66
+ return {
67
+ success: false,
68
+ skipped: true,
69
+ reason: "already_processed",
70
+ originalPath: filePath,
71
+ };
72
+ }
73
+
74
+ // Формат не совпадает - нужна переконвертация
75
+ // Проверяем, есть ли .original файл с таким же именем
76
+ const cleanName =
77
+ marker.removeAllMarkers();
78
+ const dir = path.dirname(filePath);
79
+ const ext = path.extname(filePath);
80
+ const originalPath = path.join(
81
+ dir,
82
+ `${cleanName}.original${ext}`
83
+ );
84
+
85
+ const fm = new FileManager(filePath);
86
+ const originalExists = await fm.exists(
87
+ originalPath
88
+ );
89
+
90
+ if (originalExists) {
91
+ // Есть .original - это устаревший .processed
92
+ // Просто удаляем и пропускаем (оригинал будет обработан отдельно)
93
+ await fm.deleteFile(filePath);
94
+ return {
95
+ success: false,
96
+ skipped: true,
97
+ reason: "outdated_processed_removed",
98
+ originalPath: filePath,
99
+ };
100
+ } else {
101
+ // Нет .original с таким же расширением
102
+ // Проверяем, нет ли .original с другим расширением
103
+ const hasAnyOriginal =
104
+ await this.#checkAnyOriginalExists(
105
+ filePath,
106
+ cleanName
107
+ );
108
+
109
+ if (hasAnyOriginal) {
110
+ // Есть другой .original - это устаревший .processed, удаляем
111
+ await fm.deleteFile(filePath);
112
+ return {
113
+ success: false,
114
+ skipped: true,
115
+ reason: "outdated_processed_removed_other_original_exists",
116
+ originalPath: filePath,
117
+ };
118
+ }
119
+
120
+ // Нет ни одного .original - этот .processed становится оригиналом
121
+ const newPath =
122
+ await marker.addMarker(
123
+ MarkerFile.MARKERS.ORIGINAL
124
+ );
125
+
126
+ // Обновляем filePath и marker для продолжения конвертации
127
+ filePath = newPath;
128
+ wasRenamed = true; // помечаем что файл был переименован
129
+ marker.filePath = newPath;
130
+ marker.dir = path.dirname(newPath);
131
+ marker.ext = path.extname(newPath);
132
+ marker.nameWithoutExt =
133
+ path.basename(
134
+ newPath,
135
+ marker.ext
136
+ );
137
+
138
+ skipOriginalCheck = true; // пропускаем проверку .original ниже
139
+ // НЕ возвращаем - продолжаем выполнение!
140
+ }
141
+ }
142
+
143
+ // Если файл с маркером .original - проверяем, существует ли результат
144
+ if (
145
+ !skipOriginalCheck &&
146
+ this.config.markerOriginal &&
147
+ marker.isMarkOriginal()
148
+ ) {
149
+ const targetFormat = (
150
+ this.config.format || "webp"
151
+ ).toLowerCase();
152
+ const cleanName =
153
+ marker.removeAllMarkers();
154
+
155
+ const fm = new FileManager(filePath, {
156
+ outputDir: this.config.outputDir,
157
+ });
158
+
159
+ const expectedOutputPath =
160
+ fm.resolvePath({
161
+ name: cleanName, // чистое имя без маркеров
162
+ ext: `.${targetFormat}`,
163
+ marker: this.config
164
+ .markerProcessed
165
+ ? "processed"
166
+ : null,
167
+ outputDir:
168
+ this.config.outputDir,
169
+ });
170
+
171
+ // Если результат существует - пропускаем
172
+ if (
173
+ await fm.exists(expectedOutputPath)
174
+ ) {
175
+ // Если нужно удалять оригинал - удаляем его
176
+ if (this.config.removeOriginal) {
177
+ await fm.deleteFile(filePath);
178
+ }
179
+
180
+ return {
181
+ success: false,
182
+ skipped: true,
183
+ reason: "already_converted_output_exists",
184
+ originalPath: filePath,
185
+ outputPath: expectedOutputPath,
186
+ };
187
+ }
188
+
189
+ // Результата нет - продолжаем конвертацию
190
+ }
191
+ }
192
+
193
+ // 2. Проверка расширения (пропускаем если уже в нужном формате)
194
+ const currentExt = path
195
+ .extname(filePath)
196
+ .slice(1)
197
+ .toLowerCase();
198
+ const targetFormat = (
199
+ this.config.format || "webp"
200
+ ).toLowerCase();
201
+
202
+ if (currentExt === targetFormat) {
203
+ // Если это .original файл уже в целевом формате
204
+ const marker = new MarkerFile(filePath);
205
+ if (
206
+ marker.isMarkOriginal() &&
207
+ this.config.removeOriginal
208
+ ) {
209
+ // Удаляем, т.к. результат уже есть
210
+ const fm = new FileManager(filePath);
211
+ await fm.deleteFile(filePath);
212
+ return {
213
+ success: false,
214
+ skipped: true,
215
+ reason: "original_in_target_format_removed",
216
+ originalPath: filePath,
217
+ };
218
+ }
219
+
220
+ return {
221
+ success: false,
222
+ skipped: true,
223
+ reason: "already_target_format",
224
+ originalPath: filePath,
225
+ };
226
+ }
227
+
228
+ // 3. Конвертация
229
+ const quality = this.config.quality || 80;
230
+
231
+ // Читаем файл в буфер сразу, чтобы освободить дескриптор
232
+ const sourceBuffer = await fs.readFile(
233
+ filePath
234
+ );
235
+
236
+ const converter = new ConvertImages(
237
+ sourceBuffer, // передаем буфер, а не путь к файлу
238
+ targetFormat,
239
+ quality
240
+ );
241
+ const buffer = await this.#convertByFormat(
242
+ converter,
243
+ targetFormat
244
+ );
245
+
246
+ // 4. Подготовка к сохранению
247
+ // Убираем все маркеры из имени файла перед созданием результата
248
+ const originalMarker = new MarkerFile(filePath);
249
+ const cleanName =
250
+ originalMarker.removeAllMarkers();
251
+
252
+ const fm = new FileManager(filePath, {
253
+ outputDir: this.config.outputDir,
254
+ outputPattern: this.config.pattern,
255
+ });
256
+
257
+ const desiredPath = fm.resolvePath({
258
+ name: cleanName, // используем чистое имя без маркеров
259
+ ext: `.${targetFormat}`,
260
+ marker: this.config.markerProcessed
261
+ ? "processed"
262
+ : null,
263
+ outputDir: this.config.outputDir,
264
+ });
265
+
266
+ // Проверка: если файл .original и результат уже существует - удаляем оригинал
267
+ if (
268
+ originalMarker.isMarkOriginal() &&
269
+ (await fm.exists(desiredPath))
270
+ ) {
271
+ if (this.config.removeOriginal) {
272
+ await fm.deleteFile(filePath);
273
+ return {
274
+ success: false,
275
+ skipped: true,
276
+ reason: "original_removed_result_exists",
277
+ originalPath: filePath,
278
+ outputPath: desiredPath,
279
+ };
280
+ } else {
281
+ return {
282
+ success: false,
283
+ skipped: true,
284
+ reason: "result_already_exists",
285
+ originalPath: filePath,
286
+ outputPath: desiredPath,
287
+ };
288
+ }
289
+ }
290
+
291
+ // 5. Проверка дубликатов
292
+ const { finalPath, duplicate } =
293
+ await this.#checkDuplicate(
294
+ desiredPath,
295
+ buffer
296
+ );
297
+
298
+ if (duplicate) {
299
+ // Удаляем оригинал-дубликат если нужно
300
+ if (this.config.removeOriginal) {
301
+ await fm.deleteFile(filePath);
302
+ }
303
+ return {
304
+ success: false,
305
+ skipped: true,
306
+ reason: "duplicate",
307
+ originalPath: filePath,
308
+ outputPath: finalPath,
309
+ };
310
+ }
311
+
312
+ // 6. Сохранение
313
+ const dir = path.dirname(finalPath);
314
+ await fs.mkdir(dir, { recursive: true });
315
+ await fs.writeFile(finalPath, buffer);
316
+
317
+ // 7. Добавление маркера к оригиналу (если включено)
318
+ if (
319
+ this.config.markerOriginal &&
320
+ !this.config.removeOriginal
321
+ ) {
322
+ const originalMarker = new MarkerFile(
323
+ filePath
324
+ );
325
+ await originalMarker.markOriginal();
326
+ }
327
+
328
+ // 8. Удаление оригинала (если включено)
329
+ if (this.config.removeOriginal) {
330
+ console.log(
331
+ `[DEBUG] Deleting original: ${filePath}`
332
+ );
333
+ await fm.deleteFile(filePath);
334
+ }
335
+
336
+ return {
337
+ success: true,
338
+ skipped: false,
339
+ outputPath: finalPath,
340
+ originalPath: filePath,
341
+ format: targetFormat,
342
+ };
343
+ } catch (error) {
344
+ return {
345
+ success: false,
346
+ skipped: false,
347
+ error: error.message,
348
+ originalPath: filePath,
349
+ };
350
+ }
351
+ }
352
+
353
+ // Проверить существование .original файлов с любым расширением
354
+ async #checkAnyOriginalExists(filePath, cleanName) {
355
+ const dir = path.dirname(filePath);
356
+
357
+ for (const ext of this.supportedExtensions) {
358
+ const candidatePath = path.join(
359
+ dir,
360
+ `${cleanName}.original${ext}`
361
+ );
362
+ const fm = new FileManager(candidatePath);
363
+
364
+ if (await fm.exists(candidatePath)) {
365
+ return true;
366
+ }
367
+ }
368
+
369
+ return false;
370
+ }
371
+
372
+ // Приватный метод конвертации по формату
373
+ async #convertByFormat(converter, format) {
374
+ switch (format.toLowerCase()) {
375
+ case "webp":
376
+ return converter.toWebp();
377
+ case "avif":
378
+ return converter.toAvif();
379
+ case "png":
380
+ return converter.toPng();
381
+ case "jpg":
382
+ case "jpeg":
383
+ return converter.toJpg();
384
+ case "tiff":
385
+ return converter.toTiff();
386
+ default:
387
+ throw new Error(
388
+ `Unsupported format: ${format}`
389
+ );
390
+ }
391
+ }
392
+
393
+ // Приватный метод проверки дубликатов (гибридный: размер + хеш)
394
+ async #checkDuplicate(desiredPath, buffer) {
395
+ try {
396
+ const stat = await fs.stat(desiredPath);
397
+
398
+ // 1. Быстрая проверка по размеру
399
+ if (stat.size !== buffer.length) {
400
+ // Размеры разные - точно не дубликат
401
+ // Ищем уникальное имя с (N)
402
+ return await this.#findUniqueName(
403
+ desiredPath,
404
+ buffer
405
+ );
406
+ }
407
+
408
+ // 2. Размеры совпали - проверяем хеш (точная проверка)
409
+ const existingFm = new FileManager(desiredPath);
410
+ const existingHash =
411
+ await existingFm.getFileHash();
412
+
413
+ const bufferHash = crypto
414
+ .createHash("sha256")
415
+ .update(buffer)
416
+ .digest("hex");
417
+
418
+ if (existingHash === bufferHash) {
419
+ // Хеши совпали - это точно дубликат
420
+ return {
421
+ finalPath: desiredPath,
422
+ duplicate: true,
423
+ };
424
+ }
425
+
426
+ // Размеры одинаковые, но хеши разные - разные файлы
427
+ return await this.#findUniqueName(
428
+ desiredPath,
429
+ buffer
430
+ );
431
+ } catch {
432
+ // Файл не существует - используем оригинальное имя
433
+ return {
434
+ finalPath: desiredPath,
435
+ duplicate: false,
436
+ };
437
+ }
438
+ }
439
+
440
+ // Вспомогательный метод поиска уникального имени
441
+ async #findUniqueName(desiredPath, buffer) {
442
+ const dir = path.dirname(desiredPath);
443
+ const ext = path.extname(desiredPath);
444
+ const name = path.basename(desiredPath, ext);
445
+
446
+ const bufferHash = crypto
447
+ .createHash("sha256")
448
+ .update(buffer)
449
+ .digest("hex");
450
+
451
+ let counter = 1;
452
+ while (true) {
453
+ const candidate = path.join(
454
+ dir,
455
+ `${name}(${counter})${ext}`
456
+ );
457
+
458
+ try {
459
+ const candidateStat = await fs.stat(
460
+ candidate
461
+ );
462
+
463
+ // Проверяем размер
464
+ if (candidateStat.size !== buffer.length) {
465
+ counter++;
466
+ continue;
467
+ }
468
+
469
+ // Размер совпал - проверяем хеш
470
+ const candidateFm = new FileManager(
471
+ candidate
472
+ );
473
+ const candidateHash =
474
+ await candidateFm.getFileHash();
475
+
476
+ if (candidateHash === bufferHash) {
477
+ return {
478
+ finalPath: candidate,
479
+ duplicate: true,
480
+ };
481
+ }
482
+
483
+ counter++;
484
+ } catch {
485
+ // Файл не существует - используем это имя
486
+ return {
487
+ finalPath: candidate,
488
+ duplicate: false,
489
+ };
490
+ }
491
+ }
492
+ }
493
+ }
package/package.json CHANGED
@@ -1,28 +1,30 @@
1
- {
2
- "name": "auto-image-converter",
3
- "version": "2.1.1",
4
- "keywords": [
5
- "image",
6
- "converter",
7
- "webp",
8
- "avif",
9
- "sharp",
10
- "watch",
11
- "optimization",
12
- "png",
13
- "jpg",
14
- "jpeg",
15
- "for frontend"
16
- ],
17
- "type": "module",
18
- "bin": {
19
- "auto-convert-images": "./bin/index.js",
20
- "auto-convert-images-watch": "./bin/watcher.mjs"
21
- },
22
- "dependencies": {
23
- "chokidar": "^4.0.3",
24
- "commander": "^14.0.0",
25
- "fast-glob": "^3.3.3",
26
- "sharp": "^0.34.2"
27
- }
1
+ {
2
+ "name": "auto-image-converter",
3
+ "version": "2.2.0",
4
+ "keywords": [
5
+ "image",
6
+ "sharp",
7
+ "watch",
8
+ "optimization",
9
+ "converter",
10
+ "webp",
11
+ "avif",
12
+ "png",
13
+ "jpg",
14
+ "jpeg",
15
+ "tiff",
16
+ "for frontend"
17
+ ],
18
+ "type": "module",
19
+ "bin": {
20
+ "auto-convert-images": "./bin/index.js",
21
+ "auto-convert-images-watch": "./bin/watcher.mjs",
22
+ "auto-convert-images-resize": "./bin/onetimeresizer.mjs"
23
+ },
24
+ "dependencies": {
25
+ "chokidar": "^4.0.3",
26
+ "commander": "^14.0.1",
27
+ "fast-glob": "^3.3.3",
28
+ "sharp": "^0.34.4"
29
+ }
28
30
  }