morphconv 1.0.1__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.
morph/cli.py ADDED
@@ -0,0 +1,703 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ morph — convert anything to anything, from the CLI.
4
+
5
+ morph report.docx report.pdf
6
+ morph data.csv data.xlsx --table-style TableStyleMedium2 --header-bg 2E7D32
7
+ morph data.csv data.xlsx --help (shows flags for THIS pair only)
8
+ morph batch '*.mp4' mp3 --workers 4
9
+ morph formats docx
10
+ morph history
11
+ morph deps
12
+ morph (no args -> launches the interactive TUI)
13
+
14
+ There is no "convert" subcommand — morph already means convert. Anything
15
+ that isn't a recognized subcommand (formats, deps, batch, history) is
16
+ treated as a conversion job and routed through the engine.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import sys
23
+ import tempfile
24
+ import time
25
+ from pathlib import Path
26
+ from typing import List, Optional
27
+ import yaml
28
+
29
+ # Ensure Unicode output works on Windows regardless of terminal encoding
30
+ if hasattr(sys.stdout, "reconfigure"):
31
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
32
+ if hasattr(sys.stderr, "reconfigure"):
33
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
34
+
35
+ import typer
36
+ from rich import box
37
+ from rich.console import Console
38
+ from rich.panel import Panel
39
+ from rich.table import Table as RichTable
40
+ from rich.theme import Theme
41
+ from rich.tree import Tree
42
+ from typer.core import TyperGroup
43
+
44
+ from . import converters # noqa: F401 (auto-discovers & registers every converter)
45
+ from . import deps
46
+ from .progress import run_hop
47
+ from .registry import detect_format, registry
48
+
49
+ THEME = Theme({
50
+ "info": "bold cyan", "success": "bold green", "warning": "bold yellow",
51
+ "error": "bold red", "muted": "dim white", "accent": "bold blue",
52
+ })
53
+ console = Console(theme=THEME)
54
+ err_console = Console(stderr=True, theme=THEME)
55
+
56
+ # Family display order and labels
57
+ _FAMILY_ORDER = ["document", "audio", "video", "image", "data", "archive", "font", "ebook"]
58
+ _FAMILY_LABELS = {
59
+ "document": "document",
60
+ "audio": "audio",
61
+ "video": "video",
62
+ "image": "image",
63
+ "data": "data",
64
+ "archive": "archive",
65
+ "font": "font",
66
+ "ebook": "ebook",
67
+ }
68
+ _FAMILY_ICONS = {
69
+ "document": "[cyan]*[/cyan]",
70
+ "audio": "[green]*[/green]",
71
+ "video": "[magenta]*[/magenta]",
72
+ "image": "[yellow]*[/yellow]",
73
+ "data": "[blue]*[/blue]",
74
+ "archive": "[red]*[/red]",
75
+ "font": "[cyan]*[/cyan]",
76
+ "ebook": "[green]*[/green]",
77
+ }
78
+
79
+
80
+ class MorphGroup(TyperGroup):
81
+ """Makes `morph <file> <file> [flags]` work without a literal 'convert'
82
+ subcommand: if the first token isn't a known subcommand name, it's
83
+ forwarded to the hidden `run` command, which does the actual routing."""
84
+
85
+ def resolve_command(self, ctx, args):
86
+ # We explicitly allow "config" alongside whatever is in self.commands
87
+ # just in case Typer hasn't fully populated the commands dict yet.
88
+ known_commands = list(self.commands.keys()) + ["config", "init"]
89
+ if args and args[0] not in known_commands and not args[0].startswith("-"):
90
+ args = ["run", *args]
91
+ return super().resolve_command(ctx, args)
92
+
93
+ def parse_args(self, ctx, args):
94
+ if "--version" in args or "-v" in args:
95
+ import importlib.metadata
96
+ from rich import print
97
+ try:
98
+ version = importlib.metadata.version("morphcli")
99
+ except importlib.metadata.PackageNotFoundError:
100
+ version = "unknown"
101
+ print(f"[bold cyan]morph[/bold cyan] version [green]{version}[/green]")
102
+ import sys
103
+ sys.exit(0)
104
+ return super().parse_args(ctx, args)
105
+
106
+
107
+ app = typer.Typer(
108
+ name="morph",
109
+ cls=MorphGroup,
110
+ help="[bold cyan]morph[/bold cyan] — convert anything to anything, from the CLI.\n\n[bold]Global Options:[/bold]\n [cyan]-v, --version[/cyan] Show the application's version and exit.",
111
+ rich_markup_mode="rich",
112
+ invoke_without_command=True,
113
+ context_settings={"help_option_names": ["-h", "--help"]},
114
+ pretty_exceptions_show_locals=False,
115
+ )
116
+
117
+
118
+ def _fmt_of(path: str | Path) -> str:
119
+ path_str = str(path).lower()
120
+ if path_str.startswith("http://") or path_str.startswith("https://"):
121
+ import urllib.parse
122
+ parsed = urllib.parse.urlparse(path_str)
123
+ ext = Path(parsed.path).suffix.lstrip(".")
124
+ if ext and ext in registry.all_formats():
125
+ return ext
126
+ return "url"
127
+ if isinstance(path, str):
128
+ path = Path(path)
129
+ return detect_format(path)
130
+
131
+
132
+ def _print_route_help(input_file: str | Path, output_file: Path, path) -> None:
133
+ src, dst = _fmt_of(input_file), _fmt_of(output_file)
134
+ hop_str = " → ".join([src] + [s.dst for s in path])
135
+ console.print(Panel.fit(f"[bold]{hop_str}[/bold]", title="morph — conversion route", border_style="cyan"))
136
+
137
+ combined = registry.combined_options(path)
138
+ console.print(f"\n[bold]Usage:[/bold] morph {input_file} {output_file} [OPTIONS]\n")
139
+ console.print("[bold]Global options:[/bold]")
140
+ console.print(" -y, --yes Auto-confirm dependency installs")
141
+ console.print(" --dry-run Show the plan without running it")
142
+ console.print(" -q, --quiet Suppress banners and tables")
143
+
144
+ if combined:
145
+ console.print(f"\n[bold]Options for this conversion ({hop_str}):[/bold]")
146
+ tbl = RichTable(box=box.SIMPLE, show_header=False, border_style="dim", pad_edge=False)
147
+ tbl.add_column(style="accent", no_wrap=True)
148
+ tbl.add_column(style="white")
149
+ for opt in combined:
150
+ flag_str = ", ".join(opt.flags)
151
+ default_str = f" [muted](default: {opt.default})[/muted]" if opt.default not in (None, False) else ""
152
+ tbl.add_row(flag_str, f"{opt.help}{default_str}")
153
+ console.print(tbl)
154
+ else:
155
+ console.print(f"\n[muted]No extra options for {hop_str} — just run it.[/muted]")
156
+
157
+
158
+ def _build_parser(options) -> argparse.ArgumentParser:
159
+ parser = argparse.ArgumentParser(add_help=False)
160
+ for opt in options:
161
+ kwargs = {"dest": opt.name, "default": opt.default, "help": opt.help}
162
+ if opt.action:
163
+ kwargs["action"] = opt.action
164
+ else:
165
+ kwargs["type"] = opt.type
166
+ parser.add_argument(*opt.flags, **kwargs)
167
+ return parser
168
+
169
+
170
+ def get_config_path() -> Path:
171
+ return Path.home() / ".morphrc"
172
+
173
+ def load_config() -> dict:
174
+ config_path = get_config_path()
175
+ if not config_path.exists():
176
+ return {}
177
+ try:
178
+ with open(config_path, "r", encoding="utf-8") as f:
179
+ data = yaml.safe_load(f)
180
+ if not isinstance(data, dict):
181
+ return {}
182
+
183
+ flat_config = {}
184
+ for k, v in data.items():
185
+ if isinstance(v, dict):
186
+ flat_config.update(v)
187
+ else:
188
+ flat_config[k] = v
189
+ return flat_config
190
+ except Exception as e:
191
+ err_console.print(f"[warning]⚠ Failed to load {config_path}: {e}[/warning]")
192
+ return {}
193
+
194
+
195
+ @app.command("config")
196
+ def init_config():
197
+ """Generate a fully populated ~/.morphrc with best default values."""
198
+ config_path = get_config_path()
199
+ if config_path.exists():
200
+ console.print(f"[warning]⚠ {config_path} already exists. Overwriting is not supported.[/warning]")
201
+ raise typer.Exit(1)
202
+
203
+ options_by_family = registry.all_options()
204
+
205
+ yaml_lines = [
206
+ "# Morph Configuration File",
207
+ "# ------------------------",
208
+ "# This file provides default options for all conversions.",
209
+ "# Command line flags will always override these settings.",
210
+ ""
211
+ ]
212
+
213
+ yaml_lines.extend([
214
+ "global:",
215
+ " # Use headless browser to render JavaScript on URLs.",
216
+ " # js: false",
217
+ " # Auto-confirm dependency installs.",
218
+ " # yes: false",
219
+ ""
220
+ ])
221
+
222
+ for family, options in sorted(options_by_family.items()):
223
+ if not options:
224
+ continue
225
+ yaml_lines.append(f"{family}:")
226
+ for opt in sorted(options, key=lambda x: x.name):
227
+ if opt.help:
228
+ yaml_lines.append(f" # {opt.help}")
229
+
230
+ default_val = opt.default
231
+ if default_val is None:
232
+ yaml_val = "null"
233
+ elif isinstance(default_val, bool):
234
+ yaml_val = str(default_val).lower()
235
+ elif isinstance(default_val, str):
236
+ yaml_val = repr(default_val)
237
+ else:
238
+ yaml_val = str(default_val)
239
+
240
+ yaml_lines.append(f" # {opt.name}: {yaml_val}")
241
+ yaml_lines.append("")
242
+
243
+ config_path.write_text("\\n".join(yaml_lines), encoding="utf-8")
244
+ console.print(f"[success]✓ Generated full configuration file at {config_path}[/success]")
245
+
246
+
247
+ # ── run (hidden, handles direct file→file conversion) ────────────────────────
248
+
249
+ @app.command("run", hidden=True, context_settings={"ignore_unknown_options": True, "allow_extra_args": True, "help_option_names": []})
250
+ def run_cmd(
251
+ ctx: typer.Context,
252
+ input_file: str = typer.Argument(..., help="Source file or URL (http/https)."),
253
+ output_file: Path = typer.Argument(..., help="Destination file (extension picks the target format)."),
254
+ js: bool = typer.Option(False, "--js", help="Use headless browser to render JavaScript on URLs."),
255
+ yes: bool = typer.Option(False, "-y", "--yes", help="Auto-confirm dependency installs."),
256
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show the conversion plan without running it."),
257
+ quiet: bool = typer.Option(False, "-q", "--quiet", help="Suppress banners and tables."),
258
+ ) -> None:
259
+ from . import history as hist
260
+
261
+ if not quiet:
262
+ console.print("\n[bold cyan]⚡ MORPH[/bold cyan] [dim]— Convert anything to anything.[/dim]\n")
263
+
264
+ src, dst = _fmt_of(input_file), _fmt_of(output_file)
265
+ path = registry.find_path(src, dst)
266
+
267
+ if path is None:
268
+ err_console.print(f"[error]✗ No conversion path found from[/error] .{src} [error]to[/error] .{dst}")
269
+ reachable = sorted(registry.reachable_targets(src).keys())
270
+ if reachable:
271
+ err_console.print(f"[muted]From .{src} morph can currently reach:[/muted] {', '.join(reachable)}")
272
+ raise typer.Exit(1)
273
+
274
+ if "--help" in ctx.args or "-h" in ctx.args:
275
+ _print_route_help(input_file, output_file, path)
276
+ raise typer.Exit(0)
277
+
278
+ combined_opts = registry.combined_options(path)
279
+ parser = _build_parser(combined_opts)
280
+
281
+ # Merge ~/.morphrc values as defaults before parsing CLI arguments
282
+ parser.set_defaults(**load_config())
283
+
284
+ parsed, unknown = parser.parse_known_args(ctx.args)
285
+ if unknown:
286
+ err_console.print(f"[warning]⚠ Ignoring unrecognized option(s):[/warning] {' '.join(unknown)}")
287
+ options_dict = vars(parsed)
288
+ options_dict["js"] = js
289
+
290
+ if js and path and path[0].src == "url":
291
+ import dataclasses
292
+ path[0] = dataclasses.replace(path[0], backend="crawl4ai")
293
+
294
+ is_webpage_scrape = src == "url"
295
+ is_remote_file = str(input_file).lower().startswith("http") and not is_webpage_scrape
296
+
297
+ temp_download = None
298
+ if is_remote_file:
299
+ import urllib.request
300
+ from rich.progress import Progress
301
+
302
+ err_console.print(f"[info]↓ Downloading remote file...[/info]")
303
+ temp_download = tempfile.NamedTemporaryFile(suffix="." + src, delete=False)
304
+ input_path = Path(temp_download.name)
305
+
306
+ try:
307
+ with Progress(console=err_console, transient=True) as progress:
308
+ task = progress.add_task("[cyan]Downloading...", total=None)
309
+
310
+ def report(blocknum, blocksize, totalsize):
311
+ if totalsize > 0 and progress.tasks[task].total is None:
312
+ progress.update(task, total=totalsize)
313
+ progress.update(task, advance=blocksize)
314
+
315
+ urllib.request.urlretrieve(input_file, temp_download.name, reporthook=report)
316
+ except Exception as e:
317
+ err_console.print(f"[error]✗ Download failed:[/error] {e}")
318
+ temp_download.close()
319
+ Path(temp_download.name).unlink(missing_ok=True)
320
+ raise typer.Exit(1)
321
+ else:
322
+ input_path = input_file if is_webpage_scrape else Path(input_file)
323
+
324
+ if not is_webpage_scrape and not is_remote_file and not input_path.exists():
325
+ err_console.print(f"[error]✗ File not found:[/error] {input_file}")
326
+ raise typer.Exit(1)
327
+
328
+ hop_str = " → ".join([src] + [s.dst for s in path])
329
+ display_name = input_file if (is_webpage_scrape or is_remote_file) else input_path.name
330
+ if not quiet:
331
+ console.print(Panel.fit(
332
+ f"[bold]{display_name}[/bold] → [bold]{output_file.name}[/bold]\n"
333
+ f"[muted]route:[/muted] {hop_str}"
334
+ + (" [warning](lossy step involved)[/warning]" if any(s.lossy for s in path) else ""),
335
+ title="morph", border_style="cyan",
336
+ ))
337
+
338
+ if dry_run:
339
+ for i, spec in enumerate(path, 1):
340
+ dep = f" [muted](requires {spec.requires_binary})[/muted]" if spec.requires_binary else ""
341
+ console.print(f" {i}. {spec.src} → {spec.dst} [muted]via {spec.backend}[/muted]{dep}")
342
+ raise typer.Exit(0)
343
+
344
+ needed = registry.required_binaries(path)
345
+ if needed and not deps.ensure_all(needed, console, assume_yes=yes):
346
+ err_console.print("[error]✗ Required dependency not available — aborting.[/error]")
347
+ raise typer.Exit(1)
348
+
349
+ current_input = input_path
350
+ result = None
351
+ t_start = time.perf_counter()
352
+ success = False
353
+ error_msg: Optional[str] = None
354
+
355
+ try:
356
+ with tempfile.TemporaryDirectory() as tmpdir:
357
+ for i, spec in enumerate(path):
358
+ is_last = i == len(path) - 1
359
+ hop_out = output_file if is_last else Path(tmpdir) / f"hop{i}.{spec.dst}"
360
+ hop_options = {opt.name: options_dict[opt.name] for opt in spec.options}
361
+ result = run_hop(spec, current_input, hop_out, hop_options, console, quiet=quiet)
362
+ current_input = result.output
363
+ success = True
364
+ except Exception as exc:
365
+ error_msg = str(exc)
366
+ err_console.print(f"\n[error]✗ Conversion failed at step '{spec.src} → {spec.dst}':[/error] {exc}")
367
+ raise typer.Exit(1)
368
+ finally:
369
+ elapsed = time.perf_counter() - t_start
370
+ backends = ", ".join(dict.fromkeys(s.backend for s in path))
371
+ entry = hist.make_entry(
372
+ input_path, src, output_file, dst,
373
+ hop_str, backends, success, elapsed,
374
+ error=error_msg,
375
+ )
376
+ hist.append_entry(entry)
377
+
378
+ if temp_download is not None:
379
+ temp_download.close()
380
+ Path(temp_download.name).unlink(missing_ok=True)
381
+ if not quiet and result is not None:
382
+ extras = []
383
+ if result.rows is not None:
384
+ extras.append(f"{result.rows:,} rows")
385
+ if result.pages is not None:
386
+ extras.append(f"{result.pages} pages")
387
+ extra_str = f" ({', '.join(extras)})" if extras else ""
388
+ console.print(f"[success]✓ Done![/success] → [accent]{output_file}[/accent]{extra_str}")
389
+ console.print("\n[dim]Built with 💖 by [link=https://hariharen.site]Hariharen[/link][/dim]\n")
390
+
391
+
392
+ # ── batch ─────────────────────────────────────────────────────────────────────
393
+
394
+ @app.command(
395
+ "batch",
396
+ help="Convert multiple files in parallel. Last argument is the target format.",
397
+ context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
398
+ )
399
+ def batch_cmd(
400
+ ctx: typer.Context,
401
+ inputs: List[str] = typer.Argument(..., help="Glob patterns or directories. Last entry is the target format."),
402
+ out_dir: Optional[Path] = typer.Option(None, "--out-dir", "-o", help="Output directory (default: alongside input)."),
403
+ mirror: bool = typer.Option(False, "--mirror", help="Mirror input directory structure inside --out-dir."),
404
+ rename: Optional[str] = typer.Option(None, "--rename", help="Filename template, e.g. '{stem}_audio'. Variables: {stem} {fmt} {src_fmt}."),
405
+ workers: int = typer.Option(4, "-w", "--workers", help="Parallel worker threads.", min=1, max=32),
406
+ recursive: bool = typer.Option(False, "-r", "--recursive", help="Walk subdirectories."),
407
+ skip_existing: bool = typer.Option(False, "--skip-existing", help="Skip files whose output already exists."),
408
+ newer_only: bool = typer.Option(False, "--newer-only", help="Skip files whose output is already newer than the input."),
409
+ fail_fast: bool = typer.Option(False, "--fail-fast", help="Abort on first failure."),
410
+ error_log: Optional[Path] = typer.Option(None, "--error-log", help="Write failed paths to this file."),
411
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be converted without running."),
412
+ yes: bool = typer.Option(False, "-y", "--yes", help="Auto-confirm dependency installs."),
413
+ quiet: bool = typer.Option(False, "-q", "--quiet", help="No live display — just print summary."),
414
+ ) -> None:
415
+ from .batch import collect_inputs, run_batch
416
+
417
+ if not quiet:
418
+ console.print("\n[bold cyan]⚡ MORPH[/bold cyan] [dim]— Convert anything to anything.[/dim]\n")
419
+
420
+ if len(inputs) < 2:
421
+ err_console.print("[error]✗ Provide at least one input pattern and a target format.[/error]")
422
+ err_console.print(" [muted]Example: morph batch '*.mp4' mp3[/muted]")
423
+ raise typer.Exit(1)
424
+
425
+ # Last argument is always the target format
426
+ *patterns, target_fmt = inputs
427
+ target_fmt = target_fmt.lower().lstrip(".")
428
+
429
+ # Validate target format is known
430
+ if not registry.all_formats() or target_fmt not in registry.all_formats():
431
+ err_console.print(f"[error]✗ Unknown target format:[/error] [bold]{target_fmt}[/bold]")
432
+ known = sorted(registry.all_formats())
433
+ err_console.print(f"[muted]Known formats: {', '.join(known)}[/muted]")
434
+ raise typer.Exit(1)
435
+
436
+ # Determine base_dir for --mirror (use common parent of patterns if dir)
437
+ base_dir: Optional[Path] = None
438
+ if mirror and out_dir:
439
+ candidate = Path(patterns[0])
440
+ if candidate.is_dir():
441
+ base_dir = candidate.resolve()
442
+
443
+ # Collect jobs
444
+ jobs = collect_inputs(
445
+ patterns, target_fmt,
446
+ recursive=recursive,
447
+ out_dir=out_dir,
448
+ mirror=mirror,
449
+ rename_template=rename,
450
+ skip_existing=skip_existing,
451
+ newer_only=newer_only,
452
+ base_dir=base_dir,
453
+ )
454
+
455
+ if not jobs:
456
+ console.print("[warning]No matching files found.[/warning]")
457
+ raise typer.Exit(0)
458
+
459
+ # Gather any extra options from ctx.args (passthrough to converters)
460
+ # Parse known converter options for the target format's routes
461
+ from .batch import JobState
462
+ convertible_jobs = [j for j in jobs if j.state == JobState.WAITING and j.conv_path]
463
+ options_dict: dict = {}
464
+ if convertible_jobs:
465
+ sample_path = convertible_jobs[0].conv_path
466
+ combined_opts = registry.combined_options(sample_path)
467
+ if combined_opts and ctx.args:
468
+ parser = _build_parser(combined_opts)
469
+ parsed, _ = parser.parse_known_args(ctx.args)
470
+ options_dict = vars(parsed)
471
+
472
+ if dry_run:
473
+ console.print(Panel.fit(
474
+ f"[bold]morph batch — dry run[/bold]\n"
475
+ f"Target: [cyan]{target_fmt}[/cyan] Workers: {workers}",
476
+ border_style="cyan",
477
+ ))
478
+ from .batch import JobState, _SYMBOLS
479
+ for j in jobs:
480
+ sym = _SYMBOLS.get(j.state, "·")
481
+ if j.state == JobState.WAITING:
482
+ route = " → ".join([j.src_fmt] + [s.dst for s in j.conv_path])
483
+ console.print(f" {sym} [white]{j.input_path}[/white] [dim]→ {j.output_path.name} ({route})[/dim]")
484
+ else:
485
+ console.print(f" {sym} [dim]{j.input_path} ({j.error})[/dim]")
486
+ raise typer.Exit(0)
487
+
488
+ result = run_batch(
489
+ jobs, target_fmt, options_dict, console,
490
+ workers=workers,
491
+ fail_fast=fail_fast,
492
+ error_log=error_log,
493
+ quiet=quiet,
494
+ )
495
+
496
+ console.print("\n[dim]Built with 💖 by [link=https://hariharen.site]Hariharen[/link][/dim]\n")
497
+ raise typer.Exit(0 if result.failed == 0 else 1)
498
+
499
+
500
+ # ── formats ───────────────────────────────────────────────────────────────────
501
+
502
+ @app.command("formats", help="List formats morph can convert, optionally filtered by source format.")
503
+ def formats_cmd(
504
+ source: Optional[str] = typer.Argument(None, help="e.g. 'docx' to see everything reachable from docx."),
505
+ ) -> None:
506
+ if source:
507
+ # Per-format table (existing behaviour — already good)
508
+ targets = registry.reachable_targets(source)
509
+ if not targets:
510
+ console.print(f"[warning]No known conversions from .{source}[/warning]")
511
+ raise typer.Exit(0)
512
+ tbl = RichTable(box=box.ROUNDED, border_style="cyan")
513
+ tbl.add_column("Target", style="bold white")
514
+ tbl.add_column("Route", style="muted")
515
+ tbl.add_column("Hops", justify="right", style="green")
516
+ for dst, hop_path in sorted(targets.items()):
517
+ hop_str = " → ".join([source] + [s.dst for s in hop_path])
518
+ tbl.add_row(dst, hop_str, str(len(hop_path)))
519
+ console.print(tbl)
520
+ return
521
+
522
+ # Tree view grouped by family
523
+ all_formats = registry.all_formats()
524
+ by_family = registry.formats_by_family()
525
+ by_edges = registry.edges_by_family()
526
+ backends = registry.family_backends()
527
+
528
+ total_fmts = len(all_formats)
529
+ total_families = len(by_family)
530
+
531
+ console.print()
532
+ console.print(f" [bold cyan]morph[/bold cyan] — [bold]{total_fmts}[/bold] formats across [bold]{total_families}[/bold] families\n")
533
+
534
+ # Render each family in defined order, then any unknown ones
535
+ order = [f for f in _FAMILY_ORDER if f in by_family]
536
+ extras = [f for f in sorted(by_family) if f not in order]
537
+
538
+ for family in order + extras:
539
+ fmts = sorted(by_family[family])
540
+ edges = by_edges.get(family, [])
541
+ be = sorted(backends.get(family, set()))
542
+ icon = _FAMILY_ICONS.get(family, "▸")
543
+ label = _FAMILY_LABELS.get(family, family)
544
+ be_str = f"via {', '.join(be)}" if be else ""
545
+
546
+ tree = Tree(
547
+ f"{icon} [bold]{label}[/bold] [dim]({len(fmts)} formats"
548
+ + (f", {be_str}" if be_str else "")
549
+ + f", {len(edges)} direct routes)[/dim]",
550
+ guide_style="dim cyan",
551
+ )
552
+
553
+ # Per-format: show top direct targets
554
+ src_targets: dict[str, list[str]] = {}
555
+ for spec in edges:
556
+ src_targets.setdefault(spec.src, []).append(spec.dst)
557
+
558
+ fmt_lines = []
559
+ for fmt in fmts:
560
+ targets_for = sorted(src_targets.get(fmt, []))
561
+ if targets_for:
562
+ t_str = " [dim]→ " + " ".join(targets_for) + "[/dim]"
563
+ else:
564
+ t_str = ""
565
+ fmt_lines.append(f"[bold white]{fmt}[/bold white]{t_str}")
566
+
567
+ for line in fmt_lines:
568
+ tree.add(line)
569
+
570
+ console.print(tree)
571
+
572
+ console.print()
573
+ console.print(" [muted]Run [bold]morph formats <format>[/bold] to see all reachable targets from any format.[/muted]")
574
+ console.print()
575
+
576
+
577
+ # ── history ───────────────────────────────────────────────────────────────────
578
+
579
+ @app.command("history", help="Show recent conversion history.")
580
+ def history_cmd(
581
+ n: int = typer.Option(20, "-n", help="Number of entries to show."),
582
+ failed: bool = typer.Option(False, "--failed", help="Show only failed conversions."),
583
+ fmt: Optional[str] = typer.Option(None, "--fmt", help="Filter by source or target format (e.g. mp4)."),
584
+ clear: bool = typer.Option(False, "--clear", help="Delete the history file."),
585
+ json_out: bool = typer.Option(False, "--json", help="Print raw JSON Lines."),
586
+ ) -> None:
587
+ from . import history as hist
588
+ import json
589
+
590
+ if clear:
591
+ if typer.confirm("Delete all conversion history?"):
592
+ existed = hist.clear()
593
+ console.print("[success]✓ History cleared.[/success]" if existed else "[muted]History was already empty.[/muted]")
594
+ return
595
+
596
+ entries = hist.read_entries(limit=n, failed_only=failed, fmt_filter=fmt)
597
+
598
+ if not entries:
599
+ console.print("[muted]No history entries found.[/muted]")
600
+ if not hist.HISTORY_FILE.exists():
601
+ console.print(f"[muted]History file: {hist.HISTORY_FILE}[/muted]")
602
+ return
603
+
604
+ if json_out:
605
+ from dataclasses import asdict
606
+ for e in entries:
607
+ console.print(json.dumps(asdict(e), ensure_ascii=False))
608
+ return
609
+
610
+ # Pretty table
611
+ from datetime import datetime
612
+
613
+ def _fmt_time(ts: str) -> str:
614
+ try:
615
+ dt = datetime.fromisoformat(ts)
616
+ now = datetime.now()
617
+ diff = (now - dt).total_seconds()
618
+ if diff < 3600:
619
+ return f"{int(diff // 60)}m ago"
620
+ if diff < 86400:
621
+ return f"{int(diff // 3600)}h ago"
622
+ if diff < 172800:
623
+ return "yesterday"
624
+ return dt.strftime("%b %d")
625
+ except Exception:
626
+ return ts[:16]
627
+
628
+ tbl = RichTable(box=box.ROUNDED, border_style="cyan", show_header=True)
629
+ tbl.add_column("When", style="dim", width=11, no_wrap=True)
630
+ tbl.add_column("From", style="bold white", width=7, no_wrap=True)
631
+ tbl.add_column("To", style="bold cyan", width=7, no_wrap=True)
632
+ tbl.add_column("File", style="white", ratio=1, no_wrap=True)
633
+ tbl.add_column("Mode", style="dim", width=7, no_wrap=True)
634
+ tbl.add_column("Status", width=14, no_wrap=True)
635
+
636
+ for e in reversed(entries):
637
+ name = Path(e.src_path).name
638
+ if len(name) > 40:
639
+ name = name[:37] + "..."
640
+
641
+ if e.success:
642
+ status = f"[green]✓ {e.elapsed_s:.1f}s[/green]"
643
+ else:
644
+ err_short = (e.error or "error")[:20]
645
+ status = f"[red]✗ {err_short}[/red]"
646
+
647
+ tbl.add_row(
648
+ _fmt_time(e.ts),
649
+ e.src_fmt, e.dst_fmt,
650
+ name,
651
+ e.mode,
652
+ status,
653
+ )
654
+
655
+ title_parts = [f"last {len(entries)}"]
656
+ if failed:
657
+ title_parts.append("failures")
658
+ if fmt:
659
+ title_parts.append(f"format={fmt}")
660
+ title = "morph history — " + ", ".join(title_parts)
661
+
662
+ console.print()
663
+ console.print(tbl)
664
+
665
+ total = len(entries)
666
+ ok = sum(1 for e in entries if e.success)
667
+ bad = total - ok
668
+ console.print(
669
+ f" [dim]{total} shown "
670
+ f"[green]{ok} successful[/green] "
671
+ + (f"[red]{bad} failed[/red]" if bad else "[dim]0 failed[/dim]")
672
+ + f" history: {hist.HISTORY_FILE}[/dim]"
673
+ )
674
+ console.print()
675
+
676
+
677
+ # ── deps ──────────────────────────────────────────────────────────────────────
678
+
679
+ @app.command("deps", help="Check status of external tools morph relies on.")
680
+ def deps_cmd() -> None:
681
+ binaries = sorted(registry.known_binaries())
682
+ tbl = RichTable(box=box.ROUNDED, border_style="cyan")
683
+ tbl.add_column("Tool", style="bold white")
684
+ tbl.add_column("Status")
685
+ tbl.add_column("Install command", style="muted")
686
+ for b in binaries:
687
+ status = deps.check(b)
688
+ state = "[success]✓ installed[/success]" if status.installed else "[error]✗ missing[/error]"
689
+ tbl.add_row(b, state, status.install_cmd or "[muted]no package manager detected[/muted]")
690
+ console.print(tbl)
691
+
692
+
693
+ # ── entry point ───────────────────────────────────────────────────────────────
694
+
695
+ @app.callback(invoke_without_command=True)
696
+ def main(ctx: typer.Context) -> None:
697
+ if ctx.invoked_subcommand is None:
698
+ from .tui import run_tui
699
+ run_tui()
700
+
701
+
702
+ if __name__ == "__main__":
703
+ app()