max-cli 0.2.0__py3-none-any.whl
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.
- max_cli/__init__.py +0 -0
- max_cli/common/cache.py +145 -0
- max_cli/common/concurrent.py +83 -0
- max_cli/common/exceptions.py +40 -0
- max_cli/common/logger.py +22 -0
- max_cli/common/logging.py +24 -0
- max_cli/common/retry.py +51 -0
- max_cli/common/utils.py +40 -0
- max_cli/config.py +43 -0
- max_cli/core/ai_engine.py +541 -0
- max_cli/core/file_organizer.py +254 -0
- max_cli/core/image_processor.py +139 -0
- max_cli/core/media_engine.py +681 -0
- max_cli/core/network_engine.py +103 -0
- max_cli/core/pdf_engine.py +520 -0
- max_cli/core/system_engine.py +57 -0
- max_cli/interface/cli_ai.py +376 -0
- max_cli/interface/cli_config.py +363 -0
- max_cli/interface/cli_files.py +388 -0
- max_cli/interface/cli_images.py +176 -0
- max_cli/interface/cli_media.py +558 -0
- max_cli/interface/cli_network.py +174 -0
- max_cli/interface/cli_pdf.py +651 -0
- max_cli/interface/cli_tools.py +60 -0
- max_cli/main.py +91 -0
- max_cli/plugins/__init__.py +4 -0
- max_cli/plugins/base.py +39 -0
- max_cli/plugins/manager.py +81 -0
- max_cli-0.2.0.dist-info/METADATA +632 -0
- max_cli-0.2.0.dist-info/RECORD +34 -0
- max_cli-0.2.0.dist-info/WHEEL +5 -0
- max_cli-0.2.0.dist-info/entry_points.txt +2 -0
- max_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- max_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from rich.prompt import Confirm
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
from max_cli.core.file_organizer import FileOrganizer
|
|
8
|
+
from max_cli.common.logger import console, log_error, log_success
|
|
9
|
+
from max_cli.interface.cli_ai import engine # Import the AIEngine instance
|
|
10
|
+
|
|
11
|
+
app = typer.Typer()
|
|
12
|
+
organizer = FileOrganizer()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app.command("order")
|
|
16
|
+
def order_files(
|
|
17
|
+
folder: Path = typer.Argument(..., help="The folder containing files to order."),
|
|
18
|
+
dry_run: bool = typer.Option(
|
|
19
|
+
False, "--dry-run", help="Simulate the rename without changing files."
|
|
20
|
+
),
|
|
21
|
+
force: bool = typer.Option(
|
|
22
|
+
False, "-f", "--force", help="Skip confirmation prompt."
|
|
23
|
+
),
|
|
24
|
+
start: int = typer.Option(
|
|
25
|
+
1, "--start", help="Number to start counting from (default 1)."
|
|
26
|
+
),
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Rename all files in a folder with a number prefix (e.g. 1_file.txt).
|
|
30
|
+
Skips files that are already numbered.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
if not folder.is_dir():
|
|
34
|
+
log_error(f"'{folder}' is not a directory.")
|
|
35
|
+
raise typer.Exit(code=1)
|
|
36
|
+
|
|
37
|
+
# 1. Get stats first to show the user what will happen
|
|
38
|
+
try:
|
|
39
|
+
files = organizer.scan_directory(folder)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
log_error(str(e))
|
|
42
|
+
raise typer.Exit(code=1)
|
|
43
|
+
|
|
44
|
+
if not files:
|
|
45
|
+
console.print("[yellow]Folder is empty. Nothing to do.[/yellow]")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
# 2. Confirmation (Unless forced or dry-run)
|
|
49
|
+
if not dry_run and not force:
|
|
50
|
+
console.print(
|
|
51
|
+
Panel(
|
|
52
|
+
Text(f"Target: {folder}\nFiles found: {len(files)}", justify="center"),
|
|
53
|
+
title="[bold yellow]⚠ Bulk Rename Warning[/bold yellow]",
|
|
54
|
+
border_style="yellow",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
if not Confirm.ask("Are you sure you want to rename these files?"):
|
|
58
|
+
console.print("[red]Aborted.[/red]")
|
|
59
|
+
raise typer.Exit()
|
|
60
|
+
|
|
61
|
+
# 3. Execute
|
|
62
|
+
console.print(
|
|
63
|
+
f"[bold cyan]Processing files starting at index {start}...[/bold cyan]"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
results = organizer.order_files(folder, dry_run=dry_run, start_index=start)
|
|
67
|
+
|
|
68
|
+
# 4. Report
|
|
69
|
+
# Print the log of actions (limited to last 10 if too many, to avoid spam)
|
|
70
|
+
actions = results["actions"]
|
|
71
|
+
if len(actions) > 20:
|
|
72
|
+
for action in actions[:10]:
|
|
73
|
+
console.print(f" {action}")
|
|
74
|
+
console.print(f" ... and {len(actions) - 10} more.")
|
|
75
|
+
else:
|
|
76
|
+
for action in actions:
|
|
77
|
+
console.print(f" {action}")
|
|
78
|
+
|
|
79
|
+
summary_color = "green" if not dry_run else "yellow"
|
|
80
|
+
console.print(f"\n[{summary_color}]Summary:[/ {summary_color}]")
|
|
81
|
+
console.print(f" Files Processed: {results['renamed']}")
|
|
82
|
+
console.print(f" Files Skipped: {results['skipped']}")
|
|
83
|
+
|
|
84
|
+
if dry_run:
|
|
85
|
+
console.print(
|
|
86
|
+
"\n[bold yellow]This was a Dry Run. No files were changed.[/bold yellow]"
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
log_success("File ordering complete!")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("smart-sort")
|
|
93
|
+
def smart_sort(
|
|
94
|
+
path: Path = typer.Argument(".", help="Folder to organize."),
|
|
95
|
+
dry_run: bool = typer.Option(
|
|
96
|
+
False, "--dry-run", help="Show changes without moving."
|
|
97
|
+
),
|
|
98
|
+
):
|
|
99
|
+
"""
|
|
100
|
+
AI-powered file organization. Groups files by content/meaning, not just extension.
|
|
101
|
+
"""
|
|
102
|
+
files = [
|
|
103
|
+
f.name for f in path.iterdir() if f.is_file() and not f.name.startswith(".")
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
if not files:
|
|
107
|
+
console.print("[yellow]No files to organize.[/yellow]")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
console.print(f"[cyan]Analyzing {len(files)} files with AI...[/cyan]")
|
|
111
|
+
|
|
112
|
+
# engine is the AIEngine instance
|
|
113
|
+
categories = engine.categorize_files(files)
|
|
114
|
+
|
|
115
|
+
for filename, category in categories.items():
|
|
116
|
+
src = path / filename
|
|
117
|
+
dest_dir = path / category
|
|
118
|
+
|
|
119
|
+
console.print(
|
|
120
|
+
f" [dim]{filename}[/dim][/dim] -> [bold cyan]{category}/[/bold cyan]"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if not dry_run:
|
|
124
|
+
dest_dir.mkdir(exist_ok=True)
|
|
125
|
+
src.rename(dest_dir / filename)
|
|
126
|
+
|
|
127
|
+
if not dry_run:
|
|
128
|
+
log_success(f"Successfully organized {len(files)} files.")
|
|
129
|
+
else:
|
|
130
|
+
console.print("[yellow]Dry run complete. No files moved.[/yellow]")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command("duplicates")
|
|
134
|
+
def find_duplicates(
|
|
135
|
+
folder: Path = typer.Argument(".", help="Folder to scan for duplicates."),
|
|
136
|
+
recursive: bool = typer.Option(
|
|
137
|
+
False, "-r", "--recursive", help="Scan subdirectories as well."
|
|
138
|
+
),
|
|
139
|
+
delete: bool = typer.Option(
|
|
140
|
+
False, "-d", "--delete", help="Delete duplicates (keeps one copy)."
|
|
141
|
+
),
|
|
142
|
+
):
|
|
143
|
+
"""
|
|
144
|
+
Find and optionally remove duplicate files based on content.
|
|
145
|
+
"""
|
|
146
|
+
if not folder.is_dir():
|
|
147
|
+
log_error(f"'{folder}' is not a directory.")
|
|
148
|
+
raise typer.Exit(code=1)
|
|
149
|
+
|
|
150
|
+
console.print(f"[cyan]Scanning for duplicates in {folder}...[/cyan]")
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
duplicates = organizer.find_duplicates(folder, recursive=recursive)
|
|
154
|
+
|
|
155
|
+
if not duplicates:
|
|
156
|
+
console.print("[green]No duplicates found![/green]")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
total_dupes = sum(len(v) - 1 for v in duplicates.values())
|
|
160
|
+
console.print(
|
|
161
|
+
f"[yellow]Found {total_dupes} duplicate(s) in {len(duplicates)} group(s):[/yellow]\n"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
for hash_val, paths in duplicates.items():
|
|
165
|
+
console.print("[bold]Duplicate group:[/bold]")
|
|
166
|
+
for p in paths:
|
|
167
|
+
console.print(f" {p}")
|
|
168
|
+
console.print()
|
|
169
|
+
|
|
170
|
+
if delete:
|
|
171
|
+
removed = 0
|
|
172
|
+
for hash_val, paths in duplicates.items():
|
|
173
|
+
keep = paths[0]
|
|
174
|
+
for p in paths[1:]:
|
|
175
|
+
p.unlink()
|
|
176
|
+
removed += 1
|
|
177
|
+
console.print(f"[red]Deleted:[/red] {p}")
|
|
178
|
+
|
|
179
|
+
log_success(f"Removed {removed} duplicate(s). Kept: {keep}")
|
|
180
|
+
else:
|
|
181
|
+
console.print("[dim]Run with --delete to remove duplicates[/dim]")
|
|
182
|
+
|
|
183
|
+
except Exception as e:
|
|
184
|
+
log_error(f"Error finding duplicates: {e}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command("shred")
|
|
188
|
+
def secure_delete(
|
|
189
|
+
target: Path = typer.Argument(..., help="File to securely delete."),
|
|
190
|
+
passes: int = typer.Option(
|
|
191
|
+
3, "--passes", "-p", help="Number of overwrite passes (default 3)."
|
|
192
|
+
),
|
|
193
|
+
force: bool = typer.Option(False, "-f", "--force", help="Skip confirmation."),
|
|
194
|
+
):
|
|
195
|
+
"""
|
|
196
|
+
Securely delete a file by overwriting with random data before deletion.
|
|
197
|
+
"""
|
|
198
|
+
if not target.exists():
|
|
199
|
+
log_error(f"File not found: {target}")
|
|
200
|
+
raise typer.Exit(1)
|
|
201
|
+
|
|
202
|
+
if target.is_dir():
|
|
203
|
+
log_error("Cannot shred directories. Use rm -r instead.")
|
|
204
|
+
raise typer.Exit(1)
|
|
205
|
+
|
|
206
|
+
if not force:
|
|
207
|
+
console.print(f"[red]⚠ This will PERMANENTLY destroy: {target.name}[/red]")
|
|
208
|
+
if not Confirm.ask("Are you sure?"):
|
|
209
|
+
console.print("[yellow]Aborted.[/yellow]")
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
console.print(f"[cyan]Shredding {target.name} ({passes} passes)...[/cyan]")
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
organizer.secure_delete(target, passes=passes)
|
|
216
|
+
log_success(f"File securely deleted: {target.name}")
|
|
217
|
+
except Exception as e:
|
|
218
|
+
log_error(f"Secure delete failed: {e}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@app.command("preview")
|
|
222
|
+
def file_preview(
|
|
223
|
+
target: Path = typer.Argument(..., help="File to preview."),
|
|
224
|
+
lines: int = typer.Option(
|
|
225
|
+
20, "-n", "--lines", help="Number of lines to show for text files."
|
|
226
|
+
),
|
|
227
|
+
):
|
|
228
|
+
"""
|
|
229
|
+
Show file metadata and preview content.
|
|
230
|
+
"""
|
|
231
|
+
from datetime import datetime
|
|
232
|
+
from max_cli.common.utils import format_size
|
|
233
|
+
|
|
234
|
+
if not target.exists():
|
|
235
|
+
log_error(f"File not found: {target}")
|
|
236
|
+
raise typer.Exit(1)
|
|
237
|
+
|
|
238
|
+
stat = target.stat()
|
|
239
|
+
|
|
240
|
+
console.print(Panel(f"[bold cyan]{target.name}[/bold cyan]", border_style="cyan"))
|
|
241
|
+
|
|
242
|
+
console.print(f"[bold]Path:[/bold] {target.absolute()}")
|
|
243
|
+
console.print(f"[bold]Type:[/bold] {target.suffix or 'No extension'}")
|
|
244
|
+
console.print(f"[bold]Size:[/bold] {format_size(stat.st_size)}")
|
|
245
|
+
console.print(f"[bold]Created:[/bold] {datetime.fromtimestamp(stat.st_ctime)}")
|
|
246
|
+
console.print(f"[bold]Modified:[/bold] {datetime.fromtimestamp(stat.st_mtime)}")
|
|
247
|
+
console.print(f"[bold]Accessed:[/bold] {datetime.fromtimestamp(stat.st_atime)}")
|
|
248
|
+
|
|
249
|
+
console.print()
|
|
250
|
+
|
|
251
|
+
text_extensions = {
|
|
252
|
+
".txt",
|
|
253
|
+
".md",
|
|
254
|
+
".py",
|
|
255
|
+
".js",
|
|
256
|
+
".json",
|
|
257
|
+
".yaml",
|
|
258
|
+
".yml",
|
|
259
|
+
".xml",
|
|
260
|
+
".html",
|
|
261
|
+
".css",
|
|
262
|
+
".sh",
|
|
263
|
+
".bat",
|
|
264
|
+
".ps1",
|
|
265
|
+
".ini",
|
|
266
|
+
".cfg",
|
|
267
|
+
".conf",
|
|
268
|
+
".log",
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if target.suffix.lower() in text_extensions:
|
|
272
|
+
try:
|
|
273
|
+
content = target.read_text(encoding="utf-8", errors="ignore")
|
|
274
|
+
preview_lines = content.splitlines()[:lines]
|
|
275
|
+
console.print("[bold]Preview:[/bold]")
|
|
276
|
+
for i, line in enumerate(preview_lines, 1):
|
|
277
|
+
console.print(f"{i:3}: {line}")
|
|
278
|
+
if len(content.splitlines()) > lines:
|
|
279
|
+
console.print(
|
|
280
|
+
f"[dim]... and {len(content.splitlines()) - lines} more lines[/dim]"
|
|
281
|
+
)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
console.print(f"[yellow]Could not read file content: {e}[/yellow]")
|
|
284
|
+
elif target.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}:
|
|
285
|
+
console.print("[bold]Image Dimensions:[/bold] (requires PIL)")
|
|
286
|
+
try:
|
|
287
|
+
from PIL import Image
|
|
288
|
+
|
|
289
|
+
with Image.open(target) as img:
|
|
290
|
+
console.print(f" {img.width} x {img.height} pixels")
|
|
291
|
+
console.print(f" Mode: {img.mode}")
|
|
292
|
+
except Exception:
|
|
293
|
+
console.print(" [dim]Could not read image info[/dim]")
|
|
294
|
+
elif target.suffix.lower() == ".pdf":
|
|
295
|
+
console.print("[bold]PDF Info:[/bold] (requires PyMuPDF)")
|
|
296
|
+
try:
|
|
297
|
+
import fitz
|
|
298
|
+
|
|
299
|
+
doc = fitz.open(target)
|
|
300
|
+
console.print(f" Pages: {len(doc)}")
|
|
301
|
+
console.print(f" Title: {doc.metadata.get('title', 'N/A')}")
|
|
302
|
+
console.print(f" Author: {doc.metadata.get('author', 'N/A')}")
|
|
303
|
+
except Exception:
|
|
304
|
+
console.print(" [dim]Could not read PDF info[/dim]")
|
|
305
|
+
else:
|
|
306
|
+
console.print("[dim]Preview not available for this file type[/dim]")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@app.command("backup")
|
|
310
|
+
def backup_file(
|
|
311
|
+
target: Path = typer.Argument(..., help="File to backup."),
|
|
312
|
+
label: str = typer.Option("manual", "-l", "--label", help="Label for this backup."),
|
|
313
|
+
):
|
|
314
|
+
"""
|
|
315
|
+
Create a backup of a file.
|
|
316
|
+
"""
|
|
317
|
+
if not target.exists():
|
|
318
|
+
log_error(f"File not found: {target}")
|
|
319
|
+
raise typer.Exit(1)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
backup_path = organizer.create_backup(target, label=label)
|
|
323
|
+
log_success(f"Backup created: {backup_path}")
|
|
324
|
+
except Exception as e:
|
|
325
|
+
log_error(f"Backup failed: {e}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@app.command("backups")
|
|
329
|
+
def list_backups(
|
|
330
|
+
filter: str = typer.Option(None, "--filter", "-f", help="Filter by filename."),
|
|
331
|
+
restore: Path = typer.Option(
|
|
332
|
+
None, "--restore", "-r", help="Restore a specific backup."
|
|
333
|
+
),
|
|
334
|
+
output: Path = typer.Option(None, "-o", help="Restore to specific directory."),
|
|
335
|
+
):
|
|
336
|
+
"""
|
|
337
|
+
List and manage backups.
|
|
338
|
+
"""
|
|
339
|
+
from datetime import datetime
|
|
340
|
+
from max_cli.common.utils import format_size
|
|
341
|
+
|
|
342
|
+
if restore:
|
|
343
|
+
try:
|
|
344
|
+
restored = organizer.restore_backup(restore, output)
|
|
345
|
+
log_success(f"Restored: {restored}")
|
|
346
|
+
except Exception as e:
|
|
347
|
+
log_error(f"Restore failed: {e}")
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
backups = organizer.list_backups(filter)
|
|
351
|
+
|
|
352
|
+
if not backups:
|
|
353
|
+
console.print("[yellow]No backups found.[/yellow]")
|
|
354
|
+
return
|
|
355
|
+
|
|
356
|
+
console.print(f"[cyan]Found {len(backups)} backup(s):[/cyan]\n")
|
|
357
|
+
|
|
358
|
+
for b in backups:
|
|
359
|
+
console.print(f"[bold]{b['name']}[/bold]")
|
|
360
|
+
console.print(f" Size: {format_size(b['size'])}")
|
|
361
|
+
console.print(f" Created: {datetime.fromtimestamp(b['created'])}")
|
|
362
|
+
console.print(f" Path: {b['path']}")
|
|
363
|
+
console.print()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@app.command("backup-cleanup")
|
|
367
|
+
def cleanup_backups(
|
|
368
|
+
days: int = typer.Option(
|
|
369
|
+
30, "-d", "--days", help="Remove backups older than N days."
|
|
370
|
+
),
|
|
371
|
+
force: bool = typer.Option(False, "-f", "--force", help="Skip confirmation."),
|
|
372
|
+
):
|
|
373
|
+
"""
|
|
374
|
+
Clean up old backups to save space.
|
|
375
|
+
"""
|
|
376
|
+
if not force:
|
|
377
|
+
console.print(
|
|
378
|
+
f"[yellow]This will remove backups older than {days} days.[/yellow]"
|
|
379
|
+
)
|
|
380
|
+
if not Confirm.ask("Continue?"):
|
|
381
|
+
console.print("[yellow]Aborted.[/yellow]")
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
removed = organizer.cleanup_old_backups(days)
|
|
386
|
+
log_success(f"Removed {removed} old backup(s)")
|
|
387
|
+
except Exception as e:
|
|
388
|
+
log_error(f"Cleanup failed: {e}")
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional, List, Tuple
|
|
4
|
+
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
from rich import box
|
|
7
|
+
|
|
8
|
+
from max_cli.core.image_processor import ImageEngine
|
|
9
|
+
from max_cli.common.logger import console, log_success, log_error
|
|
10
|
+
from max_cli.common.concurrent import process_batch_parallel
|
|
11
|
+
from max_cli.config import settings
|
|
12
|
+
|
|
13
|
+
app = typer.Typer()
|
|
14
|
+
engine = ImageEngine()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve_batch(target: Path) -> Tuple[List[Path], Path]:
|
|
18
|
+
if target.is_file():
|
|
19
|
+
return [target], target.parent
|
|
20
|
+
files = [
|
|
21
|
+
f
|
|
22
|
+
for f in target.iterdir()
|
|
23
|
+
if f.is_file() and f.suffix.lower() in engine.SUPPORTED_EXTENSIONS
|
|
24
|
+
]
|
|
25
|
+
out_dir = target.parent / f"{target.name}_optimized"
|
|
26
|
+
out_dir.mkdir(exist_ok=True)
|
|
27
|
+
return files, out_dir
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("compress")
|
|
31
|
+
def compress_images(
|
|
32
|
+
target: Path = typer.Argument(Path("."), help="File or folder."),
|
|
33
|
+
quality: int = typer.Option(
|
|
34
|
+
settings.DEFAULT_QUALITY, "-q", help="Quality (1-100)."
|
|
35
|
+
),
|
|
36
|
+
scale: Optional[int] = typer.Option(None, "-s", help="Scale percentage."),
|
|
37
|
+
max_dim: Optional[int] = typer.Option(None, "-m", help="Max dimension (px)."),
|
|
38
|
+
force_jpeg: bool = typer.Option(False, "--jpeg", help="Force output to JPEG."),
|
|
39
|
+
quantize: bool = typer.Option(
|
|
40
|
+
False, "--quantize", help="Lossy PNG compression (256 colors)."
|
|
41
|
+
),
|
|
42
|
+
strip: bool = typer.Option(True, "--strip/--keep", help="Remove EXIF metadata."),
|
|
43
|
+
workers: int = typer.Option(
|
|
44
|
+
settings.MAX_WORKERS, "-j", help="Number of parallel workers."
|
|
45
|
+
),
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
All-in-one optimizer. Compress, resize, and convert formats in one go.
|
|
49
|
+
"""
|
|
50
|
+
files, out_dir = _resolve_batch(target)
|
|
51
|
+
|
|
52
|
+
_run_batch(
|
|
53
|
+
files,
|
|
54
|
+
out_dir,
|
|
55
|
+
"Optimizing",
|
|
56
|
+
workers=workers,
|
|
57
|
+
quality=quality,
|
|
58
|
+
scale=scale,
|
|
59
|
+
max_dim=max_dim,
|
|
60
|
+
force_format="jpg" if force_jpeg else None,
|
|
61
|
+
quantize_png=quantize,
|
|
62
|
+
strip_exif=strip,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command("resize")
|
|
67
|
+
def resize_images(
|
|
68
|
+
target: Path = typer.Argument(Path("."), help="File or folder."),
|
|
69
|
+
width: Optional[int] = typer.Option(None, "-w", help="Width in px."),
|
|
70
|
+
height: Optional[int] = typer.Option(None, "-h", help="Height in px."),
|
|
71
|
+
scale: Optional[int] = typer.Option(None, "-s", help="Scale %."),
|
|
72
|
+
workers: int = typer.Option(
|
|
73
|
+
settings.MAX_WORKERS, "-j", help="Number of parallel workers."
|
|
74
|
+
),
|
|
75
|
+
):
|
|
76
|
+
"""Specialized command for adjusting image dimensions."""
|
|
77
|
+
if not any([width, height, scale]):
|
|
78
|
+
log_error("Specify --width, --height, or --scale.")
|
|
79
|
+
return
|
|
80
|
+
files, out_dir = _resolve_batch(target)
|
|
81
|
+
_run_batch(
|
|
82
|
+
files,
|
|
83
|
+
out_dir,
|
|
84
|
+
"Resizing",
|
|
85
|
+
workers=workers,
|
|
86
|
+
width=width,
|
|
87
|
+
height=height,
|
|
88
|
+
scale=scale,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("convert")
|
|
93
|
+
def convert_images(
|
|
94
|
+
target: Path = typer.Argument(Path("."), help="File or folder."),
|
|
95
|
+
to: str = typer.Option(..., help="Target format (webp, jpg, png)."),
|
|
96
|
+
workers: int = typer.Option(
|
|
97
|
+
settings.MAX_WORKERS, "-j", help="Number of parallel workers."
|
|
98
|
+
),
|
|
99
|
+
):
|
|
100
|
+
"""Bulk convert images to a new format."""
|
|
101
|
+
files, out_dir = _resolve_batch(target)
|
|
102
|
+
_run_batch(files, out_dir, "Converting", workers=workers, force_format=to)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("strip")
|
|
106
|
+
def strip_metadata(
|
|
107
|
+
target: Path = typer.Argument(Path("."), help="File or folder."),
|
|
108
|
+
workers: int = typer.Option(
|
|
109
|
+
settings.MAX_WORKERS, "-j", help="Number of parallel workers."
|
|
110
|
+
),
|
|
111
|
+
):
|
|
112
|
+
"""Remove GPS and EXIF data from images for privacy."""
|
|
113
|
+
files, out_dir = _resolve_batch(target)
|
|
114
|
+
_run_batch(files, out_dir, "Stripping", workers=workers, strip_exif=True)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run_batch(
|
|
118
|
+
files: List[Path], out_dir: Path, action: str, workers: int = 4, **kwargs
|
|
119
|
+
):
|
|
120
|
+
def process_file(f: Path) -> dict:
|
|
121
|
+
if len(files) == 1:
|
|
122
|
+
out_path = out_dir / f"{f.stem}_opt{f.suffix}"
|
|
123
|
+
else:
|
|
124
|
+
out_path = out_dir / f.name
|
|
125
|
+
return engine.process_single_image(f, out_path, **kwargs)
|
|
126
|
+
|
|
127
|
+
stats_list: List[dict] = []
|
|
128
|
+
with Progress(
|
|
129
|
+
SpinnerColumn(),
|
|
130
|
+
TextColumn("[progress.description]{task.description}"),
|
|
131
|
+
BarColumn(),
|
|
132
|
+
transient=True,
|
|
133
|
+
) as progress:
|
|
134
|
+
task = progress.add_task(f"[green]{action}...", total=len(files))
|
|
135
|
+
|
|
136
|
+
if workers > 1 and len(files) > 1:
|
|
137
|
+
results = process_batch_parallel(
|
|
138
|
+
files,
|
|
139
|
+
process_file,
|
|
140
|
+
max_workers=workers,
|
|
141
|
+
progress=progress,
|
|
142
|
+
task_id=task,
|
|
143
|
+
)
|
|
144
|
+
for r in results:
|
|
145
|
+
if "error" in r:
|
|
146
|
+
console.print(f"[red]Error {r['item'].name}: {r['error']}[/red]")
|
|
147
|
+
else:
|
|
148
|
+
stats_list.append(r)
|
|
149
|
+
else:
|
|
150
|
+
for f in files:
|
|
151
|
+
try:
|
|
152
|
+
stats = process_file(f)
|
|
153
|
+
stats_list.append(stats)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
console.print(f"[red]Error {f.name}: {e}[/red]")
|
|
156
|
+
progress.advance(task)
|
|
157
|
+
|
|
158
|
+
if not stats_list:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
table = Table(title=f"{action} Summary", box=box.ROUNDED)
|
|
162
|
+
table.add_column("File", style="cyan")
|
|
163
|
+
table.add_column("Original", justify="right")
|
|
164
|
+
table.add_column("Final", justify="right", style="green")
|
|
165
|
+
table.add_column("Saved", justify="right", style="bold yellow")
|
|
166
|
+
|
|
167
|
+
for s in stats_list[:15]:
|
|
168
|
+
table.add_row(
|
|
169
|
+
s["file_name"],
|
|
170
|
+
s["original_size"],
|
|
171
|
+
s["final_size"],
|
|
172
|
+
f"{s['reduction_pct']}%",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
console.print(table)
|
|
176
|
+
log_success(f"Output saved to: {out_dir}")
|