vflow-cli 0.1.2__tar.gz → 0.1.4__tar.gz

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.
Files changed (31) hide show
  1. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/PKG-INFO +1 -1
  2. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/pyproject.toml +1 -1
  3. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/config.py +4 -0
  4. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/main.py +51 -147
  5. vflow_cli-0.1.4/src/vflow/utils_date.py +104 -0
  6. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/PKG-INFO +1 -1
  7. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/SOURCES.txt +1 -0
  8. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/README.md +0 -0
  9. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/setup.cfg +0 -0
  10. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/__init__.py +0 -0
  11. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/actions.py +0 -0
  12. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/backup_service.py +0 -0
  13. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/core/__init__.py +0 -0
  14. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/core/date_utils.py +0 -0
  15. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/core/fs_ops.py +0 -0
  16. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/core/media_ops.py +0 -0
  17. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/core/patterns.py +0 -0
  18. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/delivery_service.py +0 -0
  19. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow/ingest_service.py +0 -0
  20. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/dependency_links.txt +0 -0
  21. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/entry_points.txt +0 -0
  22. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/requires.txt +0 -0
  23. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/src/vflow_cli.egg-info/top_level.txt +0 -0
  24. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_braw_and_prores_extensions.py +0 -0
  25. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_backup_and_verify.py +0 -0
  26. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_ingest_and_pull_filters.py +0 -0
  27. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_ingest_report.py +0 -0
  28. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_list_backups.py +0 -0
  29. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_restore_folder.py +0 -0
  30. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_cli_verify_backup_mirror.py +0 -0
  31. {vflow_cli-0.1.2 → vflow_cli-0.1.4}/tests/test_patterns_and_duplicates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vflow-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: The v-flow CLI and Claude skills bundle for automating media backup and processing workflows for videographers.
5
5
  Author-email: Kaung <kaungzinye@gmail.com>
6
6
  Requires-Python: >=3.8
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "vflow-cli"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "The v-flow CLI and Claude skills bundle for automating media backup and processing workflows for videographers."
5
5
  authors = [{ name = "Kaung", email = "kaungzinye@gmail.com" }]
6
6
  readme = "README.md"
@@ -42,3 +42,7 @@ def get_location(config: dict, name: str) -> Path:
42
42
  def get_setting(config: dict, key: str, default: any = None) -> any:
43
43
  """Gets a setting from the config, returning default if not found."""
44
44
  return config.get("settings", {}).get(key, default)
