image-convert-cli 1.1.1 → 1.1.3
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/README.md +2 -4
- package/bin/index.ts +2 -1
- package/dist/index.js +223 -21
- package/package.json +1 -1
- package/src/cli.ts +109 -3
- package/src/converter.ts +108 -1
- package/src/index.ts +1 -1
- package/src/prompts.ts +55 -0
- package/src/types.ts +27 -0
- package/src/utils/path.ts +44 -0
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# image-convert-cli
|
|
2
2
|
|
|
3
|
-
> A fast, interactive CLI tool for converting images between WebP, JPEG, and
|
|
3
|
+
> A fast, interactive CLI tool for converting images between WebP, JPEG, JPG and PNG formats
|
|
4
4
|
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
7
|
## Demo
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
https://github.com/user-attachments/assets/f3df2efc-5db7-4201-9092-1aa309880380
|
|
10
10
|
|
|
11
11
|
## Quick Start
|
|
12
12
|
|
|
@@ -182,8 +182,6 @@ interface ConversionResult {
|
|
|
182
182
|
}
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
-
See `CLAUDE.md` for detailed development workflow including TDD practices.
|
|
186
|
-
|
|
187
185
|
## Contributing
|
|
188
186
|
|
|
189
187
|
1. Fork the repository
|
package/bin/index.ts
CHANGED
|
@@ -8,7 +8,8 @@ const options: ConvertOptions = {};
|
|
|
8
8
|
|
|
9
9
|
// Check for update command (positional argument)
|
|
10
10
|
if (args[0] === "update") {
|
|
11
|
-
|
|
11
|
+
const autoUpdate = args.includes("--yes") || args.includes("-y");
|
|
12
|
+
await handleUpdate(undefined, undefined, autoUpdate);
|
|
12
13
|
process.exit(0);
|
|
13
14
|
}
|
|
14
15
|
|
package/dist/index.js
CHANGED
|
@@ -6571,10 +6571,13 @@ var require_lib2 = __commonJS((exports, module) => {
|
|
|
6571
6571
|
module.exports = Sharp;
|
|
6572
6572
|
});
|
|
6573
6573
|
|
|
6574
|
+
// src/cli.ts
|
|
6575
|
+
import { spawn } from "child_process";
|
|
6576
|
+
|
|
6574
6577
|
// src/prompts.ts
|
|
6575
6578
|
import * as readline3 from "readline";
|
|
6576
|
-
import * as
|
|
6577
|
-
import * as
|
|
6579
|
+
import * as fs3 from "fs";
|
|
6580
|
+
import * as path2 from "path";
|
|
6578
6581
|
|
|
6579
6582
|
// node_modules/@inquirer/core/dist/lib/key.js
|
|
6580
6583
|
var isUpKey = (key, keybindings = []) => key.name === "up" || keybindings.includes("vim") && key.name === "k" || keybindings.includes("emacs") && key.ctrl && key.name === "p";
|
|
@@ -8280,7 +8283,7 @@ var dist_default3 = createPrompt((config, done) => {
|
|
|
8280
8283
|
});
|
|
8281
8284
|
// src/converter.ts
|
|
8282
8285
|
var import_sharp = __toESM(require_lib2(), 1);
|
|
8283
|
-
import * as
|
|
8286
|
+
import * as fs2 from "fs";
|
|
8284
8287
|
|
|
8285
8288
|
// src/utils/format.ts
|
|
8286
8289
|
function formatBytes(bytes) {
|
|
@@ -8292,11 +8295,53 @@ function formatBytes(bytes) {
|
|
|
8292
8295
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
8293
8296
|
}
|
|
8294
8297
|
|
|
8298
|
+
// src/utils/path.ts
|
|
8299
|
+
import * as path from "path";
|
|
8300
|
+
import * as fs from "fs";
|
|
8301
|
+
function getDefaultDestinationPath(sourcePath, format) {
|
|
8302
|
+
const parsed = path.parse(sourcePath);
|
|
8303
|
+
return path.join(parsed.dir, `${parsed.name}.${format}`);
|
|
8304
|
+
}
|
|
8305
|
+
function isDirectory(sourcePath) {
|
|
8306
|
+
try {
|
|
8307
|
+
return fs.statSync(sourcePath).isDirectory();
|
|
8308
|
+
} catch {
|
|
8309
|
+
return false;
|
|
8310
|
+
}
|
|
8311
|
+
}
|
|
8312
|
+
function getExtension(filePath) {
|
|
8313
|
+
const ext = path.extname(filePath);
|
|
8314
|
+
return ext.length > 0 ? ext.slice(1).toLowerCase() : "";
|
|
8315
|
+
}
|
|
8316
|
+
function isSameFormat(sourcePath, targetFormat) {
|
|
8317
|
+
const ext = getExtension(sourcePath);
|
|
8318
|
+
if (ext === targetFormat)
|
|
8319
|
+
return true;
|
|
8320
|
+
if ((ext === "jpeg" || ext === "jpg") && (targetFormat === "jpeg" || targetFormat === "jpg")) {
|
|
8321
|
+
return true;
|
|
8322
|
+
}
|
|
8323
|
+
return false;
|
|
8324
|
+
}
|
|
8325
|
+
var SUPPORTED_FORMATS = ["webp", "jpeg", "jpg", "png"];
|
|
8326
|
+
function getImageFilesFromDirectory(dirPath) {
|
|
8327
|
+
try {
|
|
8328
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
8329
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => path.join(dirPath, entry.name)).filter((filePath) => SUPPORTED_FORMATS.includes(getExtension(filePath)));
|
|
8330
|
+
} catch {
|
|
8331
|
+
return [];
|
|
8332
|
+
}
|
|
8333
|
+
}
|
|
8334
|
+
async function ensureDirectoryExists(dirPath) {
|
|
8335
|
+
if (!fs.existsSync(dirPath)) {
|
|
8336
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
8337
|
+
}
|
|
8338
|
+
}
|
|
8339
|
+
|
|
8295
8340
|
// src/converter.ts
|
|
8296
8341
|
async function convertImage(sourcePath, destinationPath, format, compress) {
|
|
8297
8342
|
const startTime = Date.now();
|
|
8298
8343
|
try {
|
|
8299
|
-
const originalSize =
|
|
8344
|
+
const originalSize = fs2.statSync(sourcePath).size;
|
|
8300
8345
|
let sharpFormat;
|
|
8301
8346
|
let options;
|
|
8302
8347
|
switch (format) {
|
|
@@ -8317,7 +8362,7 @@ async function convertImage(sourcePath, destinationPath, format, compress) {
|
|
|
8317
8362
|
throw new Error(`Unsupported format: ${format}`);
|
|
8318
8363
|
}
|
|
8319
8364
|
await import_sharp.default(sourcePath).toFormat(sharpFormat, options).toFile(destinationPath);
|
|
8320
|
-
const outputSize =
|
|
8365
|
+
const outputSize = fs2.statSync(destinationPath).size;
|
|
8321
8366
|
const elapsed = Date.now() - startTime;
|
|
8322
8367
|
return {
|
|
8323
8368
|
success: true,
|
|
@@ -8354,16 +8399,85 @@ Output saved to: ${result.destinationPath}`);
|
|
|
8354
8399
|
\u2717 Conversion failed:`, result.error);
|
|
8355
8400
|
}
|
|
8356
8401
|
}
|
|
8402
|
+
async function convertBatch(settings) {
|
|
8403
|
+
const startTime = Date.now();
|
|
8404
|
+
const files = getImageFilesFromDirectory(settings.sourceDir);
|
|
8405
|
+
await ensureDirectoryExists(settings.destinationDir);
|
|
8406
|
+
const results = [];
|
|
8407
|
+
let successCount = 0;
|
|
8408
|
+
let failureCount = 0;
|
|
8409
|
+
let skippedCount = 0;
|
|
8410
|
+
for (const sourcePath of files) {
|
|
8411
|
+
const fileName = __require("path").basename(sourcePath);
|
|
8412
|
+
const destPath = __require("path").join(settings.destinationDir, `${__require("path").parse(fileName).name}.${settings.targetFormat}`);
|
|
8413
|
+
if (isSameFormat(sourcePath, settings.targetFormat)) {
|
|
8414
|
+
skippedCount++;
|
|
8415
|
+
continue;
|
|
8416
|
+
}
|
|
8417
|
+
if (settings.yesMode && fs2.existsSync(destPath)) {
|
|
8418
|
+
skippedCount++;
|
|
8419
|
+
continue;
|
|
8420
|
+
}
|
|
8421
|
+
const result = await convertImage(sourcePath, destPath, settings.targetFormat, settings.compress);
|
|
8422
|
+
results.push(result);
|
|
8423
|
+
if (result.success) {
|
|
8424
|
+
successCount++;
|
|
8425
|
+
} else {
|
|
8426
|
+
failureCount++;
|
|
8427
|
+
}
|
|
8428
|
+
}
|
|
8429
|
+
return {
|
|
8430
|
+
totalFiles: files.length,
|
|
8431
|
+
successCount,
|
|
8432
|
+
failureCount,
|
|
8433
|
+
skippedCount,
|
|
8434
|
+
results,
|
|
8435
|
+
totalElapsed: Date.now() - startTime
|
|
8436
|
+
};
|
|
8437
|
+
}
|
|
8438
|
+
function displayBatchResult(summary) {
|
|
8439
|
+
console.log(`
|
|
8440
|
+
Batch conversion complete!`);
|
|
8441
|
+
console.log(` Total files: ${summary.totalFiles}`);
|
|
8442
|
+
console.log(` Converted: ${summary.successCount}`);
|
|
8443
|
+
console.log(` Failed: ${summary.failureCount}`);
|
|
8444
|
+
console.log(` Skipped: ${summary.skippedCount}`);
|
|
8445
|
+
console.log(` Time: ${summary.totalElapsed}ms`);
|
|
8446
|
+
if (summary.successCount > 0) {
|
|
8447
|
+
let totalOriginal = 0;
|
|
8448
|
+
let totalOutput = 0;
|
|
8449
|
+
for (const result of summary.results) {
|
|
8450
|
+
if (result.success) {
|
|
8451
|
+
totalOriginal += result.originalSize;
|
|
8452
|
+
totalOutput += result.outputSize;
|
|
8453
|
+
}
|
|
8454
|
+
}
|
|
8455
|
+
if (totalOriginal > 0) {
|
|
8456
|
+
console.log(` Saved: ${((totalOriginal - totalOutput) / totalOriginal * 100).toFixed(1)}%`);
|
|
8457
|
+
}
|
|
8458
|
+
}
|
|
8459
|
+
console.log(`
|
|
8460
|
+
Output directory: ${summary.results[0]?.destinationPath ? __require("path").dirname(summary.results[0].destinationPath) : "N/A"}`);
|
|
8461
|
+
if (summary.failureCount > 0) {
|
|
8462
|
+
console.log(`
|
|
8463
|
+
Failed files:`);
|
|
8464
|
+
for (const result of summary.results) {
|
|
8465
|
+
if (!result.success) {
|
|
8466
|
+
console.log(` - ${result.sourcePath}: ${result.error}`);
|
|
8467
|
+
}
|
|
8468
|
+
}
|
|
8469
|
+
}
|
|
8470
|
+
}
|
|
8357
8471
|
|
|
8358
8472
|
// src/prompts.ts
|
|
8359
8473
|
function filePathCompleter(line) {
|
|
8360
8474
|
const trimmed = line.trim();
|
|
8361
8475
|
const input = trimmed.split(" ")[0] || ".";
|
|
8362
8476
|
const isDirectoryInput = input.endsWith("/");
|
|
8363
|
-
const dir = isDirectoryInput ? input.slice(0, -1) || "." :
|
|
8364
|
-
const base = isDirectoryInput ? "" :
|
|
8477
|
+
const dir = isDirectoryInput ? input.slice(0, -1) || "." : path2.dirname(input) || ".";
|
|
8478
|
+
const base = isDirectoryInput ? "" : path2.basename(input) || "";
|
|
8365
8479
|
try {
|
|
8366
|
-
const files =
|
|
8480
|
+
const files = fs3.readdirSync(dir, { withFileTypes: true });
|
|
8367
8481
|
const filtered = base ? files.filter((dirent) => dirent.name.startsWith(base)) : files;
|
|
8368
8482
|
const completions = filtered.map((dirent) => {
|
|
8369
8483
|
const fullPath = dir === "." ? dirent.name : `${dir}/${dirent.name}`;
|
|
@@ -8405,7 +8519,7 @@ class InteractivePromptService {
|
|
|
8405
8519
|
if (!input.trim()) {
|
|
8406
8520
|
return "Please enter a file path";
|
|
8407
8521
|
}
|
|
8408
|
-
if (!
|
|
8522
|
+
if (!fs3.existsSync(input)) {
|
|
8409
8523
|
return "File does not exist";
|
|
8410
8524
|
}
|
|
8411
8525
|
return true;
|
|
@@ -8416,8 +8530,8 @@ class InteractivePromptService {
|
|
|
8416
8530
|
if (!input.trim()) {
|
|
8417
8531
|
return "Please enter a destination path";
|
|
8418
8532
|
}
|
|
8419
|
-
const dir =
|
|
8420
|
-
if (dir !== "." && !
|
|
8533
|
+
const dir = path2.dirname(input);
|
|
8534
|
+
if (dir !== "." && !fs3.existsSync(dir)) {
|
|
8421
8535
|
return "Destination directory does not exist";
|
|
8422
8536
|
}
|
|
8423
8537
|
return true;
|
|
@@ -8446,6 +8560,29 @@ class InteractivePromptService {
|
|
|
8446
8560
|
default: true
|
|
8447
8561
|
});
|
|
8448
8562
|
}
|
|
8563
|
+
async promptForOverwrite(filePath) {
|
|
8564
|
+
return dist_default2({
|
|
8565
|
+
message: `File ${filePath} already exists. Overwrite?`,
|
|
8566
|
+
default: false
|
|
8567
|
+
});
|
|
8568
|
+
}
|
|
8569
|
+
async promptBatchDestination(defaultPath, validate2) {
|
|
8570
|
+
return inputWithPathCompletion("Output directory", defaultPath, validate2 || ((input) => {
|
|
8571
|
+
if (!input.trim()) {
|
|
8572
|
+
return "Please enter an output directory";
|
|
8573
|
+
}
|
|
8574
|
+
if (!fs3.existsSync(input)) {
|
|
8575
|
+
return "Output directory does not exist";
|
|
8576
|
+
}
|
|
8577
|
+
return true;
|
|
8578
|
+
}));
|
|
8579
|
+
}
|
|
8580
|
+
async promptBatchConfirm(fileCount) {
|
|
8581
|
+
return dist_default2({
|
|
8582
|
+
message: `Convert ${fileCount} files?`,
|
|
8583
|
+
default: true
|
|
8584
|
+
});
|
|
8585
|
+
}
|
|
8449
8586
|
showHelp() {
|
|
8450
8587
|
console.log(`Image Converter CLI
|
|
8451
8588
|
|
|
@@ -8499,16 +8636,44 @@ Conversion settings:`);
|
|
|
8499
8636
|
}
|
|
8500
8637
|
}
|
|
8501
8638
|
|
|
8502
|
-
// src/utils/path.ts
|
|
8503
|
-
import * as path2 from "path";
|
|
8504
|
-
function getDefaultDestinationPath(sourcePath, format) {
|
|
8505
|
-
const parsed = path2.parse(sourcePath);
|
|
8506
|
-
return path2.join(parsed.dir, `${parsed.name}.${format}`);
|
|
8507
|
-
}
|
|
8508
|
-
|
|
8509
8639
|
// src/cli.ts
|
|
8510
8640
|
var NPM_REGISTRY_URL = "https://registry.npmjs.org/image-convert-cli/latest";
|
|
8511
|
-
async function
|
|
8641
|
+
async function executeUpdate() {
|
|
8642
|
+
return new Promise((resolve) => {
|
|
8643
|
+
console.log("Updating image-convert-cli...");
|
|
8644
|
+
const child = spawn("bun", ["add", "-g", "image-convert-cli"], {
|
|
8645
|
+
stdio: ["inherit", "pipe", "pipe"]
|
|
8646
|
+
});
|
|
8647
|
+
child.stdout?.on("data", (data) => {
|
|
8648
|
+
process.stdout.write(data);
|
|
8649
|
+
});
|
|
8650
|
+
child.stderr?.on("data", (data) => {
|
|
8651
|
+
process.stderr.write(data);
|
|
8652
|
+
});
|
|
8653
|
+
child.on("close", (code) => {
|
|
8654
|
+
if (code === 0) {
|
|
8655
|
+
console.log("Update completed successfully.");
|
|
8656
|
+
} else {
|
|
8657
|
+
console.error(`Update failed with exit code: ${code}`);
|
|
8658
|
+
}
|
|
8659
|
+
resolve();
|
|
8660
|
+
});
|
|
8661
|
+
child.on("error", (error) => {
|
|
8662
|
+
console.error(`Update failed: ${error.message}`);
|
|
8663
|
+
resolve();
|
|
8664
|
+
});
|
|
8665
|
+
});
|
|
8666
|
+
}
|
|
8667
|
+
async function promptForUpdate(latestVersion, promptService) {
|
|
8668
|
+
const prompts = promptService || new InteractivePromptService;
|
|
8669
|
+
return prompts.promptConfirm({
|
|
8670
|
+
sourcePath: "",
|
|
8671
|
+
targetFormat: "webp",
|
|
8672
|
+
destinationPath: "",
|
|
8673
|
+
compress: false
|
|
8674
|
+
});
|
|
8675
|
+
}
|
|
8676
|
+
async function handleUpdate(fetchVersion, currentVersion, autoUpdate, promptService) {
|
|
8512
8677
|
const fetcher = fetchVersion || (async () => {
|
|
8513
8678
|
const response = await fetch(NPM_REGISTRY_URL);
|
|
8514
8679
|
if (!response.ok) {
|
|
@@ -8524,7 +8689,16 @@ async function handleUpdate(fetchVersion, currentVersion) {
|
|
|
8524
8689
|
console.log(`You are running the latest version: ${version}`);
|
|
8525
8690
|
} else {
|
|
8526
8691
|
console.log(`Update available: ${version} -> ${latestVersion}`);
|
|
8527
|
-
|
|
8692
|
+
if (autoUpdate) {
|
|
8693
|
+
await executeUpdate();
|
|
8694
|
+
} else {
|
|
8695
|
+
const shouldUpdate = await promptForUpdate(latestVersion, promptService);
|
|
8696
|
+
if (shouldUpdate) {
|
|
8697
|
+
await executeUpdate();
|
|
8698
|
+
} else {
|
|
8699
|
+
console.log("Update cancelled.");
|
|
8700
|
+
}
|
|
8701
|
+
}
|
|
8528
8702
|
}
|
|
8529
8703
|
} catch (error) {
|
|
8530
8704
|
console.error(`Error checking for updates: ${error.message}`);
|
|
@@ -8550,6 +8724,11 @@ async function runCli(options, promptService) {
|
|
|
8550
8724
|
`);
|
|
8551
8725
|
const sourcePath = options.source || await prompts.promptSourceFile();
|
|
8552
8726
|
const targetFormat = options.format || await prompts.promptFormat();
|
|
8727
|
+
const isBatchMode = isDirectory(sourcePath);
|
|
8728
|
+
if (isBatchMode) {
|
|
8729
|
+
await runBatchMode(sourcePath, targetFormat, options, prompts);
|
|
8730
|
+
return;
|
|
8731
|
+
}
|
|
8553
8732
|
const destinationPath = options.destination || await prompts.promptDestination(getDefaultDestinationPath(sourcePath, targetFormat));
|
|
8554
8733
|
const compress = options.yes !== undefined ? options.yes : await prompts.promptCompress();
|
|
8555
8734
|
const settings = {
|
|
@@ -8570,12 +8749,35 @@ async function runCli(options, promptService) {
|
|
|
8570
8749
|
process.exit(1);
|
|
8571
8750
|
}
|
|
8572
8751
|
}
|
|
8752
|
+
async function runBatchMode(sourceDir, targetFormat, options, prompts) {
|
|
8753
|
+
const yesMode = options.yes === true;
|
|
8754
|
+
const destinationDir = options.destination || (yesMode ? sourceDir : await prompts.promptBatchDestination(sourceDir));
|
|
8755
|
+
const compress = yesMode ? false : await prompts.promptCompress();
|
|
8756
|
+
const files = getImageFilesFromDirectory(sourceDir);
|
|
8757
|
+
if (!yesMode) {
|
|
8758
|
+
const shouldProceed = await prompts.promptBatchConfirm(files.length);
|
|
8759
|
+
if (!shouldProceed) {
|
|
8760
|
+
console.log("Batch conversion cancelled.");
|
|
8761
|
+
return;
|
|
8762
|
+
}
|
|
8763
|
+
}
|
|
8764
|
+
const batchSettings = {
|
|
8765
|
+
sourceDir,
|
|
8766
|
+
targetFormat,
|
|
8767
|
+
destinationDir,
|
|
8768
|
+
compress,
|
|
8769
|
+
yesMode
|
|
8770
|
+
};
|
|
8771
|
+
const result = await convertBatch(batchSettings);
|
|
8772
|
+
displayBatchResult(result);
|
|
8773
|
+
}
|
|
8573
8774
|
|
|
8574
8775
|
// bin/index.ts
|
|
8575
8776
|
var args = process.argv.slice(2);
|
|
8576
8777
|
var options = {};
|
|
8577
8778
|
if (args[0] === "update") {
|
|
8578
|
-
|
|
8779
|
+
const autoUpdate = args.includes("--yes") || args.includes("-y");
|
|
8780
|
+
await handleUpdate(undefined, undefined, autoUpdate);
|
|
8579
8781
|
process.exit(0);
|
|
8580
8782
|
}
|
|
8581
8783
|
if (args[0] === "version") {
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,13 +1,63 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
1
3
|
import type { ConvertOptions } from "./types";
|
|
2
4
|
import { IPromptService, InteractivePromptService } from "./prompts";
|
|
3
|
-
import { convertImage, displayConversionResult } from "./converter";
|
|
4
|
-
import { getDefaultDestinationPath } from "./utils/path";
|
|
5
|
+
import { convertImage, displayConversionResult, convertBatch, displayBatchResult } from "./converter";
|
|
6
|
+
import { getDefaultDestinationPath, isDirectory, getImageFilesFromDirectory } from "./utils/path";
|
|
7
|
+
import type { BatchConversionSettings } from "./types";
|
|
5
8
|
|
|
6
9
|
const NPM_REGISTRY_URL = "https://registry.npmjs.org/image-convert-cli/latest";
|
|
7
10
|
|
|
11
|
+
export async function executeUpdate(): Promise<void> {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
console.log("Updating image-convert-cli...");
|
|
14
|
+
|
|
15
|
+
const child = spawn("bun", ["add", "-g", "image-convert-cli"], {
|
|
16
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
child.stdout?.on("data", (data) => {
|
|
20
|
+
process.stdout.write(data);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
child.stderr?.on("data", (data) => {
|
|
24
|
+
process.stderr.write(data);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
child.on("close", (code) => {
|
|
28
|
+
if (code === 0) {
|
|
29
|
+
console.log("Update completed successfully.");
|
|
30
|
+
} else {
|
|
31
|
+
console.error(`Update failed with exit code: ${code}`);
|
|
32
|
+
}
|
|
33
|
+
resolve();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
child.on("error", (error) => {
|
|
37
|
+
console.error(`Update failed: ${error.message}`);
|
|
38
|
+
resolve();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function promptForUpdate(
|
|
44
|
+
latestVersion: string,
|
|
45
|
+
promptService?: IPromptService,
|
|
46
|
+
): Promise<boolean> {
|
|
47
|
+
const prompts = promptService || new InteractivePromptService();
|
|
48
|
+
return prompts.promptConfirm({
|
|
49
|
+
sourcePath: "",
|
|
50
|
+
targetFormat: "webp",
|
|
51
|
+
destinationPath: "",
|
|
52
|
+
compress: false,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
8
56
|
export async function handleUpdate(
|
|
9
57
|
fetchVersion?: () => Promise<string>,
|
|
10
58
|
currentVersion?: string,
|
|
59
|
+
autoUpdate?: boolean,
|
|
60
|
+
promptService?: IPromptService,
|
|
11
61
|
): Promise<void> {
|
|
12
62
|
const fetcher = fetchVersion || (async () => {
|
|
13
63
|
const response = await fetch(NPM_REGISTRY_URL);
|
|
@@ -27,7 +77,17 @@ export async function handleUpdate(
|
|
|
27
77
|
console.log(`You are running the latest version: ${version}`);
|
|
28
78
|
} else {
|
|
29
79
|
console.log(`Update available: ${version} -> ${latestVersion}`);
|
|
30
|
-
|
|
80
|
+
|
|
81
|
+
if (autoUpdate) {
|
|
82
|
+
await executeUpdate();
|
|
83
|
+
} else {
|
|
84
|
+
const shouldUpdate = await promptForUpdate(latestVersion, promptService);
|
|
85
|
+
if (shouldUpdate) {
|
|
86
|
+
await executeUpdate();
|
|
87
|
+
} else {
|
|
88
|
+
console.log("Update cancelled.");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
31
91
|
}
|
|
32
92
|
} catch (error) {
|
|
33
93
|
console.error(`Error checking for updates: ${(error as Error).message}`);
|
|
@@ -64,6 +124,15 @@ export async function runCli(
|
|
|
64
124
|
const targetFormat =
|
|
65
125
|
options.format || (await prompts.promptFormat());
|
|
66
126
|
|
|
127
|
+
// Check if source is a directory (batch mode)
|
|
128
|
+
const isBatchMode = isDirectory(sourcePath);
|
|
129
|
+
|
|
130
|
+
if (isBatchMode) {
|
|
131
|
+
await runBatchMode(sourcePath, targetFormat, options, prompts);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Single file conversion
|
|
67
136
|
const destinationPath =
|
|
68
137
|
options.destination ||
|
|
69
138
|
(await prompts.promptDestination(
|
|
@@ -103,3 +172,40 @@ export async function runCli(
|
|
|
103
172
|
process.exit(1);
|
|
104
173
|
}
|
|
105
174
|
}
|
|
175
|
+
|
|
176
|
+
async function runBatchMode(
|
|
177
|
+
sourceDir: string,
|
|
178
|
+
targetFormat: string,
|
|
179
|
+
options: ConvertOptions,
|
|
180
|
+
prompts: IPromptService,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
const yesMode = options.yes === true;
|
|
183
|
+
|
|
184
|
+
// Get destination directory - default to same as source
|
|
185
|
+
const destinationDir = options.destination || (yesMode ? sourceDir : await prompts.promptBatchDestination(sourceDir));
|
|
186
|
+
|
|
187
|
+
const compress = yesMode ? false : await prompts.promptCompress();
|
|
188
|
+
|
|
189
|
+
// Count files to convert
|
|
190
|
+
const files = getImageFilesFromDirectory(sourceDir);
|
|
191
|
+
|
|
192
|
+
// In non-yes mode, show batch confirmation
|
|
193
|
+
if (!yesMode) {
|
|
194
|
+
const shouldProceed = await prompts.promptBatchConfirm(files.length);
|
|
195
|
+
if (!shouldProceed) {
|
|
196
|
+
console.log("Batch conversion cancelled.");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const batchSettings: BatchConversionSettings = {
|
|
202
|
+
sourceDir,
|
|
203
|
+
targetFormat: targetFormat as any,
|
|
204
|
+
destinationDir,
|
|
205
|
+
compress,
|
|
206
|
+
yesMode,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const result = await convertBatch(batchSettings);
|
|
210
|
+
displayBatchResult(result);
|
|
211
|
+
}
|
package/src/converter.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import sharp from "sharp";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
ConversionResult,
|
|
5
|
+
SupportedFormat,
|
|
6
|
+
BatchConversionSettings,
|
|
7
|
+
BatchConversionSummary,
|
|
8
|
+
BatchConversionResult,
|
|
9
|
+
} from "./types";
|
|
4
10
|
import { formatBytes } from "./utils/format";
|
|
11
|
+
import {
|
|
12
|
+
getImageFilesFromDirectory,
|
|
13
|
+
isSameFormat,
|
|
14
|
+
ensureDirectoryExists,
|
|
15
|
+
} from "./utils/path";
|
|
5
16
|
|
|
6
17
|
export async function convertImage(
|
|
7
18
|
sourcePath: string,
|
|
@@ -83,3 +94,99 @@ export function displayConversionResult(result: ConversionResult): void {
|
|
|
83
94
|
console.error("\n✗ Conversion failed:", result.error);
|
|
84
95
|
}
|
|
85
96
|
}
|
|
97
|
+
|
|
98
|
+
export async function convertBatch(
|
|
99
|
+
settings: BatchConversionSettings,
|
|
100
|
+
): Promise<BatchConversionSummary> {
|
|
101
|
+
const startTime = Date.now();
|
|
102
|
+
const files = getImageFilesFromDirectory(settings.sourceDir);
|
|
103
|
+
|
|
104
|
+
await ensureDirectoryExists(settings.destinationDir);
|
|
105
|
+
|
|
106
|
+
const results: BatchConversionResult[] = [];
|
|
107
|
+
let successCount = 0;
|
|
108
|
+
let failureCount = 0;
|
|
109
|
+
let skippedCount = 0;
|
|
110
|
+
|
|
111
|
+
for (const sourcePath of files) {
|
|
112
|
+
const fileName = require("node:path").basename(sourcePath);
|
|
113
|
+
const destPath = require("node:path").join(
|
|
114
|
+
settings.destinationDir,
|
|
115
|
+
`${require("node:path").parse(fileName).name}.${settings.targetFormat}`,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Skip files already in target format
|
|
119
|
+
if (isSameFormat(sourcePath, settings.targetFormat)) {
|
|
120
|
+
skippedCount++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Skip existing files in yes mode
|
|
125
|
+
if (settings.yesMode && fs.existsSync(destPath)) {
|
|
126
|
+
skippedCount++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result = await convertImage(
|
|
131
|
+
sourcePath,
|
|
132
|
+
destPath,
|
|
133
|
+
settings.targetFormat,
|
|
134
|
+
settings.compress,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
results.push(result);
|
|
138
|
+
|
|
139
|
+
if (result.success) {
|
|
140
|
+
successCount++;
|
|
141
|
+
} else {
|
|
142
|
+
failureCount++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
totalFiles: files.length,
|
|
148
|
+
successCount,
|
|
149
|
+
failureCount,
|
|
150
|
+
skippedCount,
|
|
151
|
+
results,
|
|
152
|
+
totalElapsed: Date.now() - startTime,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function displayBatchResult(summary: BatchConversionSummary): void {
|
|
157
|
+
console.log("\nBatch conversion complete!");
|
|
158
|
+
console.log(` Total files: ${summary.totalFiles}`);
|
|
159
|
+
console.log(` Converted: ${summary.successCount}`);
|
|
160
|
+
console.log(` Failed: ${summary.failureCount}`);
|
|
161
|
+
console.log(` Skipped: ${summary.skippedCount}`);
|
|
162
|
+
console.log(` Time: ${summary.totalElapsed}ms`);
|
|
163
|
+
|
|
164
|
+
if (summary.successCount > 0) {
|
|
165
|
+
let totalOriginal = 0;
|
|
166
|
+
let totalOutput = 0;
|
|
167
|
+
|
|
168
|
+
for (const result of summary.results) {
|
|
169
|
+
if (result.success) {
|
|
170
|
+
totalOriginal += result.originalSize;
|
|
171
|
+
totalOutput += result.outputSize;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (totalOriginal > 0) {
|
|
176
|
+
console.log(
|
|
177
|
+
` Saved: ${(((totalOriginal - totalOutput) / totalOriginal) * 100).toFixed(1)}%`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log(`\nOutput directory: ${summary.results[0]?.destinationPath ? require("node:path").dirname(summary.results[0].destinationPath) : "N/A"}`);
|
|
183
|
+
|
|
184
|
+
if (summary.failureCount > 0) {
|
|
185
|
+
console.log("\nFailed files:");
|
|
186
|
+
for (const result of summary.results) {
|
|
187
|
+
if (!result.success) {
|
|
188
|
+
console.log(` - ${result.sourcePath}: ${result.error}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export * from "./types";
|
|
2
2
|
export * from "./utils/path";
|
|
3
3
|
export * from "./utils/format";
|
|
4
|
-
export { convertImage, displayConversionResult } from "./converter";
|
|
4
|
+
export { convertImage, displayConversionResult, convertBatch, displayBatchResult } from "./converter";
|
|
5
5
|
export { runCli, handleUpdate } from "./cli";
|
|
6
6
|
export {
|
|
7
7
|
type IPromptService,
|
package/src/prompts.ts
CHANGED
|
@@ -72,6 +72,9 @@ export interface IPromptService {
|
|
|
72
72
|
promptFormat(): Promise<SupportedFormat>;
|
|
73
73
|
promptCompress(): Promise<boolean>;
|
|
74
74
|
promptConfirm(settings: ConversionSettings): Promise<boolean>;
|
|
75
|
+
promptForOverwrite(filePath: string): Promise<boolean>;
|
|
76
|
+
promptBatchDestination(defaultPath: string, validate?: (path: string) => string | true): Promise<string>;
|
|
77
|
+
promptBatchConfirm(fileCount: number): Promise<boolean>;
|
|
75
78
|
showHelp(): void;
|
|
76
79
|
showSettings(settings: ConversionSettings): void;
|
|
77
80
|
showResult(result: { success: boolean; destinationPath: string; elapsed: number; originalSize: number; outputSize: number; error?: string }): void;
|
|
@@ -144,6 +147,40 @@ export class InteractivePromptService implements IPromptService {
|
|
|
144
147
|
});
|
|
145
148
|
}
|
|
146
149
|
|
|
150
|
+
async promptForOverwrite(filePath: string): Promise<boolean> {
|
|
151
|
+
return confirm({
|
|
152
|
+
message: `File ${filePath} already exists. Overwrite?`,
|
|
153
|
+
default: false,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async promptBatchDestination(
|
|
158
|
+
defaultPath: string,
|
|
159
|
+
validate?: (path: string) => string | true,
|
|
160
|
+
): Promise<string> {
|
|
161
|
+
return inputWithPathCompletion(
|
|
162
|
+
"Output directory",
|
|
163
|
+
defaultPath,
|
|
164
|
+
validate ||
|
|
165
|
+
((input) => {
|
|
166
|
+
if (!input.trim()) {
|
|
167
|
+
return "Please enter an output directory";
|
|
168
|
+
}
|
|
169
|
+
if (!fs.existsSync(input)) {
|
|
170
|
+
return "Output directory does not exist";
|
|
171
|
+
}
|
|
172
|
+
return true;
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async promptBatchConfirm(fileCount: number): Promise<boolean> {
|
|
178
|
+
return confirm({
|
|
179
|
+
message: `Convert ${fileCount} files?`,
|
|
180
|
+
default: true,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
147
184
|
showHelp(): void {
|
|
148
185
|
console.log(`Image Converter CLI
|
|
149
186
|
|
|
@@ -205,6 +242,9 @@ export class NoopPromptService implements IPromptService {
|
|
|
205
242
|
format?: SupportedFormat;
|
|
206
243
|
compress?: boolean;
|
|
207
244
|
confirm?: boolean;
|
|
245
|
+
overwrite?: boolean;
|
|
246
|
+
batchDestination?: string;
|
|
247
|
+
batchConfirm?: boolean;
|
|
208
248
|
};
|
|
209
249
|
|
|
210
250
|
constructor(responses?: {
|
|
@@ -213,6 +253,9 @@ export class NoopPromptService implements IPromptService {
|
|
|
213
253
|
format?: SupportedFormat;
|
|
214
254
|
compress?: boolean;
|
|
215
255
|
confirm?: boolean;
|
|
256
|
+
overwrite?: boolean;
|
|
257
|
+
batchDestination?: string;
|
|
258
|
+
batchConfirm?: boolean;
|
|
216
259
|
}) {
|
|
217
260
|
this.responses = responses || {};
|
|
218
261
|
}
|
|
@@ -237,6 +280,18 @@ export class NoopPromptService implements IPromptService {
|
|
|
237
280
|
return this.responses.confirm ?? true;
|
|
238
281
|
}
|
|
239
282
|
|
|
283
|
+
async promptForOverwrite(): Promise<boolean> {
|
|
284
|
+
return this.responses.overwrite ?? false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async promptBatchDestination(defaultPath: string): Promise<string> {
|
|
288
|
+
return this.responses.batchDestination || defaultPath;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async promptBatchConfirm(): Promise<boolean> {
|
|
292
|
+
return this.responses.batchConfirm ?? true;
|
|
293
|
+
}
|
|
294
|
+
|
|
240
295
|
showHelp(): void {}
|
|
241
296
|
showSettings(): void {}
|
|
242
297
|
showResult(): void {}
|
package/src/types.ts
CHANGED
|
@@ -26,3 +26,30 @@ export type ConversionSettings = {
|
|
|
26
26
|
destinationPath: string;
|
|
27
27
|
compress: boolean;
|
|
28
28
|
};
|
|
29
|
+
|
|
30
|
+
export type BatchConversionResult = {
|
|
31
|
+
success: boolean;
|
|
32
|
+
sourcePath: string;
|
|
33
|
+
destinationPath: string;
|
|
34
|
+
originalSize: number;
|
|
35
|
+
outputSize: number;
|
|
36
|
+
elapsed: number;
|
|
37
|
+
error?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type BatchConversionSettings = {
|
|
41
|
+
sourceDir: string;
|
|
42
|
+
targetFormat: SupportedFormat;
|
|
43
|
+
destinationDir: string;
|
|
44
|
+
compress: boolean;
|
|
45
|
+
yesMode: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type BatchConversionSummary = {
|
|
49
|
+
totalFiles: number;
|
|
50
|
+
successCount: number;
|
|
51
|
+
failureCount: number;
|
|
52
|
+
skippedCount: number;
|
|
53
|
+
results: BatchConversionResult[];
|
|
54
|
+
totalElapsed: number;
|
|
55
|
+
};
|
package/src/utils/path.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
+
import * as fs from "node:fs";
|
|
2
3
|
import type { SupportedFormat } from "../types";
|
|
3
4
|
|
|
4
5
|
export function getDefaultDestinationPath(
|
|
@@ -8,3 +9,46 @@ export function getDefaultDestinationPath(
|
|
|
8
9
|
const parsed = path.parse(sourcePath);
|
|
9
10
|
return path.join(parsed.dir, `${parsed.name}.${format}`);
|
|
10
11
|
}
|
|
12
|
+
|
|
13
|
+
export function isDirectory(sourcePath: string): boolean {
|
|
14
|
+
try {
|
|
15
|
+
return fs.statSync(sourcePath).isDirectory();
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getExtension(filePath: string): string {
|
|
22
|
+
const ext = path.extname(filePath);
|
|
23
|
+
return ext.length > 0 ? ext.slice(1).toLowerCase() : "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isSameFormat(sourcePath: string, targetFormat: SupportedFormat): boolean {
|
|
27
|
+
const ext = getExtension(sourcePath);
|
|
28
|
+
if (ext === targetFormat) return true;
|
|
29
|
+
// Handle jpeg/jpg equivalence
|
|
30
|
+
if ((ext === "jpeg" || ext === "jpg") && (targetFormat === "jpeg" || targetFormat === "jpg")) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const SUPPORTED_FORMATS = ["webp", "jpeg", "jpg", "png"];
|
|
37
|
+
|
|
38
|
+
export function getImageFilesFromDirectory(dirPath: string): string[] {
|
|
39
|
+
try {
|
|
40
|
+
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
41
|
+
return entries
|
|
42
|
+
.filter((entry) => entry.isFile())
|
|
43
|
+
.map((entry) => path.join(dirPath, entry.name))
|
|
44
|
+
.filter((filePath) => SUPPORTED_FORMATS.includes(getExtension(filePath)));
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function ensureDirectoryExists(dirPath: string): Promise<void> {
|
|
51
|
+
if (!fs.existsSync(dirPath)) {
|
|
52
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
}
|