chore: trim dead code, modularize CLI, and archive stale docs
Extract review-plan, report, quarantine, state, and config handlers into commands/ with shared cli_helpers; remove unused exceptions and duplicate plan summary wrappers. Archive superseded review markdown, sync docs to 517-test baseline, and fix empty series titles when only a quality tag remains. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.5
Cursor
parent
5f0b531269
commit
79797644e1
@@ -0,0 +1,62 @@
|
||||
"""Config CLI command implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error, default_config_path
|
||||
from vlm.config import create_default_config, validate_config
|
||||
from vlm.context import CLIContext
|
||||
|
||||
|
||||
def config_init_cmd(ctx: CLIContext, path: Path) -> None:
|
||||
"""Initialize configuration file with defaults."""
|
||||
try:
|
||||
if path.exists():
|
||||
click.echo(f"Configuration file already exists at {path}", err=True)
|
||||
if not click.confirm("Overwrite existing configuration?"):
|
||||
click.echo("Configuration initialization cancelled.")
|
||||
return
|
||||
|
||||
create_default_config(path)
|
||||
click.echo(f"Configuration file created at {path}")
|
||||
click.echo("Edit this file to customize your settings.")
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error creating configuration: {e}",
|
||||
f"Configuration creation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def config_show_cmd(ctx: CLIContext) -> None:
|
||||
"""Show current configuration."""
|
||||
cfg = ctx.config
|
||||
click.echo("Current configuration:")
|
||||
click.echo(f" Library root: {cfg.library_root}")
|
||||
click.echo(f" Video extensions: {', '.join(cfg.video_extensions)}")
|
||||
click.echo(f" Movie template: {cfg.movie_template}")
|
||||
click.echo(f" Series template: {cfg.series_template}")
|
||||
click.echo(f" Movie filename template: {cfg.movie_filename_template}")
|
||||
click.echo(f" Series filename template: {cfg.series_filename_template}")
|
||||
click.echo(f" Quarantine directory: {cfg.quarantine_dir}")
|
||||
click.echo(f" Workspace directory: {cfg.workspace_dir}")
|
||||
click.echo(f" Log level: {cfg.log_level}")
|
||||
|
||||
|
||||
def config_validate_cmd(ctx: CLIContext) -> None:
|
||||
"""Validate configuration."""
|
||||
cfg = ctx.config
|
||||
errors = validate_config(cfg)
|
||||
|
||||
if not errors:
|
||||
click.echo("Configuration is valid.")
|
||||
else:
|
||||
click.echo("Configuration validation errors:", err=True)
|
||||
for error in errors:
|
||||
click.echo(f" - {error}", err=True)
|
||||
command_error(ctx, "Configuration validation failed.", "Configuration validation failed")
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Quarantine CLI command implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error
|
||||
from vlm.context import CLIContext
|
||||
from vlm.quarantine import QuarantineManager
|
||||
from vlm.utils import format_size
|
||||
|
||||
|
||||
def quarantine_list_cmd(ctx: CLIContext, category: Optional[str]) -> None:
|
||||
"""List quarantined files."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
manager = QuarantineManager(config, logger)
|
||||
entries = manager.list_quarantined(category=category)
|
||||
|
||||
if not entries:
|
||||
if category:
|
||||
click.echo(f"No quarantined files found in category '{category}'.")
|
||||
else:
|
||||
click.echo("No quarantined files found.")
|
||||
return
|
||||
|
||||
click.echo()
|
||||
if category:
|
||||
click.echo(f"Quarantined files in category '{category}':")
|
||||
else:
|
||||
click.echo("Quarantined files:")
|
||||
click.echo("=" * 80)
|
||||
|
||||
for i, entry in enumerate(entries, 1):
|
||||
click.echo(f"\n[{i}] {entry.quarantine_path.name}")
|
||||
click.echo(f" Category: {entry.category}")
|
||||
click.echo(f" Original: {entry.original_path}")
|
||||
click.echo(f" Quarantine: {entry.quarantine_path}")
|
||||
click.echo(f" Size: {format_size(entry.size_bytes)}")
|
||||
click.echo(f" Quarantined: {entry.quarantined_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
if entry.reason:
|
||||
click.echo(f" Reason: {entry.reason}")
|
||||
|
||||
click.echo()
|
||||
click.echo("=" * 80)
|
||||
click.echo(f"Total: {len(entries)} quarantined file(s)")
|
||||
click.echo()
|
||||
|
||||
logger.info(
|
||||
"Listed %s quarantined files%s",
|
||||
len(entries),
|
||||
f" from category '{category}'" if category else "",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error listing quarantined files: {e}",
|
||||
f"Failed to list quarantined files: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def quarantine_add_cmd(ctx: CLIContext, file: Path, reason: Optional[str]) -> None:
|
||||
"""Add file to quarantine."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
manager = QuarantineManager(config, logger)
|
||||
|
||||
click.echo(f"Quarantining file: {file}")
|
||||
if reason:
|
||||
click.echo(f"Reason: {reason}")
|
||||
click.echo()
|
||||
|
||||
result = manager.quarantine_file(file, reason=reason)
|
||||
|
||||
if result.success:
|
||||
click.echo("✓ File successfully quarantined!")
|
||||
click.echo(f" Original: {result.operation.source_path}")
|
||||
click.echo(f" Quarantine: {result.operation.destination_path}")
|
||||
click.echo()
|
||||
click.echo("To restore this file, run:")
|
||||
click.echo(f" vlm quarantine restore {result.operation.destination_path}")
|
||||
else:
|
||||
command_error(
|
||||
ctx,
|
||||
f"✗ Failed to quarantine file: {result.error_message}",
|
||||
f"Quarantine failed for {file}: {result.error_message}",
|
||||
)
|
||||
|
||||
logger.info("Quarantined file: %s", file)
|
||||
|
||||
except ValueError as e:
|
||||
command_error(ctx, f"Error: {e}", f"Quarantine rejected: {e}")
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error quarantining file: {e}",
|
||||
f"Failed to quarantine file: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def quarantine_restore_cmd(ctx: CLIContext, file: Path) -> None:
|
||||
"""Restore file from quarantine."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
manager = QuarantineManager(config, logger)
|
||||
|
||||
click.echo(f"Restoring file from quarantine: {file}")
|
||||
click.echo()
|
||||
|
||||
result = manager.restore_from_quarantine(file)
|
||||
|
||||
if result.success:
|
||||
click.echo("✓ File successfully restored!")
|
||||
click.echo(f" Quarantine: {result.operation.source_path}")
|
||||
click.echo(f" Restored to: {result.operation.destination_path}")
|
||||
else:
|
||||
if result.operation.has_conflict:
|
||||
click.echo(f"✗ Cannot restore: {result.operation.conflict_reason}", err=True)
|
||||
click.echo(f" Original location: {result.operation.destination_path}", err=True)
|
||||
else:
|
||||
click.echo(f"✗ Failed to restore file: {result.error_message}", err=True)
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error restoring file: {result.error_message or result.operation.conflict_reason or 'unknown error'}",
|
||||
f"Failed to restore file: {file}",
|
||||
)
|
||||
|
||||
logger.info("Restored file from quarantine: %s", file)
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error restoring file: {e}",
|
||||
f"Failed to restore file: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Report CLI command implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error, resolve_legacy_default_input_path
|
||||
from vlm.context import CLIContext
|
||||
from vlm.io import load_analysis_json, load_inventory_csv
|
||||
from vlm.models import DuplicateGroup, MovieIdentity, SeasonCompleteness, SeriesIdentity, VideoFile
|
||||
from vlm.plan_render import preferred_plan_summary
|
||||
from vlm.planner import load_plan
|
||||
from vlm.reports import (
|
||||
generate_completeness_report,
|
||||
generate_duplicate_report,
|
||||
generate_inventory_report,
|
||||
generate_summary_report,
|
||||
)
|
||||
|
||||
|
||||
def report_inventory_cmd(
|
||||
ctx: CLIContext,
|
||||
format: str,
|
||||
input: Path,
|
||||
output: Optional[Path],
|
||||
) -> None:
|
||||
"""Generate inventory report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
|
||||
video_files = load_inventory_csv(input)
|
||||
|
||||
click.echo(f"Loaded {len(video_files)} files")
|
||||
click.echo()
|
||||
|
||||
click.echo(f"Generating inventory report in {format} format...")
|
||||
|
||||
report_format = "csv" if format == "text" else format
|
||||
report_content = generate_inventory_report(video_files, report_format, config.library_root)
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
|
||||
logger.info("Generated inventory report in %s format with %s files", format, len(video_files))
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating inventory report: {e}",
|
||||
f"Inventory report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def report_completeness_cmd(
|
||||
ctx: CLIContext,
|
||||
format: str,
|
||||
input: Path,
|
||||
output: Optional[Path],
|
||||
plan: Optional[Path],
|
||||
) -> None:
|
||||
"""Generate completeness report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
|
||||
plan_summary = None
|
||||
if plan:
|
||||
if not plan.exists():
|
||||
click.echo(f"Error: Plan file not found: {plan}", err=True)
|
||||
sys.exit(1)
|
||||
execution_plan = load_plan(plan)
|
||||
plan_summary = preferred_plan_summary(execution_plan)
|
||||
|
||||
try:
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
|
||||
analysis_data = load_analysis_json(input)
|
||||
|
||||
completeness_list = analysis_data.get("completeness", [])
|
||||
|
||||
season_completeness = []
|
||||
for c in completeness_list:
|
||||
season_completeness.append(
|
||||
SeasonCompleteness(
|
||||
series_title=c["series_title"],
|
||||
season=c["season"],
|
||||
episodes_found=c["episodes_found"],
|
||||
episodes_missing=c["episodes_missing"],
|
||||
)
|
||||
)
|
||||
|
||||
click.echo(f"Loaded {len(season_completeness)} series with gaps")
|
||||
click.echo()
|
||||
|
||||
click.echo(f"Generating completeness report in {format} format...")
|
||||
report_content = generate_completeness_report(
|
||||
season_completeness, format, config.library_root, plan_summary=plan_summary
|
||||
)
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
|
||||
logger.info(
|
||||
"Generated completeness report in %s format with %s series",
|
||||
format,
|
||||
len(season_completeness),
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error: Failed to parse JSON file: {e}",
|
||||
f"JSON parsing failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating completeness report: {e}",
|
||||
f"Completeness report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def report_duplicates_cmd(
|
||||
ctx: CLIContext,
|
||||
format: str,
|
||||
input: Path,
|
||||
output: Optional[Path],
|
||||
plan: Optional[Path],
|
||||
) -> None:
|
||||
"""Generate duplicate report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
|
||||
plan_summary = None
|
||||
if plan:
|
||||
if not plan.exists():
|
||||
click.echo(f"Error: Plan file not found: {plan}", err=True)
|
||||
sys.exit(1)
|
||||
execution_plan = load_plan(plan)
|
||||
plan_summary = preferred_plan_summary(execution_plan)
|
||||
|
||||
try:
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
|
||||
analysis_data = load_analysis_json(input)
|
||||
|
||||
duplicates_list = analysis_data.get("duplicates", [])
|
||||
|
||||
duplicate_groups = []
|
||||
for d in duplicates_list:
|
||||
identity_data = d["identity"]
|
||||
|
||||
if identity_data["type"] == "movie":
|
||||
identity = MovieIdentity(
|
||||
title=identity_data["title"],
|
||||
year=identity_data.get("year"),
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename="",
|
||||
)
|
||||
else:
|
||||
identity = SeriesIdentity(
|
||||
title=identity_data["title"],
|
||||
season=identity_data.get("season"),
|
||||
episodes=identity_data.get("episodes", []),
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename="",
|
||||
)
|
||||
|
||||
quality_by_path = {
|
||||
str(item.get("path", "")): item for item in d.get("quality_comparison", [])
|
||||
}
|
||||
|
||||
files = []
|
||||
for file_path in d["files"]:
|
||||
quality = quality_by_path.get(str(file_path), {})
|
||||
files.append(
|
||||
VideoFile(
|
||||
path=Path(file_path),
|
||||
filename=Path(file_path).name,
|
||||
size_bytes=int(quality.get("size_bytes", 0) or 0),
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="",
|
||||
resolution=quality.get("resolution"),
|
||||
codec=quality.get("codec"),
|
||||
duration_seconds=quality.get("duration_seconds"),
|
||||
bitrate_kbps=quality.get("bitrate_kbps"),
|
||||
)
|
||||
)
|
||||
|
||||
duplicate_groups.append(
|
||||
DuplicateGroup(
|
||||
identity=identity,
|
||||
files=files,
|
||||
quality_comparison=d["quality_comparison"],
|
||||
)
|
||||
)
|
||||
|
||||
click.echo(f"Loaded {len(duplicate_groups)} duplicate groups")
|
||||
click.echo()
|
||||
|
||||
click.echo(f"Generating duplicate report in {format} format...")
|
||||
report_content = generate_duplicate_report(
|
||||
duplicate_groups, format, config.library_root, plan_summary=plan_summary
|
||||
)
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
|
||||
logger.info(
|
||||
"Generated duplicate report in %s format with %s groups",
|
||||
format,
|
||||
len(duplicate_groups),
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error: Failed to parse JSON file: {e}",
|
||||
f"JSON parsing failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating duplicate report: {e}",
|
||||
f"Duplicate report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def report_summary_cmd(ctx: CLIContext, input: Path, output: Optional[Path]) -> None:
|
||||
"""Generate summary report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
|
||||
video_files = load_inventory_csv(input)
|
||||
|
||||
click.echo(f"Loaded {len(video_files)} files")
|
||||
click.echo()
|
||||
|
||||
click.echo("Generating summary report...")
|
||||
report_content = generate_summary_report(video_files, config.library_root)
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
|
||||
logger.info("Generated summary report with %s files", len(video_files))
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating summary report: {e}",
|
||||
f"Summary report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Review-plan and apply-review command implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error, default_artifact_path, resolve_legacy_default_input_path, review_plan_tui_streams_ok
|
||||
from vlm.context import CLIContext
|
||||
from vlm.io import load_analysis_json, load_identities_json
|
||||
from vlm.plan_render import (
|
||||
duplicate_groups_from_plan,
|
||||
preferred_plan_summary,
|
||||
render_review_footer,
|
||||
render_review_preview,
|
||||
render_review_verdict,
|
||||
)
|
||||
from vlm.plan_review import GROUP_BY_CHOICES, prepare_review_rows, save_review_csv
|
||||
from vlm.plan_structure_preview import write_structure_preview
|
||||
from vlm.planner import apply_review_to_plan, load_plan, save_plan
|
||||
|
||||
|
||||
def review_plan_cmd(
|
||||
ctx: CLIContext,
|
||||
input: Path,
|
||||
output: Path,
|
||||
season_threshold: int,
|
||||
episode_threshold: int,
|
||||
preview_limit: int,
|
||||
show_all: bool,
|
||||
tui: bool,
|
||||
identities: Optional[Path],
|
||||
analysis: Optional[Path],
|
||||
group_by: str,
|
||||
sample_safe: int,
|
||||
structure_preview: Optional[Path],
|
||||
) -> None:
|
||||
"""Review a plan and export high-risk operations for manual confirmation."""
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
input = resolve_legacy_default_input_path(input, "input", "plan.json", "--input")
|
||||
if season_threshold < 1 or episode_threshold < 1:
|
||||
click.echo("Error: thresholds must be >= 1", err=True)
|
||||
sys.exit(1)
|
||||
if preview_limit < 1:
|
||||
click.echo("Error: --preview-limit must be >= 1", err=True)
|
||||
sys.exit(1)
|
||||
if sample_safe < 0:
|
||||
click.echo("Error: --sample-safe must be >= 0", err=True)
|
||||
sys.exit(1)
|
||||
group_by = group_by.lower()
|
||||
if group_by not in GROUP_BY_CHOICES:
|
||||
click.echo(f"Error: invalid --group-by {group_by}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
if tui:
|
||||
if not review_plan_tui_streams_ok():
|
||||
click.echo("Error: --tui requires an interactive terminal (TTY)", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
click.echo(f"Loading plan: {input}")
|
||||
execution_plan = load_plan(input)
|
||||
|
||||
identities_data = None
|
||||
identities_path = identities
|
||||
if identities_path is None:
|
||||
default_id = default_artifact_path("identities.json")
|
||||
if default_id.is_file():
|
||||
identities_path = default_id
|
||||
if identities_path is not None and identities_path.is_file():
|
||||
identities_data = load_identities_json(identities_path)
|
||||
click.echo(f"Loaded identities: {identities_path}")
|
||||
|
||||
analysis_data = None
|
||||
if analysis is not None and analysis.is_file():
|
||||
analysis_data = load_analysis_json(analysis)
|
||||
click.echo(f"Loaded analysis: {analysis}")
|
||||
|
||||
rows, counters = prepare_review_rows(
|
||||
execution_plan,
|
||||
season_threshold=season_threshold,
|
||||
episode_threshold=episode_threshold,
|
||||
library_root=ctx.config.library_root,
|
||||
identities_data=identities_data,
|
||||
analysis_data=analysis_data,
|
||||
sample_safe=sample_safe,
|
||||
group_by=group_by,
|
||||
)
|
||||
|
||||
if structure_preview is not None:
|
||||
write_structure_preview(
|
||||
execution_plan,
|
||||
ctx.config.library_root,
|
||||
structure_preview,
|
||||
)
|
||||
click.echo(f"Wrote structure preview: {structure_preview}")
|
||||
|
||||
if tui:
|
||||
from vlm.plan_review import build_duplicate_path_maps
|
||||
from vlm.review_tui import ReviewTUIContext, run_plan_review_tui
|
||||
|
||||
_, path_to_quality = build_duplicate_path_maps(analysis_data)
|
||||
tui_ctx = ReviewTUIContext(
|
||||
rows=rows,
|
||||
counters=counters,
|
||||
library_root=ctx.config.library_root,
|
||||
output_csv=output,
|
||||
plan_input=input,
|
||||
summary_text=preferred_plan_summary(execution_plan),
|
||||
path_to_quality=path_to_quality,
|
||||
)
|
||||
rc = run_plan_review_tui(tui_ctx)
|
||||
if rc != 0:
|
||||
click.echo("Plan review aborted (no CSV written).", err=True)
|
||||
sys.exit(rc)
|
||||
click.echo(f"Saved manual review CSV to: {output}")
|
||||
logger.info(
|
||||
"Plan review TUI completed: total=%s high_risk=%s output=%s",
|
||||
counters["total_operations"],
|
||||
counters["high_risk_operations"],
|
||||
output,
|
||||
)
|
||||
return
|
||||
|
||||
save_review_csv(rows, output)
|
||||
|
||||
click.echo()
|
||||
click.echo("Plan overview:")
|
||||
click.echo(preferred_plan_summary(execution_plan))
|
||||
|
||||
click.echo()
|
||||
click.echo("Plan review summary:")
|
||||
click.echo(f" Total operations: {counters['total_operations']}")
|
||||
click.echo(f" High-risk operations: {counters['high_risk_operations']}")
|
||||
click.echo(f" manual_review: {counters['manual_review']}")
|
||||
click.echo(f" sample_source: {counters['sample_source']}")
|
||||
click.echo(f" high_season: {counters['high_season']}")
|
||||
click.echo(f" high_episode: {counters['high_episode']}")
|
||||
click.echo(f" conflicts: {counters['conflicts']}")
|
||||
|
||||
click.echo()
|
||||
click.echo(render_review_verdict(counters))
|
||||
|
||||
click.echo()
|
||||
click.echo("High-risk operations preview:")
|
||||
if rows:
|
||||
preview_lines, hidden_count = render_review_preview(
|
||||
rows,
|
||||
preview_limit=preview_limit,
|
||||
show_all=show_all,
|
||||
library_root=ctx.config.library_root,
|
||||
)
|
||||
for line in preview_lines:
|
||||
click.echo(line)
|
||||
if hidden_count > 0:
|
||||
click.echo(
|
||||
f" ... and {hidden_count} more high-risk operations "
|
||||
"(use --show-all to display all)"
|
||||
)
|
||||
else:
|
||||
click.echo(" (none)")
|
||||
|
||||
click.echo()
|
||||
click.echo(f"Saved manual review CSV to: {output}")
|
||||
|
||||
dup_groups = duplicate_groups_from_plan(execution_plan)
|
||||
click.echo()
|
||||
for line in render_review_footer(
|
||||
counters=counters,
|
||||
output_csv=output,
|
||||
plan_input=input,
|
||||
duplicate_groups=dup_groups,
|
||||
):
|
||||
click.echo(line)
|
||||
|
||||
logger.info(
|
||||
"Plan review completed: total=%s high_risk=%s output=%s",
|
||||
counters["total_operations"],
|
||||
counters["high_risk_operations"],
|
||||
output,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Plan file not found: {input}", f"Plan file not found: {input}")
|
||||
except json.JSONDecodeError as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error: Failed to parse plan JSON: {e}",
|
||||
f"Plan review JSON parsing failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error during plan review: {e}",
|
||||
f"Plan review failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def apply_review_cmd(
|
||||
ctx: CLIContext,
|
||||
plan: Path,
|
||||
csv: Path,
|
||||
output: Optional[Path],
|
||||
) -> None:
|
||||
"""Apply modifications from a manual review CSV back to the plan JSON."""
|
||||
logger = ctx.logger
|
||||
output_path = output or plan
|
||||
|
||||
try:
|
||||
click.echo(f"Loading plan: {plan}")
|
||||
execution_plan = load_plan(plan)
|
||||
|
||||
click.echo(f"Applying review from: {csv}")
|
||||
updated_plan = apply_review_to_plan(execution_plan, csv)
|
||||
|
||||
save_plan(updated_plan, output_path)
|
||||
click.echo(f"Successfully updated plan saved to: {output_path}")
|
||||
|
||||
modified = 0
|
||||
for i, op in enumerate(updated_plan.operations):
|
||||
if op.operation_type != execution_plan.operations[i].operation_type:
|
||||
modified += 1
|
||||
|
||||
click.echo(f"Total operations modified: {modified}")
|
||||
logger.info("Applied review from %s to %s, modified %s ops", csv, output_path, modified)
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error applying review: {e}",
|
||||
f"Apply review failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""State tracking CLI command implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error
|
||||
from vlm.context import CLIContext
|
||||
from vlm.state import StateManager
|
||||
|
||||
|
||||
def _state_path() -> Path:
|
||||
return Path.home() / ".vlm" / "state.json"
|
||||
|
||||
|
||||
def state_show_cmd(ctx: CLIContext, file: Path) -> None:
|
||||
"""Show state for a file."""
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
manager = StateManager(_state_path())
|
||||
file_state = manager.get_file_state(file)
|
||||
|
||||
if file_state is None:
|
||||
click.echo(f"No state found for file: {file}")
|
||||
click.echo("This file has not been tracked yet.")
|
||||
else:
|
||||
click.echo(f"State for file: {file}")
|
||||
click.echo()
|
||||
click.echo(f" Status: {file_state.status}")
|
||||
if file_state.reason:
|
||||
click.echo(f" Reason: {file_state.reason}")
|
||||
click.echo(f" Updated: {file_state.updated_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
logger.info("Showed state for file: %s", file)
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error showing file state: {e}",
|
||||
f"Failed to show file state: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def state_set_cmd(ctx: CLIContext, file: Path, status: str, reason: Optional[str]) -> None:
|
||||
"""Set state for a file."""
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
manager = StateManager(_state_path())
|
||||
manager.set_file_state(file, status, reason)
|
||||
manager.save()
|
||||
|
||||
click.echo(f"✓ State updated for file: {file}")
|
||||
click.echo(f" Status: {status}")
|
||||
if reason:
|
||||
click.echo(f" Reason: {reason}")
|
||||
|
||||
logger.info("Set state for file %s: status=%s, reason=%s", file, status, reason)
|
||||
|
||||
except ValueError as e:
|
||||
command_error(ctx, f"Error: {e}", f"Invalid status: {e}")
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error setting file state: {e}",
|
||||
f"Failed to set file state: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def state_query_cmd(ctx: CLIContext, status: str) -> None:
|
||||
"""Query files by status."""
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
manager = StateManager(_state_path())
|
||||
file_states = manager.query_by_status(status)
|
||||
|
||||
if not file_states:
|
||||
click.echo(f"No files found with status '{status}'.")
|
||||
return
|
||||
|
||||
click.echo(f"Files with status '{status}':")
|
||||
click.echo("=" * 80)
|
||||
click.echo()
|
||||
|
||||
for i, file_state in enumerate(file_states, 1):
|
||||
click.echo(f"[{i}] {file_state.file_path}")
|
||||
if file_state.reason:
|
||||
click.echo(f" Reason: {file_state.reason}")
|
||||
click.echo(f" Updated: {file_state.updated_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
click.echo()
|
||||
|
||||
click.echo("=" * 80)
|
||||
click.echo(f"Total: {len(file_states)} file(s)")
|
||||
|
||||
logger.info("Queried files with status '%s': %s found", status, len(file_states))
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error querying file states: {e}",
|
||||
f"Failed to query file states: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def state_clear_cmd(ctx: CLIContext, file: Path) -> None:
|
||||
"""Clear state for a file."""
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
manager = StateManager(_state_path())
|
||||
file_state = manager.get_file_state(file)
|
||||
|
||||
if file_state is None:
|
||||
click.echo(f"No state found for file: {file}")
|
||||
click.echo("Nothing to clear.")
|
||||
return
|
||||
|
||||
manager.clear_state(file)
|
||||
manager.save()
|
||||
|
||||
click.echo(f"✓ State cleared for file: {file}")
|
||||
logger.info("Cleared state for file: %s", file)
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error clearing file state: {e}",
|
||||
f"Failed to clear file state: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
Reference in New Issue
Block a user