vflow-cli 0.1.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.
vflow/main.py ADDED
@@ -0,0 +1,571 @@
1
+ import typer
2
+ import yaml
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ from . import config
6
+
7
+ app = typer.Typer()
8
+
9
+ from . import actions
10
+
11
+ @app.command()
12
+ def ingest(
13
+ source: str = typer.Option(..., "--source", "-s", help="Exact folder path where videos are located (e.g., '/Volumes/Kaung 128GB/private/M4ROOT/CLIP')"),
14
+ shoot: str = typer.Option(None, "--shoot", "-n", help="Name of the shoot (e.g., '2025-09-15_Stockholm_Broll'). Optional if --auto is used."),
15
+ auto: bool = typer.Option(False, "--auto", "-a", help="Automatically infer shoot folder name from file dates. Creates date range if spanning multiple days."),
16
+ force: bool = typer.Option(False, "--force", "-f", help="Force ingest even if shoot name conflicts with existing date ranges."),
17
+ skip_laptop: bool = typer.Option(False, "--skip-laptop", help="Skip copying files to the laptop ingest folder (saves space)."),
18
+ workspace: bool = typer.Option(False, "--workspace", "-w", help="Also ingest directly to the Workspace SSD."),
19
+ split_by_gap: int = typer.Option(0, "--split-by-gap", help="Automatically split footage into multiple shoots if a time gap of X hours is detected."),
20
+ files: list[str] = typer.Option(None, "--files", help="Optional: Specific filenames, patterns, or ranges to ingest (e.g., 'C3317' or 'C3317-C3351'). Can specify multiple times. If omitted, ingests all files."),
21
+ ):
22
+ """
23
+ Ingests footage from a source to the laptop and archive.
24
+
25
+ The source should be the exact folder path where your video files are located
26
+ (e.g., '/Volumes/Kaung 128GB/private/M4ROOT/CLIP' for Sony cameras).
27
+ Videos will be searched recursively from this folder.
28
+ """
29
+ if not auto and not shoot:
30
+ typer.echo("Either --shoot or --auto must be provided.", err=True)
31
+ raise typer.Exit(code=1)
32
+
33
+ # Load configuration
34
+ app_config = config.load_config()
35
+
36
+ # Get locations
37
+ laptop_dest = config.get_location(app_config, "laptop")
38
+ archive_dest = config.get_location(app_config, "archive_hdd")
39
+
40
+ workspace_dest = None
41
+ if workspace:
42
+ workspace_dest = config.get_location(app_config, "work_ssd")
43
+
44
+ # Check for default split gap if not provided via flag
45
+ if split_by_gap == 0:
46
+ split_by_gap = config.get_setting(app_config, "default_split_gap", 0)
47
+
48
+ actions.ingest_shoot(source, shoot, laptop_dest, archive_dest, auto=auto, force=force, skip_laptop=skip_laptop, workspace_dest=workspace_dest, split_threshold=split_by_gap, files_filter=files)
49
+
50
+ @app.command("ingest-report")
51
+ def ingest_report_cmd(
52
+ source: str = typer.Option(..., "--source", "-s", help="SD card CLIP folder (e.g., '/Volumes/Untitled/private/M4ROOT/CLIP')"),
53
+ priority_day: int = typer.Option(28, "--priority-day", help="Day of month to highlight as priority (e.g. 28 for the 28th)"),
54
+ priority_month: Optional[int] = typer.Option(None, "--priority-month", help="Month for priority day (optional; if omitted, any 28th on card is highlighted)"),
55
+ ):
56
+ """
57
+ Report what on the SD card has not been ingested yet.
58
+ Compares source to BOTH laptop ingest and archive (duplicate = same name+size in either).
59
+ Highlights a priority day (default 28th) for editing.
60
+ """
61
+ app_config = config.load_config()
62
+ archive_dest = config.get_location(app_config, "archive_hdd")
63
+ laptop_dest = config.get_location(app_config, "laptop")
64
+ actions.ingest_report(source, archive_dest, laptop_path=laptop_dest, priority_day=priority_day, priority_month=priority_month)
65
+
66
+ @app.command("list-duplicates")
67
+ def list_duplicates_cmd(
68
+ location: str = typer.Option("archive", "--location", "-l", help="Where to scan: 'archive', 'laptop', or 'both'"),
69
+ past_hours: Optional[int] = typer.Option(None, "--past-hours", "-H", help="Only consider files modified in the last N hours (e.g. 24 for newly ingested)"),
70
+ ):
71
+ """
72
+ List duplicate files (same name + size in multiple places) in archive and/or laptop.
73
+ Use --past-hours 24 to only check files ingested in the last 24 hours.
74
+ """
75
+ app_config = config.load_config()
76
+ archive_dest = config.get_location(app_config, "archive_hdd")
77
+ laptop_dest = config.get_location(app_config, "laptop")
78
+
79
+ def report_duplicates(label: str, root: Path) -> None:
80
+ if not root.exists():
81
+ typer.echo(f"{label}: path not found ({root})")
82
+ return
83
+ dupes = actions.list_duplicates(root, max_age_hours=past_hours)
84
+ typer.echo(f"\n{'='*70}")
85
+ typer.echo(f"{label}")
86
+ typer.echo(f"{'='*70}")
87
+ typer.echo(f"Scanned: {root}" + (f" (only files modified in last {past_hours}h)" if past_hours else ""))
88
+ typer.echo(f"Duplicate groups: {len(dupes)}")
89
+ total_extra = sum(len(paths) - 1 for _, paths in dupes)
90
+ typer.echo(f"Extra copies (could be removed): {total_extra}")
91
+ typer.echo("")
92
+ for (name, size), paths in sorted(dupes, key=lambda x: (x[0][0], x[0][1])):
93
+ paths_sorted = sorted(paths, key=lambda p: str(p))
94
+ typer.echo(f" {name} ({size} bytes) appears {len(paths_sorted)} times:")
95
+ for p in paths_sorted:
96
+ try:
97
+ rel = p.relative_to(root)
98
+ except ValueError:
99
+ rel = p
100
+ typer.echo(f" - {rel}")
101
+ typer.echo("")
102
+
103
+ if location in ("archive", "both"):
104
+ archive_raw = archive_dest / "Video" / "RAW"
105
+ report_duplicates("ARCHIVE (Video/RAW)", archive_raw)
106
+ if location in ("laptop", "both"):
107
+ report_duplicates("LAPTOP (Ingest)", laptop_dest)
108
+ if location not in ("archive", "laptop", "both"):
109
+ typer.echo("Invalid --location. Use 'archive', 'laptop', or 'both'.", err=True)
110
+ raise typer.Exit(code=1)
111
+
112
+ @app.command("remove-duplicates")
113
+ def remove_duplicates_cmd(
114
+ dry_run: bool = typer.Option(False, "--dry-run", help="Only report what would be removed"),
115
+ past_hours: Optional[int] = typer.Option(None, "--past-hours", "-H", help="Only consider files modified in the last N hours (e.g. 24 for newly ingested)"),
116
+ ):
117
+ """
118
+ Remove duplicate files (same name + size in multiple shoot folders) from
119
+ archive Video/RAW and laptop ingest. Keeps one copy per file, deletes the rest.
120
+ Use --past-hours 24 to only remove duplicates among recently ingested files.
121
+ """
122
+ app_config = config.load_config()
123
+ archive_dest = config.get_location(app_config, "archive_hdd")
124
+ laptop_dest = config.get_location(app_config, "laptop")
125
+ archive_raw = archive_dest / "Video" / "RAW"
126
+ suffix = f" (only files modified in last {past_hours}h)" if past_hours else ""
127
+ typer.echo("Scanning archive for duplicates...")
128
+ if archive_raw.exists():
129
+ n_archive = actions.remove_duplicates(archive_raw, dry_run=dry_run, max_age_hours=past_hours)
130
+ typer.echo(f"Archive: {n_archive} duplicate(s) {'would be ' if dry_run else ''}removed.{suffix}")
131
+ else:
132
+ typer.echo("Archive Video/RAW not found.")
133
+ typer.echo("Scanning laptop ingest for duplicates...")
134
+ if laptop_dest.exists():
135
+ n_laptop = actions.remove_duplicates(laptop_dest, dry_run=dry_run, max_age_hours=past_hours)
136
+ typer.echo(f"Laptop: {n_laptop} duplicate(s) {'would be ' if dry_run else ''}removed.{suffix}")
137
+ else:
138
+ typer.echo("Laptop ingest folder not found.")
139
+ typer.echo("Done.")
140
+
141
+ @app.command()
142
+ def prep(
143
+ shoot: str = typer.Option(..., "--shoot", "-n", help="Name of the shoot to prepare for editing"),
144
+ ):
145
+ """
146
+ Prepares a shoot for editing by moving it to the work SSD.
147
+ """
148
+ typer.echo(f"Preparing '{shoot}' for editing...")
149
+
150
+ # Load configuration
151
+ app_config = config.load_config()
152
+
153
+ # Get locations
154
+ laptop_dest = config.get_location(app_config, "laptop")
155
+ work_ssd_dest = config.get_location(app_config, "work_ssd")
156
+
157
+ actions.prep_shoot(shoot, laptop_dest, work_ssd_dest)
158
+
159
+ @app.command()
160
+ def pull(
161
+ shoot: str = typer.Option(..., "--shoot", "-n", help="Name of the shoot to pull from archive"),
162
+ source: str = typer.Option("raw", "--source", "-s", help="What to pull: 'raw' (default), 'selects', or 'both'. Raw files go to 01_Source, graded selects go to 05_Graded_Selects."),
163
+ files: list[str] = typer.Option(None, "--files", "-f", help="Optional: Specific filenames, patterns, or ranges to pull (e.g., 'C3317' or 'C3317-C3351'). Can specify multiple times. If omitted, pulls all files."),
164
+ ):
165
+ """
166
+ Pulls files from archive to the work SSD for editing.
167
+
168
+ Useful when you want to work with archived footage. Creates the standard
169
+ project structure and copies (doesn't move) files from archive to your work SSD.
170
+
171
+ Source options:
172
+ - 'raw': Pull raw files from Video/RAW/ to 01_Source/ (default)
173
+ - 'selects': Pull graded selects from Video/Graded_Selects/ to 05_Graded_Selects/
174
+ - 'both': Pull both raw files and graded selects to their respective folders
175
+
176
+ You can optionally specify specific files or partial filenames to pull only
177
+ selected clips.
178
+ """
179
+ if source not in ("raw", "selects", "both"):
180
+ typer.echo(f"Invalid source type: {source}. Must be 'raw', 'selects', or 'both'.", err=True)
181
+ raise typer.Exit(code=1)
182
+
183
+ typer.echo(f"Pulling '{shoot}' from archive to work SSD (source: {source})...")
184
+
185
+ # Load configuration
186
+ app_config = config.load_config()
187
+
188
+ # Get locations
189
+ work_ssd_dest = config.get_location(app_config, "work_ssd")
190
+ archive_dest = config.get_location(app_config, "archive_hdd")
191
+
192
+ actions.pull_shoot(shoot, work_ssd_dest, archive_dest, source_type=source, files_filter=files)
193
+
194
+ @app.command()
195
+ def archive(
196
+ shoot: str = typer.Option(..., "--shoot", "-n", help="Name of the shoot"),
197
+ file: str = typer.Option(..., "--file", "-f", help="Filename of the exported video to archive"),
198
+ tags: str = typer.Option(..., "--tags", "-t", help="Comma-separated metadata tags"),
199
+ keep_log: bool = typer.Option(False, "--keep-log", help="Do not delete the original S-LOG file from the source folder"),
200
+ ):
201
+ """
202
+ Archives a final render, tags it, and cleans up the source file.
203
+ """
204
+ typer.echo(f"Archiving '{file}' from shoot '{shoot}'...")
205
+
206
+ app_config = config.load_config()
207
+ work_ssd_dest = config.get_location(app_config, "work_ssd")
208
+ archive_hdd_dest = config.get_location(app_config, "archive_hdd")
209
+
210
+
211
+ actions.archive_file(shoot, file, tags, keep_log, work_ssd_dest, archive_hdd_dest)
212
+
213
+ @app.command()
214
+ def create_select(
215
+ shoot: str = typer.Option(..., "--shoot", "-n", help="Name of the shoot"),
216
+ file: str = typer.Option(..., "--file", "-f", help="Filename of the exported video to create a select from"),
217
+ tags: str = typer.Option(..., "--tags", "-t", help="Comma-separated metadata tags"),
218
+ ):
219
+ """
220
+ Creates a graded select, archiving it and copying it to the local SSD for reuse.
221
+ """
222
+ typer.echo(f"Creating select for '{file}' from shoot '{shoot}'...")
223
+
224
+ app_config = config.load_config()
225
+ work_ssd_dest = config.get_location(app_config, "work_ssd")
226
+ archive_hdd_dest = config.get_location(app_config, "archive_hdd")
227
+
228
+ actions.create_select_file(shoot, file, tags, work_ssd_dest, archive_hdd_dest)
229
+
230
+ @app.command()
231
+ def consolidate(
232
+ source: str = typer.Option(..., "--source", "-s", help="Source directory to scan for unique files"),
233
+ output_folder: str = typer.Option(None, "--output-folder", "-o", help="Name of the folder to create in the archive for unique media (required if --destination not provided)"),
234
+ destination: str = typer.Option(None, "--destination", "-d", help="Path relative to archive root (e.g., 'Video/Graded'). If provided, uses this instead of --output-folder."),
235
+ files: list[str] = typer.Option(None, "--files", "-f", help="Optional: Specific filenames, patterns, or ranges to process (e.g., 'C3317' or 'project1'). Can specify multiple times. If omitted, processes all files."),
236
+ tags: str = typer.Option(None, "--tags", "-t", help="Optional: Comma-separated metadata tags to add to copied files"),
237
+ ):
238
+ """
239
+ Finds and copies unique media from a source drive into the archive.
240
+
241
+ Can be used for general consolidation (with --output-folder) or for backing up exports
242
+ to a specific location (with --destination, e.g., "Video/Graded").
243
+
244
+ Examples:
245
+ - Consolidate all files: consolidate --source "/path/to/source" --output-folder "NewFolder"
246
+ - Backup specific projects: consolidate --source "/path/to/exports" --destination "Video/Graded" --files "project1" --files "project2"
247
+ """
248
+ if not output_folder and not destination:
249
+ typer.echo("Either --output-folder or --destination must be provided.", err=True)
250
+ raise typer.Exit(code=1)
251
+
252
+ typer.echo(f"Consolidating unique files from '{source}'...")
253
+
254
+ app_config = config.load_config()
255
+ archive_hdd_dest = config.get_location(app_config, "archive_hdd")
256
+
257
+ actions.consolidate_files(
258
+ source,
259
+ output_folder,
260
+ archive_hdd_dest,
261
+ destination_path=destination,
262
+ file_filter=files,
263
+ tags=tags,
264
+ preserve_structure=True,
265
+ )
266
+
267
+
268
+ @app.command()
269
+ def backup(
270
+ source: str = typer.Option(..., "--source", "-s", help="Source directory to back up (e.g., '~/Desktop/Ingest')."),
271
+ destination: str = typer.Option(..., "--destination", "-d", help="Path relative to archive root (e.g., 'Video/RAW/2025-10-12_Shoot')."),
272
+ files: list[str] = typer.Option(None, "--files", "-f", help="Optional: Specific filenames, patterns, or ranges to back up (e.g., 'C3317' or 'C3317-C3351'). Can specify multiple times. If omitted, processes all files."),
273
+ tags: str = typer.Option(None, "--tags", "-t", help="Optional: Comma-separated metadata tags to add to copied files."),
274
+ dry_run: bool = typer.Option(False, "--dry-run", help="Analyze what would be backed up without copying any files."),
275
+ delete_source: bool = typer.Option(
276
+ False,
277
+ "--delete-source",
278
+ help="After copying, prompt to optionally delete source files that were successfully backed up.",
279
+ ),
280
+ ):
281
+ """
282
+ Backs up media from an arbitrary source folder into the archive with duplicate checks.
283
+
284
+ This is a friendly wrapper around the consolidate logic, intended for backing up
285
+ ingest folders or project folders (e.g., Desktop/Ingest) into your archive drive.
286
+
287
+ Use --dry-run first to see which files are not already in the archive.
288
+ """
289
+ typer.echo(f"{'Dry-running' if dry_run else 'Backing up'} from '{source}' to archive destination '{destination}'...")
290
+ if delete_source and dry_run:
291
+ typer.echo(
292
+ "Note: --delete-source is set; this dry-run will only report which files would be eligible for deletion after a real backup."
293
+ )
294
+
295
+ app_config = config.load_config()
296
+ archive_hdd_dest = config.get_location(app_config, "archive_hdd")
297
+
298
+ actions.consolidate_files(
299
+ source,
300
+ output_folder_name=None,
301
+ archive_path=archive_hdd_dest,
302
+ destination_path=destination,
303
+ file_filter=files,
304
+ tags=tags,
305
+ preserve_structure=True,
306
+ dry_run=dry_run,
307
+ delete_source=delete_source,
308
+ )
309
+
310
+
311
+ @app.command("verify-backup")
312
+ def verify_backup_cmd(
313
+ source: str = typer.Option(..., "--source", "-s", help="Source directory that was backed up."),
314
+ destination: str = typer.Option(
315
+ ..., "--destination", "-d", help="Destination directory where backup was written."
316
+ ),
317
+ allow_delete: bool = typer.Option(
318
+ False,
319
+ "--allow-delete",
320
+ help="After successful verification, prompt to delete all files under the source folder.",
321
+ ),
322
+ archive_wide: bool = typer.Option(
323
+ False,
324
+ "--archive-wide",
325
+ help="Treat destination as an archive root and verify that each source file exists "
326
+ "anywhere under it by name+size, instead of requiring a path-for-path mirror.",
327
+ ),
328
+ ):
329
+ """
330
+ Verify that all files in a source folder exist in a destination folder with matching sizes.
331
+
332
+ This is a general-purpose checker for any two folders (e.g. Desktop/Ingest vs archive).
333
+ Use together with 'backup' or any other copy method to confirm that your backup is complete
334
+ before optionally deleting the source files.
335
+ """
336
+ typer.echo(f"Verifying backup between source '{source}' and destination '{destination}'...")
337
+ actions.verify_backup(source, destination, allow_delete=allow_delete, archive_wide=archive_wide)
338
+
339
+
340
+ @app.command("list-backups")
341
+ def list_backups_cmd(
342
+ subpath: str = typer.Option(
343
+ "Video/RAW/Desktop_Ingest",
344
+ "--subpath",
345
+ "-p",
346
+ help="Subpath under archive root to scan for backups (e.g., 'Video/RAW/Desktop_Ingest').",
347
+ ),
348
+ ):
349
+ """
350
+ List backup folders under a given archive subpath with file counts and total sizes.
351
+
352
+ Useful for quickly seeing what has been consolidated, and how large each backup folder is.
353
+ """
354
+ app_config = config.load_config()
355
+ archive_hdd_dest = config.get_location(app_config, "archive_hdd")
356
+ actions.list_backups(archive_hdd_dest, subpath)
357
+
358
+
359
+ @app.command("restore-folder")
360
+ def restore_folder_cmd(
361
+ source: str = typer.Option(..., "--source", "-s", help="Source folder to restore from (e.g., an archive backup folder)."),
362
+ destination: str = typer.Option(
363
+ ..., "--destination", "-d", help="Destination folder to restore into (e.g., a workspace or temp folder)."
364
+ ),
365
+ dry_run: bool = typer.Option(
366
+ False,
367
+ "--dry-run",
368
+ help="Simulate the restore without copying any files. Shows what would be copied/overwritten.",
369
+ ),
370
+ overwrite: bool = typer.Option(
371
+ False,
372
+ "--overwrite",
373
+ help="Allow overwriting destination files that differ in size. If false, such conflicts are reported and skipped.",
374
+ ),
375
+ ):
376
+ """
377
+ Restore (copy) an arbitrary folder tree from one location to another.
378
+
379
+ This is the inverse of 'backup' for general folders and can be used to pull a
380
+ backup folder from archive back to a workspace path.
381
+ """
382
+ actions.restore_folder(source, destination, dry_run=dry_run, overwrite=overwrite)
383
+
384
+ @app.command()
385
+ def copy_meta(
386
+ source_folder: Path = typer.Option(..., "--source-folder", "-s", help="Path to the folder with original files"),
387
+ target_folder: Path = typer.Option(..., "--target-folder", "-t", help="Path to the folder with exported files"),
388
+ ):
389
+ """
390
+ Copies metadata from files in a source folder to files in a target folder based on matching filenames.
391
+ """
392
+ typer.echo(f"Copying metadata from '{source_folder}' to '{target_folder}'...")
393
+ actions.copy_metadata_folder(source_folder, target_folder)
394
+
395
+
396
+
397
+ @app.command()
398
+ def make_config():
399
+ """
400
+ Creates a sample configuration file in your home directory.
401
+ """
402
+ if config.CONFIG_PATH.exists():
403
+ typer.echo(f"Configuration file already exists at: {config.CONFIG_PATH}")
404
+ overwrite = typer.confirm("Overwrite?")
405
+ if not overwrite:
406
+ typer.echo("Aborting.")
407
+ raise typer.Exit()
408
+
409
+ sample_config = {
410
+ "locations": {
411
+ "laptop": "/path/to/your/laptop/ingest/folder",
412
+ "work_ssd": "/path/to/your/fast/ssd/projects",
413
+ "archive_hdd": "/path/to/your/archive/hdd",
414
+ }
415
+ }
416
+
417
+ with open(config.CONFIG_PATH, "w") as f:
418
+ yaml.dump(sample_config, f, default_flow_style=False, sort_keys=False)
419
+
420
+ 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.")
568
+
569
+
570
+ if __name__ == "__main__":
571
+ app()