45
+
46
+ def save_config(config: dict) -> None:
47
+ with open(CONFIG_PATH, "w") as f:
48
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False, allow_unicode=True)
@@ -394,6 +394,56 @@ def copy_meta(
394
394
 
395
395
 
396
396
 
397
+ LOCATION_KEYS = {"laptop", "work_ssd", "archive_hdd"}
398
+
399
+ @app.command()
400
+ def locations():
401
+ """Show configured v-flow paths and whether they are currently mounted."""
402
+ app_config = config.load_config()
403
+ locs = app_config.get("locations", {})
404
+ settings = app_config.get("settings", {})
405
+ typer.echo("Locations:")
406
+ for key, path in locs.items():
407
+ mounted = "✓" if Path(path).exists() else "✗ not mounted"
408
+ typer.echo(f" {key}: {path} [{mounted}]")
409
+ if settings:
410
+ typer.echo("Settings:")
411
+ for key, val in settings.items():
412
+ typer.echo(f" {key}: {val}")
413
+
414
+
415
+ @app.command("set")
416
+ def set_config(
417
+ key: str = typer.Argument(help="Config key to update. Location keys: laptop, work_ssd, archive_hdd. Settings: settings.<key> (e.g. settings.default_split_gap)."),
418
+ value: str = typer.Argument(help="New value for the key."),
419
+ ):
420
+ """Update a single config value without re-running setup.
421
+
422
+ Examples:
423
+ v-flow set archive_hdd "/Volumes/Kaung HDD/MediaArchive"
424
+ v-flow set laptop "/Users/me/Desktop/Ingest"
425
+ v-flow set settings.default_split_gap 24
426
+ """
427
+ app_config = config.load_config()
428
+
429
+ if key in LOCATION_KEYS:
430
+ app_config.setdefault("locations", {})[key] = value
431
+ typer.echo(f"Set locations.{key} = {value}")
432
+ elif key.startswith("settings."):
433
+ setting_key = key[len("settings."):]
434
+ app_config.setdefault("settings", {})[setting_key] = value
435
+ typer.echo(f"Set settings.{setting_key} = {value}")
436
+ else:
437
+ typer.echo(
438
+ f"Unknown key '{key}'. Use one of: {', '.join(sorted(LOCATION_KEYS))}, or settings.<key>.",
439
+ err=True,
440
+ )
441
+ raise typer.Exit(code=1)
442
+
443
+ config.save_config(app_config)
444
+ typer.echo(f"Config saved to {config.CONFIG_PATH}")
445
+
446
+
397
447
  @app.command()
398
448
  def make_config():
399
449
  """
@@ -418,153 +468,7 @@ def make_config():
418
468
  yaml.dump(sample_config, f, default_flow_style=False, sort_keys=False)
419
469
 
420
470
  typer.echo(f"Sample configuration file created at: {config.CONFIG_PATH}")
421
- typer.echo("Please edit this file with your actual folder paths, or run 'v-flow setup' for a guided wizard.")
422
-
423
-
424
- @app.command("setup")
425
- def setup_config() -> None:
426
- """
427
- Interactive wizard to create or update your v-flow configuration.
428
- """
429
- typer.echo(f"\nThis wizard will create or update your v-flow config at:\n {config.CONFIG_PATH}\n")
430
- existing: dict = {}
431
- if config.CONFIG_PATH.exists():
432
- typer.echo("Existing configuration found. Loading it so you can review/edit values...")
433
- try:
434
- with config.CONFIG_PATH.open("r") as f:
435
- existing = yaml.safe_load(f) or {}
436
- except Exception as e:
437
- typer.echo(f"[WARNING] Could not read existing config: {e}", err=True)
438
- existing = {}
439
-
440
- locations = dict(existing.get("locations", {}))
441
- settings = dict(existing.get("settings", {}))
442
-
443
- # Optionally show mounted volumes (macOS style) to help users pick paths.
444
- volumes_root = Path("/Volumes")
445
- if volumes_root.exists() and volumes_root.is_dir():
446
- volumes = [p for p in volumes_root.iterdir() if p.is_dir()]
447
- if volumes:
448
- typer.echo("\nDetected external volumes (for reference):")
449
- for v in volumes:
450
- typer.echo(f" - {v}")
451
-
452
- def ask_location(key: str, label: str, description: str, default_suggestion: Path) -> str:
453
- current = locations.get(key)
454
- default_value = current or str(default_suggestion)
455
- prompt = f"{label} ({description})"
456
- value = typer.prompt(prompt, default=default_value)
457
- return value
458
-
459
- home = Path.home()
460
- laptop_path = ask_location(
461
- "laptop",
462
- "Laptop ingest folder",
463
- "where you first copy footage onto your laptop",
464
- home / "Desktop" / "Ingest",
465
- )
466
- work_ssd_path = ask_location(
467
- "work_ssd",
468
- "Workspace SSD projects folder",
469
- "fast drive where your editing projects live",
470
- home / "Movies" / "Projects",
471
- )
472
- archive_hdd_path = ask_location(
473
- "archive_hdd",
474
- "Archive drive root",
475
- "long-term storage drive for finished and backed-up media",
476
- Path("/Volumes/Archive"),
477
- )
478
-
479
- new_locations = {
480
- "laptop": str(Path(laptop_path).expanduser()),
481
- "work_ssd": str(Path(work_ssd_path).expanduser()),
482
- "archive_hdd": str(Path(archive_hdd_path).expanduser()),
483
- }
484
-
485
- # Optional settings
486
- def ask_int_setting(key: str, label: str, default_val: int) -> int:
487
- current_val = settings.get(key, default_val)
488
- while True:
489
- raw = typer.prompt(label, default=str(current_val))
490
- try:
491
- return int(raw)
492
- except ValueError:
493
- typer.echo("Please enter a number (e.g. 0, 4, 12).", err=True)
494
-
495
- typer.echo("\nOptional settings (press Enter to accept the suggested default).")
496
- default_split_gap = ask_int_setting(
497
- "default_split_gap",
498
- "Default split gap (in hours) for ingest when --split-by-gap is not provided (0 = disable auto-splitting)",
499
- settings.get("default_split_gap", 0),
500
- )
501
-
502
- default_backup_source = typer.prompt(
503
- "Default folder to back up when you say 'back up my stuff' (optional, can be empty)",
504
- default=settings.get("default_backup_source", ""),
505
- ).strip()
506
-
507
- new_settings: dict = {}
508
- new_settings["default_split_gap"] = default_split_gap
509
- if default_backup_source:
510
- new_settings["default_backup_source"] = default_backup_source
511
-
512
- new_config: dict = {"locations": new_locations}
513
- if new_settings:
514
- new_config["settings"] = new_settings
515
-
516
- config.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
517
- with config.CONFIG_PATH.open("w") as f:
518
- yaml.dump(new_config, f, default_flow_style=False, sort_keys=False)
519
-
520
- typer.echo(f"\nConfiguration written to: {config.CONFIG_PATH}\n")
521
-
522
- # Basic validation of paths
523
- typer.echo("Validating configured locations:")
524
- missing = 0
525
- for name, path_str in new_locations.items():
526
- p = Path(path_str).expanduser()
527
- if not p.exists() or not p.is_dir():
528
- typer.echo(f" [WARNING] '{name}' points to a directory that does not exist yet: {p}", err=True)
529
- missing += 1
530
- else:
531
- typer.echo(f" [OK] {name}: {p}")
532
-
533
- if missing:
534
- typer.echo(
535
- "\nSome locations do not exist yet. You can create those folders and re-run 'v-flow setup', "
536
- "or edit the config file manually if needed.",
537
- err=True,
538
- )
539
- else:
540
- typer.echo("\nAll configured locations exist and look good.")
541
-
542
-
543
- @app.command("config-validate")
544
- def config_validate() -> None:
545
- """
546
- Validate your v-flow configuration file and report any issues.
547
- """
548
- typer.echo(f"Validating configuration at: {config.CONFIG_PATH}")
549
- cfg = config.load_config()
550
- locations = cfg.get("locations", {})
551
- if not locations:
552
- typer.echo("No locations defined in configuration.", err=True)
553
- raise typer.Exit(code=1)
554
-
555
- ok = True
556
- for name, path_str in locations.items():
557
- p = Path(path_str).expanduser()
558
- if not p.exists() or not p.is_dir():
559
- typer.echo(f"[ERROR] Location '{name}' points to missing or invalid directory: {p}", err=True)
560
- ok = False
561
- else:
562
- typer.echo(f"[OK] {name}: {p}")
563
-
564
- if not ok:
565
- raise typer.Exit(code=1)
566
-
567
- typer.echo("Configuration looks good.")
471
+ typer.echo("Please edit this file with your actual folder paths.")
568
472
 
569
473
 
570
474
  if __name__ == "__main__":
@@ -0,0 +1,104 @@
1
+ """
2
+ Date parsing and shoot range helper functions.
3
+ """
4
+ from datetime import datetime, date
5
+ import re
6
+ from typing import Optional, Tuple
7
+
8
+ def parse_shoot_date_range(shoot_name: str) -> Optional[Tuple[date, date]]:
9
+ """
10
+ Parse a shoot name to extract date range.
11
+ Returns (start_date, end_date) or None if no date found.
12
+
13
+ Supports formats:
14
+ - YYYY-MM-DD_ShootName (single date)
15
+ - YYYY-MM-DD_to_YYYY-MM-DD_ShootName (date range)
16
+ """
17
+ # Pattern for date range: YYYY-MM-DD_to_YYYY-MM-DD_ShootName
18
+ range_pattern = r'^(\d{4}-\d{2}-\d{2})_to_(\d{4}-\d{2}-\d{2})_(.+)$'
19
+ match = re.match(range_pattern, shoot_name)
20
+ if match:
21
+ try:
22
+ start = datetime.strptime(match.group(1), '%Y-%m-%d').date()
23
+ end = datetime.strptime(match.group(2), '%Y-%m-%d').date()
24
+ return (start, end)
25
+ except ValueError:
26
+ pass
27
+
28
+ # Pattern for single date: YYYY-MM-DD_ShootName
29
+ single_pattern = r'^(\d{4}-\d{2}-\d{2})_(.+)$'
30
+ match = re.match(single_pattern, shoot_name)
31
+ if match:
32
+ try:
33
+ shoot_date = datetime.strptime(match.group(1), '%Y-%m-%d').date()
34
+ return (shoot_date, shoot_date)
35
+ except ValueError:
36
+ pass
37
+
38
+ return None
39
+
40
+ def format_shoot_name(start_date: date, end_date: date, name_suffix: str = "Ingest") -> str:
41
+ """
42
+ Format a shoot name from a date range.
43
+ If start and end are the same, use single date format.
44
+ """
45
+ if start_date == end_date:
46
+ return f"{start_date.strftime('%Y-%m-%d')}_{name_suffix}"
47
+ else:
48
+ return f"{start_date.strftime('%Y-%m-%d')}_to_{end_date.strftime('%Y-%m-%d')}_{name_suffix}"
49
+
50
+ def date_in_range(check_date: date, start_date: date, end_date: date) -> bool:
51
+ """Check if a date falls within a date range (inclusive)."""
52
+ return start_date <= check_date <= end_date
53
+
54
+
55
+ def cluster_files_by_date(files_with_dates: list[Tuple[any, datetime]], gap_hours: int) -> list[list[any]]:
56
+ """
57
+ Group files into clusters based on a time gap threshold.
58
+ files_with_dates: List of tuples (file_path, file_date)
59
+ gap_hours: Minimum gap in hours to trigger a split
60
+
61
+ Returns a list of lists, where each inner list contains file paths for one cluster.
62
+ """
63
+ if not files_with_dates:
64
+ return []
65
+
66
+ # Sort by date
67
+ sorted_files = sorted(files_with_dates, key=lambda x: x[1])
68
+
69
+ clusters = []
70
+ current_cluster = []
71
+
72
+ if not sorted_files:
73
+ return []
74
+
75
+ # Start first cluster
76
+ current_cluster.append(sorted_files[0][0])
77
+ last_date = sorted_files[0][1]
78
+
79
+ for i in range(1, len(sorted_files)):
80
+ file_path, file_date = sorted_files[i]
81
+
82
+ # Calculate difference in hours
83
+ # Ensure we are comparing datetimes
84
+ if isinstance(file_date, date) and not isinstance(file_date, datetime):
85
+ # Fallback if we only have dates (assume midnight)
86
+ file_date = datetime.combine(file_date, datetime.min.time())
87
+ if isinstance(last_date, date) and not isinstance(last_date, datetime):
88
+ last_date = datetime.combine(last_date, datetime.min.time())
89
+
90
+ diff = file_date - last_date
91
+ diff_hours = diff.total_seconds() / 3600
92
+
93
+ if diff_hours >= gap_hours:
94
+ # Gap exceeded, start new cluster
95
+ clusters.append(current_cluster)
96
+ current_cluster = []
97
+
98
+ current_cluster.append(file_path)
99
+ last_date = file_date
100
+
101
+ if current_cluster:
102
+ clusters.append(current_cluster)
103
+
104
+ return clusters
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vflow-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: The v-flow CLI and Claude skills bundle for automating media backup and processing workflows for videographers.
5
5
  Author-email: Kaung <kaungzinye@gmail.com>
6
6
  Requires-Python: >=3.8
@@ -7,6 +7,7 @@ src/vflow/config.py
7
7
  src/vflow/delivery_service.py
8
8
  src/vflow/ingest_service.py
9
9
  src/vflow/main.py
10
+ src/vflow/utils_date.py
10
11
  src/vflow/core/__init__.py
11
12
  src/vflow/core/date_utils.py
12
13
  src/vflow/core/fs_ops.py
File without changes
File without changes