refactor review-plan safety and validation
This commit is contained in:
@@ -568,14 +568,6 @@ def review_plan_cmd(
|
||||
if not _review_plan_tui_streams_ok():
|
||||
click.echo("Error: --tui requires an interactive terminal (TTY)", err=True)
|
||||
sys.exit(1)
|
||||
try:
|
||||
from textual.app import App as _TextualApp # noqa: F401
|
||||
except ImportError:
|
||||
click.echo(
|
||||
'Error: Textual is not installed. Install with: uv pip install -e ".[tui]"',
|
||||
err=True,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
click.echo(f"Loading plan: {input}")
|
||||
execution_plan = load_plan(input)
|
||||
|
||||
@@ -9,6 +9,10 @@ from vlm.models import MovieIdentity, SeriesIdentity
|
||||
from vlm.utils import is_sample_path
|
||||
|
||||
|
||||
class DuplicateResolutionError(ValueError):
|
||||
"""Raised when a duplicate group cannot be resolved deterministically."""
|
||||
|
||||
|
||||
def choose_keep_index(
|
||||
items: list[tuple[Path, Union[MovieIdentity, SeriesIdentity]]],
|
||||
strategy: str,
|
||||
@@ -38,14 +42,20 @@ def choose_keep_index(
|
||||
if strategy == "first_seen":
|
||||
return 0
|
||||
if strategy == "by_quality":
|
||||
if quality_comparison is None or len(quality_comparison) != len(items):
|
||||
return 0 # Fallback to first if quality data missing/mismatched
|
||||
if quality_comparison is None:
|
||||
raise DuplicateResolutionError(
|
||||
"by_quality strategy requires quality comparison data"
|
||||
)
|
||||
if len(quality_comparison) != len(items):
|
||||
raise DuplicateResolutionError(
|
||||
"by_quality strategy requires quality data aligned with duplicate items"
|
||||
)
|
||||
return _by_quality_index(items, quality_comparison)
|
||||
if strategy == "by_reputation":
|
||||
return _by_reputation_index(items, quality_comparison=quality_comparison)
|
||||
if strategy == "by_reputation_quality_time":
|
||||
return _by_reputation_quality_time_index(items, quality_comparison=quality_comparison)
|
||||
return 0
|
||||
raise DuplicateResolutionError(f"Unsupported duplicate strategy: {strategy}")
|
||||
|
||||
|
||||
def _parse_resolution_tier(resolution: Optional[str], path: Path) -> int:
|
||||
|
||||
+70
-20
@@ -108,7 +108,25 @@ class ExecutionEngine:
|
||||
# Execute all operations
|
||||
results = []
|
||||
for i, operation in enumerate(plan.operations):
|
||||
result = self.execute_operation(operation, mode)
|
||||
try:
|
||||
result = self.execute_operation(operation, mode)
|
||||
except Exception as exc: # pragma: no cover - defensive containment
|
||||
error_msg = (
|
||||
f"Unexpected failure during {operation.operation_type}: {exc}"
|
||||
)
|
||||
self.logger.exception(
|
||||
error_msg,
|
||||
extra={
|
||||
"operation_type": "execute",
|
||||
"file_path": f" - {operation.source_path}",
|
||||
},
|
||||
)
|
||||
result = OperationResult(
|
||||
operation=operation,
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
executed_at=utc_now(),
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
# Update transaction and state logs in execute mode
|
||||
@@ -183,6 +201,53 @@ class ExecutionEngine:
|
||||
|
||||
return results, summary, rollback_log
|
||||
|
||||
def _validate_library_root_boundaries(
|
||||
self,
|
||||
operation: FileOperation,
|
||||
executed_at: datetime,
|
||||
) -> OperationResult | None:
|
||||
"""Reject move/rename operations that escape the configured library root."""
|
||||
if not self.config or operation.operation_type not in ("move", "rename"):
|
||||
return None
|
||||
|
||||
if not is_within_root(operation.source_path, self.config.library_root):
|
||||
error_msg = f"Unsafe source outside library root: {operation.source_path}"
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.ERROR,
|
||||
error_msg,
|
||||
operation_type="execute",
|
||||
file_path=operation.source_path,
|
||||
)
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
executed_at=executed_at,
|
||||
)
|
||||
|
||||
if operation.destination_path and not is_within_root(
|
||||
operation.destination_path, self.config.library_root
|
||||
):
|
||||
error_msg = (
|
||||
f"Unsafe destination outside library root: {operation.destination_path}"
|
||||
)
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.ERROR,
|
||||
error_msg,
|
||||
operation_type="execute",
|
||||
file_path=operation.source_path,
|
||||
)
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
executed_at=executed_at,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def execute_operation(
|
||||
self,
|
||||
operation: FileOperation,
|
||||
@@ -277,6 +342,10 @@ class ExecutionEngine:
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
boundary_error = self._validate_library_root_boundaries(operation, executed_at)
|
||||
if boundary_error is not None:
|
||||
return boundary_error
|
||||
|
||||
# Execute based on mode
|
||||
if mode == "dry-run":
|
||||
return self._simulate_operation(operation, executed_at)
|
||||
@@ -352,25 +421,6 @@ class ExecutionEngine:
|
||||
|
||||
# Create destination directory if needed
|
||||
if operation.destination_path:
|
||||
if self.config and not is_within_root(
|
||||
operation.destination_path, self.config.library_root
|
||||
):
|
||||
error_msg = (
|
||||
f"Unsafe destination outside library root: {operation.destination_path}"
|
||||
)
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.ERROR,
|
||||
error_msg,
|
||||
operation_type="execute",
|
||||
file_path=operation.source_path
|
||||
)
|
||||
return OperationResult(
|
||||
operation=operation,
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
executed_at=executed_at
|
||||
)
|
||||
operation.destination_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Perform the move/rename operation
|
||||
|
||||
+97
-15
@@ -1,8 +1,8 @@
|
||||
"""Unified I/O layer for inventory and identities data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
@@ -10,16 +10,19 @@ from vlm.models import (
|
||||
AnalysisJSON,
|
||||
AnalysisCompletenessRecord,
|
||||
AnalysisDuplicateRecord,
|
||||
ExecutionPlan,
|
||||
FileOperation,
|
||||
MovieIdentity,
|
||||
MovieIdentityRecord,
|
||||
ParsedIdentitiesJSON,
|
||||
PlanJSON,
|
||||
PlanOperationRecord,
|
||||
SeriesIdentity,
|
||||
SeriesIdentityRecord,
|
||||
VideoFile,
|
||||
)
|
||||
from vlm.utils import utc_now
|
||||
from vlm.utils import ensure_utc, utc_now
|
||||
|
||||
# Re-export scanner CSV functions so CLI and others use a single I/O entry point
|
||||
from vlm.scanner import load_inventory_csv, save_inventory_csv
|
||||
|
||||
__all__ = [
|
||||
@@ -31,6 +34,11 @@ __all__ = [
|
||||
"save_identities_json",
|
||||
"load_analysis_json",
|
||||
"save_analysis_json",
|
||||
"validate_plan_json",
|
||||
"execution_plan_from_record",
|
||||
"execution_plan_to_record",
|
||||
"load_execution_plan",
|
||||
"save_execution_plan",
|
||||
"identities_to_plan_input",
|
||||
"identities_to_analysis_input",
|
||||
]
|
||||
@@ -222,7 +230,21 @@ def _validate_analysis_json(data: object) -> AnalysisJSON:
|
||||
return mapping # type: ignore[return-value]
|
||||
|
||||
|
||||
def validate_plan_json(data: object) -> dict:
|
||||
def _validate_plan_operation_record(record: object, *, label: str) -> PlanOperationRecord:
|
||||
operation = _ensure_dict(record, label)
|
||||
_ensure_str(operation.get("operation_type"), f"{label}.operation_type")
|
||||
_ensure_str(operation.get("source_path"), f"{label}.source_path")
|
||||
if operation.get("destination_path") is not None:
|
||||
_ensure_str(operation.get("destination_path"), f"{label}.destination_path")
|
||||
_ensure_str(operation.get("reason"), f"{label}.reason")
|
||||
_ensure_bool(operation.get("has_conflict"), f"{label}.has_conflict")
|
||||
if "conflict_reason" in operation and operation["conflict_reason"] is not None:
|
||||
_ensure_str(operation["conflict_reason"], f"{label}.conflict_reason")
|
||||
return operation # type: ignore[return-value]
|
||||
|
||||
|
||||
|
||||
def validate_plan_json(data: object) -> PlanJSON:
|
||||
"""Validate the on-disk execution plan schema."""
|
||||
mapping = _ensure_dict(data, "plan JSON")
|
||||
_ensure_str(mapping.get("vlm_schema_version"), "plan JSON.vlm_schema_version")
|
||||
@@ -237,18 +259,78 @@ def validate_plan_json(data: object) -> dict:
|
||||
_ensure_dict(mapping["metadata"], "plan JSON.metadata")
|
||||
|
||||
operations = _ensure_list(mapping.get("operations", []), "plan JSON.operations")
|
||||
for idx, item in enumerate(operations):
|
||||
operation = _ensure_dict(item, f"plan JSON.operations[{idx}]")
|
||||
_ensure_str(operation.get("operation_type"), f"plan JSON.operations[{idx}].operation_type")
|
||||
_ensure_str(operation.get("source_path"), f"plan JSON.operations[{idx}].source_path")
|
||||
if operation.get("destination_path") is not None:
|
||||
_ensure_str(operation.get("destination_path"), f"plan JSON.operations[{idx}].destination_path")
|
||||
_ensure_str(operation.get("reason"), f"plan JSON.operations[{idx}].reason")
|
||||
_ensure_bool(operation.get("has_conflict"), f"plan JSON.operations[{idx}].has_conflict")
|
||||
if "conflict_reason" in operation and operation["conflict_reason"] is not None:
|
||||
_ensure_str(operation["conflict_reason"], f"plan JSON.operations[{idx}].conflict_reason")
|
||||
mapping["operations"] = [
|
||||
_validate_plan_operation_record(item, label=f"plan JSON.operations[{idx}]")
|
||||
for idx, item in enumerate(operations)
|
||||
]
|
||||
|
||||
return mapping # type: ignore[return-value]
|
||||
|
||||
|
||||
|
||||
def execution_plan_to_record(plan: ExecutionPlan) -> PlanJSON:
|
||||
"""Serialize a typed execution plan into the canonical JSON record."""
|
||||
plan_record: PlanJSON = {
|
||||
"vlm_schema_version": "1.0",
|
||||
"plan_id": plan.plan_id,
|
||||
"created_at": plan.created_at.isoformat(),
|
||||
"operations": [
|
||||
{
|
||||
"operation_type": op.operation_type,
|
||||
"source_path": str(op.source_path),
|
||||
"destination_path": str(op.destination_path) if op.destination_path else None,
|
||||
"reason": op.reason,
|
||||
"has_conflict": op.has_conflict,
|
||||
"conflict_reason": op.conflict_reason,
|
||||
}
|
||||
for op in plan.operations
|
||||
],
|
||||
"summary": plan.summary,
|
||||
"summary_by_reason": plan.summary_by_reason,
|
||||
"human_summary": plan.human_summary,
|
||||
"metadata": plan.metadata,
|
||||
}
|
||||
return validate_plan_json(plan_record)
|
||||
|
||||
|
||||
|
||||
def execution_plan_from_record(data: object) -> ExecutionPlan:
|
||||
"""Construct a typed execution plan after schema validation."""
|
||||
plan_dict = validate_plan_json(data)
|
||||
operations = [
|
||||
FileOperation(
|
||||
operation_type=op["operation_type"],
|
||||
source_path=Path(op["source_path"]),
|
||||
destination_path=Path(op["destination_path"]) if op["destination_path"] else None,
|
||||
reason=op["reason"],
|
||||
has_conflict=op["has_conflict"],
|
||||
conflict_reason=op.get("conflict_reason"),
|
||||
)
|
||||
for op in plan_dict["operations"]
|
||||
]
|
||||
created_at = ensure_utc(datetime.fromisoformat(plan_dict["created_at"]))
|
||||
return ExecutionPlan(
|
||||
plan_id=plan_dict["plan_id"],
|
||||
created_at=created_at,
|
||||
operations=operations,
|
||||
summary=plan_dict["summary"],
|
||||
summary_by_reason=plan_dict.get("summary_by_reason", {}),
|
||||
human_summary=plan_dict.get("human_summary", ""),
|
||||
metadata=plan_dict.get("metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
def load_execution_plan(path: Path) -> ExecutionPlan:
|
||||
"""Load an execution plan from disk through the validated typed boundary."""
|
||||
return execution_plan_from_record(load_json_file(path))
|
||||
|
||||
|
||||
|
||||
def save_execution_plan(plan: ExecutionPlan, path: Path) -> None:
|
||||
"""Persist an execution plan via the canonical validated JSON record."""
|
||||
save_json_file(execution_plan_to_record(plan), path)
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
def load_analysis_json(path: Path) -> AnalysisJSON:
|
||||
|
||||
@@ -363,3 +363,23 @@ class AnalysisJSON(TypedDict, total=False):
|
||||
completeness: list[AnalysisCompletenessRecord]
|
||||
duplicates: list[AnalysisDuplicateRecord]
|
||||
|
||||
|
||||
class PlanOperationRecord(TypedDict, total=False):
|
||||
operation_type: str
|
||||
source_path: str
|
||||
destination_path: str | None
|
||||
reason: str
|
||||
has_conflict: bool
|
||||
conflict_reason: str | None
|
||||
|
||||
|
||||
class PlanJSON(TypedDict, total=False):
|
||||
vlm_schema_version: str
|
||||
plan_id: str
|
||||
created_at: str
|
||||
operations: list[PlanOperationRecord]
|
||||
summary: dict[str, int]
|
||||
summary_by_reason: dict[str, int]
|
||||
human_summary: str
|
||||
metadata: dict[str, object]
|
||||
|
||||
|
||||
+110
-70
@@ -4,17 +4,24 @@ This module generates structured execution plans that specify how video files
|
||||
should be organized based on their parsed identities and configuration templates.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from vlm.config import Config
|
||||
from vlm.duplicate_resolve import choose_keep_index
|
||||
from vlm.io import validate_plan_json
|
||||
from vlm.utils import ensure_utc, is_sample_path, is_within_root, sanitize_path_component, utc_now
|
||||
from vlm.duplicate_resolve import DuplicateResolutionError, choose_keep_index
|
||||
from vlm.io import (
|
||||
load_execution_plan,
|
||||
save_execution_plan,
|
||||
)
|
||||
from vlm.utils import (
|
||||
canonical_path_str,
|
||||
is_sample_path,
|
||||
is_within_root,
|
||||
sanitize_path_component,
|
||||
utc_now,
|
||||
)
|
||||
from vlm.models import (
|
||||
ExecutionPlan,
|
||||
FileOperation,
|
||||
@@ -32,6 +39,42 @@ NO_OP_REASON_SEASON_OUT_OF_RANGE = "Series needs manual review (season exceeds c
|
||||
NO_OP_REASON_EPISODE_OUT_OF_RANGE = "Series needs manual review (episode exceeds configured threshold)"
|
||||
|
||||
|
||||
def _normalized_path_key(path_value: str | Path) -> str:
|
||||
"""Normalize path-like values for duplicate-group matching."""
|
||||
text = str(path_value).strip()
|
||||
if not text:
|
||||
return ""
|
||||
return canonical_path_str(Path(text.replace("\\", "/")))
|
||||
|
||||
|
||||
def _build_duplicate_quality_lookup(quality_comparison: list[dict]) -> dict[str, dict]:
|
||||
"""Index duplicate quality entries by canonicalized path."""
|
||||
lookup: dict[str, dict] = {}
|
||||
for quality in quality_comparison:
|
||||
quality_path = quality.get("path")
|
||||
if isinstance(quality_path, str) and quality_path.strip():
|
||||
lookup[_normalized_path_key(quality_path)] = quality
|
||||
return lookup
|
||||
|
||||
|
||||
def _mark_duplicate_group_manual_review(
|
||||
operations: list[FileOperation],
|
||||
indices: list[int],
|
||||
message: str,
|
||||
) -> None:
|
||||
"""Convert unresolved duplicate operations into explicit manual-review no-ops."""
|
||||
for index in indices:
|
||||
current = operations[index]
|
||||
operations[index] = FileOperation(
|
||||
operation_type="no-op",
|
||||
source_path=current.source_path,
|
||||
destination_path=None,
|
||||
reason=f"Duplicate group needs manual review: {message}",
|
||||
has_conflict=False,
|
||||
conflict_reason=None,
|
||||
)
|
||||
|
||||
|
||||
def _analyze_directory_impact(operations: list[FileOperation]) -> dict:
|
||||
"""Analyze which directories will be emptied by the plan."""
|
||||
# Get all source directories that have files being moved/renamed
|
||||
@@ -99,17 +142,31 @@ def generate_plan(
|
||||
|
||||
metadata: dict = {}
|
||||
validation_snapshot: dict[str, object] = {}
|
||||
duplicate_resolution_issues: list[dict[str, object]] = []
|
||||
if analysis_data is not None:
|
||||
validation_snapshot["analysis_source"] = analysis_data.get("metadata", {}).get("source_identities", "")
|
||||
validation_snapshot["duplicate_groups_considered"] = len(analysis_data.get("duplicates", []))
|
||||
validation_snapshot["completeness_seasons_with_gaps"] = len(analysis_data.get("completeness", []))
|
||||
|
||||
if config.duplicate_keep != "manual":
|
||||
path_to_index = {str(vf.path): i for i, (vf, _) in enumerate(identities)}
|
||||
path_to_index = {
|
||||
_normalized_path_key(vf.path): i for i, (vf, _) in enumerate(identities)
|
||||
}
|
||||
for dup in analysis_data.get("duplicates", []):
|
||||
paths = dup.get("files", [])
|
||||
indices = [path_to_index[p] for p in paths if p in path_to_index]
|
||||
path_to_qc = {qc.get("path"): qc for qc in dup.get("quality_comparison", [])}
|
||||
normalized_paths = [
|
||||
_normalized_path_key(path)
|
||||
for path in paths
|
||||
if isinstance(path, str) and path.strip()
|
||||
]
|
||||
indices = [
|
||||
path_to_index[path_key]
|
||||
for path_key in normalized_paths
|
||||
if path_key in path_to_index
|
||||
]
|
||||
path_to_qc = _build_duplicate_quality_lookup(
|
||||
dup.get("quality_comparison", [])
|
||||
)
|
||||
items = []
|
||||
valid_indices = []
|
||||
for i in indices:
|
||||
@@ -125,11 +182,25 @@ def generate_plan(
|
||||
if not items:
|
||||
continue
|
||||
quality_list = [
|
||||
path_to_qc.get(str(p), {}) for p, _ in items
|
||||
path_to_qc.get(_normalized_path_key(path), {}) for path, _ in items
|
||||
]
|
||||
keep_idx = choose_keep_index(
|
||||
items, config.duplicate_keep, quality_comparison=quality_list
|
||||
)
|
||||
try:
|
||||
if config.duplicate_keep == "by_quality" and any(not qc for qc in quality_list):
|
||||
raise DuplicateResolutionError(
|
||||
"missing quality comparison entries for one or more duplicate items"
|
||||
)
|
||||
keep_idx = choose_keep_index(
|
||||
items, config.duplicate_keep, quality_comparison=quality_list
|
||||
)
|
||||
except DuplicateResolutionError as exc:
|
||||
issue = {
|
||||
"strategy": config.duplicate_keep,
|
||||
"reason": str(exc),
|
||||
"files": [str(path) for path, _ in items],
|
||||
}
|
||||
duplicate_resolution_issues.append(issue)
|
||||
_mark_duplicate_group_manual_review(operations, valid_indices, str(exc))
|
||||
continue
|
||||
if keep_idx is None:
|
||||
continue
|
||||
keep_identity_index = valid_indices[keep_idx]
|
||||
@@ -165,14 +236,18 @@ def generate_plan(
|
||||
validation_snapshot["emptied_directories"] = [str(d) for d in directory_analysis["emptied_directories"]]
|
||||
validation_snapshot["directory_warning"] = True
|
||||
|
||||
summary = _generate_summary(operations)
|
||||
summary_by_reason = _generate_summary_by_reason(operations)
|
||||
human_summary = _generate_human_summary(operations, summary, summary_by_reason, metadata)
|
||||
if duplicate_resolution_issues:
|
||||
validation_snapshot["duplicate_resolution_issues"] = duplicate_resolution_issues
|
||||
metadata["duplicate_resolution_issues"] = duplicate_resolution_issues
|
||||
|
||||
if validation_snapshot:
|
||||
validation_snapshot["captured_at"] = utc_now().isoformat()
|
||||
metadata["validation_snapshot"] = validation_snapshot
|
||||
|
||||
summary = _generate_summary(operations)
|
||||
summary_by_reason = _generate_summary_by_reason(operations)
|
||||
human_summary = _generate_human_summary(operations, summary, summary_by_reason, metadata)
|
||||
|
||||
return ExecutionPlan(
|
||||
plan_id=str(uuid.uuid4()),
|
||||
created_at=utc_now(),
|
||||
@@ -539,10 +614,25 @@ def _generate_human_summary(
|
||||
if conflicts > 0:
|
||||
parts.append(f"冲突 {conflicts} 条。")
|
||||
if metadata:
|
||||
dup = metadata.get("duplicate_groups_considered", 0)
|
||||
gaps = metadata.get("completeness_seasons_with_gaps", 0)
|
||||
validation = metadata.get("validation_snapshot", {}) if isinstance(metadata.get("validation_snapshot"), dict) else {}
|
||||
dup = metadata.get(
|
||||
"duplicate_groups_considered",
|
||||
validation.get("duplicate_groups_considered", 0),
|
||||
)
|
||||
gaps = metadata.get(
|
||||
"completeness_seasons_with_gaps",
|
||||
validation.get("completeness_seasons_with_gaps", 0),
|
||||
)
|
||||
if dup or gaps:
|
||||
parts.append(f"依据 analysis:重复组 {dup} 个;剧集缺口 {gaps} 季。")
|
||||
resolution_issues = metadata.get(
|
||||
"duplicate_resolution_issues",
|
||||
validation.get("duplicate_resolution_issues", []),
|
||||
)
|
||||
if resolution_issues:
|
||||
parts.append(
|
||||
f"重复组中有 {len(resolution_issues)} 个因决策依据不足已转人工复核。"
|
||||
)
|
||||
quarantine_lines = _build_quarantine_recommendation_lines(operations)
|
||||
if quarantine_lines:
|
||||
parts.append("删除建议(仅隔离建议,执行删除前请人工复核):")
|
||||
@@ -598,31 +688,8 @@ def save_plan(plan: ExecutionPlan, output_path: Path) -> None:
|
||||
plan: ExecutionPlan to save
|
||||
output_path: Path where the JSON file should be saved
|
||||
"""
|
||||
# Convert ExecutionPlan to dictionary
|
||||
plan_dict = {
|
||||
"vlm_schema_version": "1.0",
|
||||
"plan_id": plan.plan_id,
|
||||
"created_at": plan.created_at.isoformat(),
|
||||
"operations": [
|
||||
{
|
||||
"operation_type": op.operation_type,
|
||||
"source_path": str(op.source_path),
|
||||
"destination_path": str(op.destination_path) if op.destination_path else None,
|
||||
"reason": op.reason,
|
||||
"has_conflict": op.has_conflict,
|
||||
"conflict_reason": op.conflict_reason
|
||||
}
|
||||
for op in plan.operations
|
||||
],
|
||||
"summary": plan.summary,
|
||||
"summary_by_reason": plan.summary_by_reason,
|
||||
"human_summary": plan.human_summary,
|
||||
"metadata": plan.metadata,
|
||||
}
|
||||
|
||||
# Write to JSON file with indentation for human readability
|
||||
with open(output_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(validate_plan_json(plan_dict), f, indent=2, ensure_ascii=False)
|
||||
save_execution_plan(plan, output_path)
|
||||
|
||||
|
||||
|
||||
def load_plan(input_path: Path) -> ExecutionPlan:
|
||||
@@ -642,34 +709,7 @@ def load_plan(input_path: Path) -> ExecutionPlan:
|
||||
json.JSONDecodeError: If the file contains invalid JSON
|
||||
KeyError: If required fields are missing from the JSON
|
||||
"""
|
||||
with open(input_path, 'r', encoding='utf-8') as f:
|
||||
plan_dict = validate_plan_json(json.load(f))
|
||||
|
||||
# Reconstruct FileOperation objects
|
||||
operations = [
|
||||
FileOperation(
|
||||
operation_type=op["operation_type"],
|
||||
source_path=Path(op["source_path"]),
|
||||
destination_path=Path(op["destination_path"]) if op["destination_path"] else None,
|
||||
reason=op["reason"],
|
||||
has_conflict=op["has_conflict"],
|
||||
conflict_reason=op.get("conflict_reason")
|
||||
)
|
||||
for op in plan_dict["operations"]
|
||||
]
|
||||
|
||||
# Reconstruct ExecutionPlan (normalize naive datetime to UTC for backward compatibility)
|
||||
created_at = ensure_utc(datetime.fromisoformat(plan_dict["created_at"]))
|
||||
return ExecutionPlan(
|
||||
plan_id=plan_dict["plan_id"],
|
||||
created_at=created_at,
|
||||
operations=operations,
|
||||
summary=plan_dict["summary"],
|
||||
summary_by_reason=plan_dict.get("summary_by_reason", {}),
|
||||
human_summary=plan_dict.get("human_summary", ""),
|
||||
metadata=plan_dict.get("metadata", {}),
|
||||
)
|
||||
|
||||
return load_execution_plan(input_path)
|
||||
|
||||
def apply_review_to_plan(plan: ExecutionPlan, csv_path: Path) -> ExecutionPlan:
|
||||
"""Update a plan's operations based on a modified review CSV.
|
||||
|
||||
+12
-1
@@ -126,7 +126,18 @@ class QuarantineManager:
|
||||
operation_type="quarantine",
|
||||
file_path=file_path
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
return OperationResult(
|
||||
operation=FileOperation(
|
||||
operation_type="quarantine",
|
||||
source_path=file_path,
|
||||
destination_path=None,
|
||||
reason=reason or "Unsupported quarantine category",
|
||||
has_conflict=False,
|
||||
),
|
||||
success=False,
|
||||
error_message=error_msg,
|
||||
executed_at=executed_at,
|
||||
)
|
||||
|
||||
# Get the actual category directory name from the file path
|
||||
# (not the category name, which may differ due to category mappings)
|
||||
|
||||
+335
-319
@@ -5,14 +5,6 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
|
||||
from textual.geometry import Size
|
||||
from textual.screen import ModalScreen, Screen
|
||||
from textual.widgets import DataTable, Footer, Static
|
||||
|
||||
from vlm.plan_review import save_review_csv
|
||||
from vlm.review_display import (
|
||||
build_csv_rows,
|
||||
@@ -21,14 +13,21 @@ from vlm.review_display import (
|
||||
risk_flags_to_labels,
|
||||
)
|
||||
|
||||
try:
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Container, Horizontal, ScrollableContainer
|
||||
from textual.geometry import Size
|
||||
from textual.screen import ModalScreen, Screen
|
||||
from textual.widgets import DataTable, Footer, Static
|
||||
except ImportError as exc: # pragma: no cover - exercised via runtime fallback
|
||||
TEXTUAL_IMPORT_ERROR: ImportError | None = exc
|
||||
else:
|
||||
TEXTUAL_IMPORT_ERROR = None
|
||||
|
||||
def _index_from_row_key(row_key) -> int: # noqa: ANN001 - RowKey | str
|
||||
if isinstance(row_key, str):
|
||||
return int(row_key)
|
||||
val = getattr(row_key, "value", None)
|
||||
if val is None:
|
||||
return int(row_key)
|
||||
return int(val)
|
||||
|
||||
MISSING_TEXTUAL_MESSAGE = 'Textual is not installed. Install with: uv pip install -e ".[tui]"'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -43,346 +42,363 @@ class ReviewTUIContext:
|
||||
summary_text: str
|
||||
|
||||
|
||||
class SummaryScreen(Screen):
|
||||
"""Migration summary; Enter continues, q aborts."""
|
||||
if TEXTUAL_IMPORT_ERROR is None:
|
||||
|
||||
BINDINGS = [
|
||||
Binding("enter", "continue_", "继续", show=True),
|
||||
Binding("q", "quit", "退出", show=True),
|
||||
]
|
||||
def _index_from_row_key(row_key) -> int: # noqa: ANN001 - RowKey | str
|
||||
if isinstance(row_key, str):
|
||||
return int(row_key)
|
||||
val = getattr(row_key, "value", None)
|
||||
if val is None:
|
||||
return int(row_key)
|
||||
return int(val)
|
||||
|
||||
def __init__(self, ctx: ReviewTUIContext) -> None:
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
stats_lines = [
|
||||
"---",
|
||||
"计划统计",
|
||||
f" 总操作: {self._ctx.counters['total_operations']}",
|
||||
f" 高危: {self._ctx.counters['high_risk_operations']}",
|
||||
f" manual_review: {self._ctx.counters['manual_review']}",
|
||||
f" sample_source: {self._ctx.counters['sample_source']}",
|
||||
f" high_season: {self._ctx.counters['high_season']}",
|
||||
f" high_episode: {self._ctx.counters['high_episode']}",
|
||||
f" conflicts: {self._ctx.counters['conflicts']}",
|
||||
"",
|
||||
f"将要写入: {self._ctx.output_csv}",
|
||||
"",
|
||||
"默认仅审核标记为高危的操作(与 CSV 行一致)。",
|
||||
"",
|
||||
"Enter 进入审核 · q 退出",
|
||||
class SummaryScreen(Screen):
|
||||
"""Migration summary; Enter continues, q aborts."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("enter", "continue_", "继续", show=True),
|
||||
Binding("q", "quit", "退出", show=True),
|
||||
]
|
||||
body = self._ctx.summary_text + "\n\n" + "\n".join(stats_lines)
|
||||
yield ScrollableContainer(Static(body, id="summary_body"))
|
||||
yield Footer()
|
||||
|
||||
def action_continue_(self) -> None:
|
||||
self.dismiss(True)
|
||||
def __init__(self, ctx: ReviewTUIContext) -> None:
|
||||
super().__init__()
|
||||
self._ctx = ctx
|
||||
|
||||
def action_quit(self) -> None:
|
||||
self.dismiss(False)
|
||||
def compose(self) -> ComposeResult:
|
||||
stats_lines = [
|
||||
"---",
|
||||
"计划统计",
|
||||
f" 总操作: {self._ctx.counters['total_operations']}",
|
||||
f" 高危: {self._ctx.counters['high_risk_operations']}",
|
||||
f" manual_review: {self._ctx.counters['manual_review']}",
|
||||
f" sample_source: {self._ctx.counters['sample_source']}",
|
||||
f" high_season: {self._ctx.counters['high_season']}",
|
||||
f" high_episode: {self._ctx.counters['high_episode']}",
|
||||
f" conflicts: {self._ctx.counters['conflicts']}",
|
||||
"",
|
||||
f"将要写入: {self._ctx.output_csv}",
|
||||
"",
|
||||
"默认仅审核标记为高危的操作(与 CSV 行一致)。",
|
||||
"",
|
||||
"Enter 进入审核 · q 退出",
|
||||
]
|
||||
body = self._ctx.summary_text + "\n\n" + "\n".join(stats_lines)
|
||||
yield ScrollableContainer(Static(body, id="summary_body"))
|
||||
yield Footer()
|
||||
|
||||
def action_continue_(self) -> None:
|
||||
self.dismiss(True)
|
||||
|
||||
def action_quit(self) -> None:
|
||||
self.dismiss(False)
|
||||
|
||||
|
||||
class ConfirmDiscardScreen(ModalScreen[bool]):
|
||||
"""Confirm discarding unsaved edits."""
|
||||
class ConfirmDiscardScreen(ModalScreen[bool]):
|
||||
"""Confirm discarding unsaved edits."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("y", "yes", show=False),
|
||||
Binding("n", "no", show=False),
|
||||
]
|
||||
BINDINGS = [
|
||||
Binding("y", "yes", show=False),
|
||||
Binding("n", "no", show=False),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Container(
|
||||
Static("未保存的修改将丢失。放弃? (y / n)", id="confirm_text"),
|
||||
id="confirm_box",
|
||||
)
|
||||
|
||||
def action_yes(self) -> None:
|
||||
self.dismiss(True)
|
||||
|
||||
def action_no(self) -> None:
|
||||
self.dismiss(False)
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ConfirmDiscardScreen {
|
||||
align: center middle;
|
||||
}
|
||||
#confirm_box {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
border: thick $primary;
|
||||
background: $surface;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class ReviewMainScreen(Screen):
|
||||
"""High-risk table + detail pane."""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("up", "cursor_up", show=False),
|
||||
Binding("down", "cursor_down", show=False),
|
||||
Binding("k", "cursor_up", show=False),
|
||||
Binding("j", "cursor_down", show=False),
|
||||
Binding("a", "keep_row", "保留", show=True),
|
||||
Binding("r", "reject_row", "驳回", show=True),
|
||||
Binding("u", "undo_row", "撤销", show=True),
|
||||
Binding("s", "save", "保存", show=True),
|
||||
Binding("q", "request_quit", "退出", show=True),
|
||||
]
|
||||
|
||||
def __init__(self, ctx: ReviewTUIContext) -> None:
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._by_index: dict[int, dict[str, str]] = {
|
||||
int(r["index"]): r for r in ctx.rows
|
||||
}
|
||||
self.initial_op_by_index: dict[int, str] = {
|
||||
int(r["index"]): r["operation_type"] for r in ctx.rows
|
||||
}
|
||||
self.op_by_index: dict[int, str] = dict(self.initial_op_by_index)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
plan_s = str(self.ctx.plan_input)
|
||||
if len(plan_s) > 72:
|
||||
plan_s = plan_s[:35] + "…" + plan_s[-34:]
|
||||
hdr = (
|
||||
f"{plan_s} · 高危 {self.ctx.counters['high_risk_operations']}"
|
||||
f" / {self.ctx.counters['total_operations']}"
|
||||
)
|
||||
yield Static(hdr, id="header_line")
|
||||
with Horizontal(id="body"):
|
||||
yield DataTable(id="review_table", cursor_type="row", zebra_stripes=True)
|
||||
with ScrollableContainer(id="detail_scroll"):
|
||||
yield Static("", id="detail_text")
|
||||
yield Static(
|
||||
"↑↓ j/k 移动 · a 保留 · r 驳回(no-op) · u 撤销本条 · s 保存退出 · q 退出",
|
||||
id="footer_line",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
DEFAULT_CSS = """
|
||||
#header_line {
|
||||
dock: top;
|
||||
padding: 0 1;
|
||||
background: $primary-darken-2;
|
||||
color: $text;
|
||||
}
|
||||
#footer_line {
|
||||
dock: bottom;
|
||||
padding: 0 1;
|
||||
background: $panel;
|
||||
color: $text-muted;
|
||||
}
|
||||
#body {
|
||||
layout: horizontal;
|
||||
height: 1fr;
|
||||
}
|
||||
#body.vertical-split {
|
||||
layout: vertical;
|
||||
height: 1fr;
|
||||
}
|
||||
#review_table {
|
||||
width: 1fr;
|
||||
min-height: 5;
|
||||
}
|
||||
#body.vertical-split #review_table {
|
||||
height: 40%;
|
||||
}
|
||||
#detail_scroll {
|
||||
width: 1fr;
|
||||
min-height: 5;
|
||||
border-left: solid $primary-darken-3;
|
||||
padding: 0 1;
|
||||
}
|
||||
#body.vertical-split #detail_scroll {
|
||||
border-left: none;
|
||||
border-top: solid $primary-darken-3;
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
def on_mount(self) -> None:
|
||||
table = self.query_one("#review_table", DataTable)
|
||||
table.cursor_type = "row"
|
||||
table.add_column(" ", key="sym", width=3)
|
||||
table.add_column("#", key="idx", width=4)
|
||||
table.add_column("类型", key="op", width=11)
|
||||
table.add_column("风险", key="risk", width=18)
|
||||
table.add_column("文件", key="file")
|
||||
|
||||
for r in self.ctx.rows:
|
||||
idx = int(r["index"])
|
||||
sym = self._symbol_for(idx)
|
||||
table.add_row(
|
||||
sym,
|
||||
str(idx),
|
||||
self.op_by_index[idx],
|
||||
risk_flags_to_labels(r["risk_flags"]),
|
||||
Path(r["source_path"]).name,
|
||||
key=str(idx),
|
||||
)
|
||||
self._apply_body_layout(self.app.size)
|
||||
if table.row_count > 0:
|
||||
table.focus()
|
||||
self._refresh_detail(_index_from_row_key(table.ordered_rows[0].key))
|
||||
else:
|
||||
self.query_one("#detail_text", Static).update(
|
||||
"无高危项。按 s 保存仅含表头的 CSV(与无 --tui 行为一致)。"
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Container(
|
||||
Static("未保存的修改将丢失。放弃? (y / n)", id="confirm_text"),
|
||||
id="confirm_box",
|
||||
)
|
||||
|
||||
def _table(self) -> DataTable:
|
||||
return self.query_one("#review_table", DataTable)
|
||||
def action_yes(self) -> None:
|
||||
self.dismiss(True)
|
||||
|
||||
def _symbol_for(self, index: int) -> str:
|
||||
return review_row_status_symbol(
|
||||
self.op_by_index,
|
||||
self.initial_op_by_index,
|
||||
index,
|
||||
)
|
||||
def action_no(self) -> None:
|
||||
self.dismiss(False)
|
||||
|
||||
def _apply_body_layout(self, app_size: Size) -> None:
|
||||
body = self.query_one("#body", Horizontal)
|
||||
if app_size.width < 100:
|
||||
body.add_class("vertical-split")
|
||||
else:
|
||||
body.remove_class("vertical-split")
|
||||
DEFAULT_CSS = """
|
||||
ConfirmDiscardScreen {
|
||||
align: center middle;
|
||||
}
|
||||
#confirm_box {
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 1 2;
|
||||
border: thick $primary;
|
||||
background: $surface;
|
||||
}
|
||||
"""
|
||||
|
||||
def on_resize(self, event) -> None: # noqa: ANN001 - textual Resize
|
||||
self._apply_body_layout(self.app.size)
|
||||
|
||||
def _current_index(self) -> int | None:
|
||||
table = self._table()
|
||||
if table.row_count == 0:
|
||||
return None
|
||||
row_index = table.cursor_coordinate.row
|
||||
row = table.ordered_rows[row_index]
|
||||
return _index_from_row_key(row.key)
|
||||
class ReviewMainScreen(Screen):
|
||||
"""High-risk table + detail pane."""
|
||||
|
||||
@on(DataTable.RowHighlighted) # type: ignore[misc]
|
||||
def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||
if event.data_table.id != "review_table":
|
||||
return
|
||||
idx = _index_from_row_key(event.row_key)
|
||||
self._refresh_detail(idx)
|
||||
BINDINGS = [
|
||||
Binding("up", "cursor_up", show=False),
|
||||
Binding("down", "cursor_down", show=False),
|
||||
Binding("k", "cursor_up", show=False),
|
||||
Binding("j", "cursor_down", show=False),
|
||||
Binding("a", "keep_row", "保留", show=True),
|
||||
Binding("r", "reject_row", "驳回", show=True),
|
||||
Binding("u", "undo_row", "撤销", show=True),
|
||||
Binding("s", "save", "保存", show=True),
|
||||
Binding("q", "request_quit", "退出", show=True),
|
||||
]
|
||||
|
||||
def _refresh_detail(self, index: int) -> None:
|
||||
r = self._by_index[index]
|
||||
op = self.op_by_index[index]
|
||||
paths = format_paths_for_detail(
|
||||
r["source_path"],
|
||||
r.get("destination_path", ""),
|
||||
self.ctx.library_root,
|
||||
)
|
||||
risk_cn = risk_flags_to_labels(r["risk_flags"], max_len=120)
|
||||
summary = (
|
||||
f"#{index} · {op} · {Path(r['source_path']).name}"
|
||||
+ (f" · {risk_cn}" if risk_cn else "")
|
||||
)
|
||||
text = (
|
||||
f"{summary}\n\n"
|
||||
f"变更\n{paths}\n\n"
|
||||
f"依据\n{r['reason']}\n\n"
|
||||
f"标记\n{r['risk_flags']}"
|
||||
)
|
||||
self.query_one("#detail_text", Static).update(text)
|
||||
def __init__(self, ctx: ReviewTUIContext) -> None:
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._by_index: dict[int, dict[str, str]] = {
|
||||
int(r["index"]): r for r in ctx.rows
|
||||
}
|
||||
self.initial_op_by_index: dict[int, str] = {
|
||||
int(r["index"]): r["operation_type"] for r in ctx.rows
|
||||
}
|
||||
self.op_by_index: dict[int, str] = dict(self.initial_op_by_index)
|
||||
|
||||
def _refresh_row_cells(self, index: int) -> None:
|
||||
table = self._table()
|
||||
key = str(index)
|
||||
sym = self._symbol_for(index)
|
||||
table.update_cell(key, "sym", sym)
|
||||
table.update_cell(key, "op", self.op_by_index[index])
|
||||
def compose(self) -> ComposeResult:
|
||||
plan_s = str(self.ctx.plan_input)
|
||||
if len(plan_s) > 72:
|
||||
plan_s = plan_s[:35] + "…" + plan_s[-34:]
|
||||
hdr = (
|
||||
f"{plan_s} · 高危 {self.ctx.counters['high_risk_operations']}"
|
||||
f" / {self.ctx.counters['total_operations']}"
|
||||
)
|
||||
yield Static(hdr, id="header_line")
|
||||
with Horizontal(id="body"):
|
||||
yield DataTable(id="review_table", cursor_type="row", zebra_stripes=True)
|
||||
with ScrollableContainer(id="detail_scroll"):
|
||||
yield Static("", id="detail_text")
|
||||
yield Static(
|
||||
"↑↓ j/k 移动 · a 保留 · r 驳回(no-op) · u 撤销本条 · s 保存退出 · q 退出",
|
||||
id="footer_line",
|
||||
)
|
||||
yield Footer()
|
||||
|
||||
def _update_dirty_header(self) -> None:
|
||||
dirty = self._is_dirty()
|
||||
hdr = self.query_one("#header_line", Static)
|
||||
plan_s = str(self.ctx.plan_input)
|
||||
if len(plan_s) > 72:
|
||||
plan_s = plan_s[:35] + "…" + plan_s[-34:]
|
||||
star = " *" if dirty else ""
|
||||
hdr.update(
|
||||
f"{plan_s}{star} · 高危 {self.ctx.counters['high_risk_operations']}"
|
||||
f" / {self.ctx.counters['total_operations']}"
|
||||
)
|
||||
DEFAULT_CSS = """
|
||||
#header_line {
|
||||
dock: top;
|
||||
padding: 0 1;
|
||||
background: $primary-darken-2;
|
||||
color: $text;
|
||||
}
|
||||
#footer_line {
|
||||
dock: bottom;
|
||||
padding: 0 1;
|
||||
background: $panel;
|
||||
color: $text-muted;
|
||||
}
|
||||
#body {
|
||||
layout: horizontal;
|
||||
height: 1fr;
|
||||
}
|
||||
#body.vertical-split {
|
||||
layout: vertical;
|
||||
height: 1fr;
|
||||
}
|
||||
#review_table {
|
||||
width: 1fr;
|
||||
min-height: 5;
|
||||
}
|
||||
#body.vertical-split #review_table {
|
||||
height: 40%;
|
||||
}
|
||||
#detail_scroll {
|
||||
width: 1fr;
|
||||
min-height: 5;
|
||||
border-left: solid $primary-darken-3;
|
||||
padding: 0 1;
|
||||
}
|
||||
#body.vertical-split #detail_scroll {
|
||||
border-left: none;
|
||||
border-top: solid $primary-darken-3;
|
||||
height: 1fr;
|
||||
}
|
||||
"""
|
||||
|
||||
def _is_dirty(self) -> bool:
|
||||
return self.op_by_index != self.initial_op_by_index
|
||||
def on_mount(self) -> None:
|
||||
table = self.query_one("#review_table", DataTable)
|
||||
table.cursor_type = "row"
|
||||
table.add_column(" ", key="sym", width=3)
|
||||
table.add_column("#", key="idx", width=4)
|
||||
table.add_column("类型", key="op", width=11)
|
||||
table.add_column("风险", key="risk", width=18)
|
||||
table.add_column("文件", key="file")
|
||||
|
||||
def action_cursor_up(self) -> None:
|
||||
if self._table().row_count:
|
||||
self._table().action_cursor_up()
|
||||
for r in self.ctx.rows:
|
||||
idx = int(r["index"])
|
||||
sym = self._symbol_for(idx)
|
||||
table.add_row(
|
||||
sym,
|
||||
str(idx),
|
||||
self.op_by_index[idx],
|
||||
risk_flags_to_labels(r["risk_flags"]),
|
||||
Path(r["source_path"]).name,
|
||||
key=str(idx),
|
||||
)
|
||||
self._apply_body_layout(self.app.size)
|
||||
if table.row_count > 0:
|
||||
table.focus()
|
||||
self._refresh_detail(_index_from_row_key(table.ordered_rows[0].key))
|
||||
else:
|
||||
self.query_one("#detail_text", Static).update(
|
||||
"无高危项。按 s 保存仅含表头的 CSV(与无 --tui 行为一致)。"
|
||||
)
|
||||
|
||||
def action_cursor_down(self) -> None:
|
||||
if self._table().row_count:
|
||||
self._table().action_cursor_down()
|
||||
def _table(self) -> DataTable:
|
||||
return self.query_one("#review_table", DataTable)
|
||||
|
||||
def action_keep_row(self) -> None:
|
||||
idx = self._current_index()
|
||||
if idx is None:
|
||||
return
|
||||
self.op_by_index[idx] = self.initial_op_by_index[idx]
|
||||
self._refresh_row_cells(idx)
|
||||
self._refresh_detail(idx)
|
||||
self._update_dirty_header()
|
||||
def _symbol_for(self, index: int) -> str:
|
||||
return review_row_status_symbol(
|
||||
self.op_by_index,
|
||||
self.initial_op_by_index,
|
||||
index,
|
||||
)
|
||||
|
||||
def action_reject_row(self) -> None:
|
||||
idx = self._current_index()
|
||||
if idx is None:
|
||||
return
|
||||
self.op_by_index[idx] = "no-op"
|
||||
self._refresh_row_cells(idx)
|
||||
self._refresh_detail(idx)
|
||||
self._update_dirty_header()
|
||||
def _apply_body_layout(self, app_size: Size) -> None:
|
||||
body = self.query_one("#body", Horizontal)
|
||||
if app_size.width < 100:
|
||||
body.add_class("vertical-split")
|
||||
else:
|
||||
body.remove_class("vertical-split")
|
||||
|
||||
def action_undo_row(self) -> None:
|
||||
self.action_keep_row()
|
||||
def on_resize(self, event) -> None: # noqa: ANN001 - textual Resize
|
||||
self._apply_body_layout(self.app.size)
|
||||
|
||||
def action_save(self) -> None:
|
||||
out_rows = build_csv_rows(self.ctx.rows, self.op_by_index)
|
||||
save_review_csv(out_rows, self.ctx.output_csv)
|
||||
self.dismiss("saved")
|
||||
def _current_index(self) -> int | None:
|
||||
table = self._table()
|
||||
if table.row_count == 0:
|
||||
return None
|
||||
row_index = table.cursor_coordinate.row
|
||||
row = table.ordered_rows[row_index]
|
||||
return _index_from_row_key(row.key)
|
||||
|
||||
def action_request_quit(self) -> None:
|
||||
if not self._is_dirty():
|
||||
self.dismiss("aborted")
|
||||
return
|
||||
@on(DataTable.RowHighlighted) # type: ignore[misc]
|
||||
def on_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||
if event.data_table.id != "review_table":
|
||||
return
|
||||
idx = _index_from_row_key(event.row_key)
|
||||
self._refresh_detail(idx)
|
||||
|
||||
def after_confirm(confirmed: bool | None) -> None:
|
||||
if confirmed:
|
||||
def _refresh_detail(self, index: int) -> None:
|
||||
r = self._by_index[index]
|
||||
op = self.op_by_index[index]
|
||||
paths = format_paths_for_detail(
|
||||
r["source_path"],
|
||||
r.get("destination_path", ""),
|
||||
self.ctx.library_root,
|
||||
)
|
||||
risk_cn = risk_flags_to_labels(r["risk_flags"], max_len=120)
|
||||
summary = (
|
||||
f"#{index} · {op} · {Path(r['source_path']).name}"
|
||||
+ (f" · {risk_cn}" if risk_cn else "")
|
||||
)
|
||||
text = (
|
||||
f"{summary}\n\n"
|
||||
f"变更\n{paths}\n\n"
|
||||
f"依据\n{r['reason']}\n\n"
|
||||
f"标记\n{r['risk_flags']}"
|
||||
)
|
||||
self.query_one("#detail_text", Static).update(text)
|
||||
|
||||
def _refresh_row_cells(self, index: int) -> None:
|
||||
table = self._table()
|
||||
key = str(index)
|
||||
sym = self._symbol_for(index)
|
||||
table.update_cell(key, "sym", sym)
|
||||
table.update_cell(key, "op", self.op_by_index[index])
|
||||
|
||||
def _update_dirty_header(self) -> None:
|
||||
dirty = self._is_dirty()
|
||||
hdr = self.query_one("#header_line", Static)
|
||||
plan_s = str(self.ctx.plan_input)
|
||||
if len(plan_s) > 72:
|
||||
plan_s = plan_s[:35] + "…" + plan_s[-34:]
|
||||
star = " *" if dirty else ""
|
||||
hdr.update(
|
||||
f"{plan_s}{star} · 高危 {self.ctx.counters['high_risk_operations']}"
|
||||
f" / {self.ctx.counters['total_operations']}"
|
||||
)
|
||||
|
||||
def _is_dirty(self) -> bool:
|
||||
return self.op_by_index != self.initial_op_by_index
|
||||
|
||||
def action_cursor_up(self) -> None:
|
||||
if self._table().row_count:
|
||||
self._table().action_cursor_up()
|
||||
|
||||
def action_cursor_down(self) -> None:
|
||||
if self._table().row_count:
|
||||
self._table().action_cursor_down()
|
||||
|
||||
def action_keep_row(self) -> None:
|
||||
idx = self._current_index()
|
||||
if idx is None:
|
||||
return
|
||||
self.op_by_index[idx] = self.initial_op_by_index[idx]
|
||||
self._refresh_row_cells(idx)
|
||||
self._refresh_detail(idx)
|
||||
self._update_dirty_header()
|
||||
|
||||
def action_reject_row(self) -> None:
|
||||
idx = self._current_index()
|
||||
if idx is None:
|
||||
return
|
||||
self.op_by_index[idx] = "no-op"
|
||||
self._refresh_row_cells(idx)
|
||||
self._refresh_detail(idx)
|
||||
self._update_dirty_header()
|
||||
|
||||
def action_undo_row(self) -> None:
|
||||
self.action_keep_row()
|
||||
|
||||
def action_save(self) -> None:
|
||||
out_rows = build_csv_rows(self.ctx.rows, self.op_by_index)
|
||||
save_review_csv(out_rows, self.ctx.output_csv)
|
||||
self.dismiss("saved")
|
||||
|
||||
def action_request_quit(self) -> None:
|
||||
if not self._is_dirty():
|
||||
self.dismiss("aborted")
|
||||
return
|
||||
|
||||
self.app.push_screen(ConfirmDiscardScreen(), callback=after_confirm)
|
||||
def after_confirm(confirmed: bool | None) -> None:
|
||||
if confirmed:
|
||||
self.dismiss("aborted")
|
||||
|
||||
self.app.push_screen(ConfirmDiscardScreen(), callback=after_confirm)
|
||||
|
||||
|
||||
class PlanReviewApp(App):
|
||||
"""Application shell: summary screen then review screen."""
|
||||
class PlanReviewApp(App):
|
||||
"""Application shell: summary screen then review screen."""
|
||||
|
||||
def __init__(self, ctx: ReviewTUIContext) -> None:
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
def __init__(self, ctx: ReviewTUIContext) -> None:
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(SummaryScreen(self.ctx), self._after_summary)
|
||||
def on_mount(self) -> None:
|
||||
self.push_screen(SummaryScreen(self.ctx), self._after_summary)
|
||||
|
||||
def _after_summary(self, result: bool | None) -> None:
|
||||
if not result:
|
||||
self.exit(return_code=1)
|
||||
return
|
||||
self.push_screen(ReviewMainScreen(self.ctx), self._after_main)
|
||||
def _after_summary(self, result: bool | None) -> None:
|
||||
if not result:
|
||||
self.exit(return_code=1)
|
||||
return
|
||||
self.push_screen(ReviewMainScreen(self.ctx), self._after_main)
|
||||
|
||||
def _after_main(self, result: str | None) -> None:
|
||||
if result == "saved":
|
||||
self.exit(return_code=0)
|
||||
else:
|
||||
self.exit(return_code=1)
|
||||
def _after_main(self, result: str | None) -> None:
|
||||
if result == "saved":
|
||||
self.exit(return_code=0)
|
||||
else:
|
||||
self.exit(return_code=1)
|
||||
|
||||
|
||||
def run_plan_review_tui(ctx: ReviewTUIContext) -> int:
|
||||
"""Block until the user finishes the TUI. Returns process exit code."""
|
||||
app = PlanReviewApp(ctx)
|
||||
app.run()
|
||||
code = app.return_code
|
||||
return 0 if code is None else code
|
||||
def run_plan_review_tui(ctx: ReviewTUIContext) -> int:
|
||||
"""Block until the user finishes the TUI. Returns process exit code."""
|
||||
app = PlanReviewApp(ctx)
|
||||
app.run()
|
||||
code = app.return_code
|
||||
return 0 if code is None else code
|
||||
|
||||
else:
|
||||
|
||||
def run_plan_review_tui(ctx: ReviewTUIContext) -> int:
|
||||
"""Raise a friendly error when the optional Textual dependency is missing."""
|
||||
raise RuntimeError(MISSING_TEXTUAL_MESSAGE) from TEXTUAL_IMPORT_ERROR
|
||||
|
||||
+27
-5
@@ -155,6 +155,29 @@ def _discover_video_paths(root: Path, video_extensions: list[str]) -> list[Path]
|
||||
return _discover_video_paths_recursive(root, video_extensions)
|
||||
|
||||
|
||||
def _log_find_nonzero_exit(returncode: int, stderr_text: str, discovered_count: int) -> None:
|
||||
"""Log the explicit contract for non-zero `find` exits.
|
||||
|
||||
Contract: if `find` emits partial stdout before failing, keep those paths and
|
||||
continue with a warning. If no paths were emitted, return an empty result and
|
||||
log that scan discovery was incomplete.
|
||||
"""
|
||||
stderr_suffix = f": {stderr_text}" if stderr_text else ""
|
||||
if discovered_count > 0:
|
||||
logger.warning(
|
||||
"find exited with code %s; using %s partial scan result(s)%s",
|
||||
returncode,
|
||||
discovered_count,
|
||||
stderr_suffix,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"find exited with code %s and produced no scan results%s",
|
||||
returncode,
|
||||
stderr_suffix,
|
||||
)
|
||||
|
||||
|
||||
def _discover_video_paths_with_find(root: Path, video_extensions: list[str]) -> list[Path]:
|
||||
"""Discover matching video files using the system `find` command."""
|
||||
normalized_extensions = [ext.lower() for ext in video_extensions if ext]
|
||||
@@ -175,11 +198,6 @@ def _discover_video_paths_with_find(root: Path, video_extensions: list[str]) ->
|
||||
)
|
||||
stdout, stderr = process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
stderr_text = stderr.decode(errors="replace").strip()
|
||||
if stderr_text:
|
||||
logger.warning(f"find reported issues while scanning: {stderr_text}")
|
||||
|
||||
discovered_paths: list[Path] = []
|
||||
for path_bytes in stdout.split(b"\0"):
|
||||
if not path_bytes:
|
||||
@@ -189,6 +207,10 @@ def _discover_video_paths_with_find(root: Path, video_extensions: list[str]) ->
|
||||
continue
|
||||
discovered_paths.append(file_path)
|
||||
|
||||
if process.returncode != 0:
|
||||
stderr_text = stderr.decode(errors="replace").strip()
|
||||
_log_find_nonzero_exit(process.returncode, stderr_text, len(discovered_paths))
|
||||
|
||||
return discovered_paths
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user