mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
224 lines
8 KiB
Python
224 lines
8 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Copyright (c) 2025 Allaun
|
|
|
|
"""Plan tool for NoDupeLabs.
|
|
|
|
This tool bridges the gap between 'scan' and 'apply' by creating
|
|
an execution plan based on duplicate detection results.
|
|
"""
|
|
|
|
from nodupe.core.tool_system.base import Tool
|
|
from typing import Any, Dict
|
|
import argparse
|
|
import json
|
|
|
|
# Tool manager is injected by the core system
|
|
PM = None
|
|
|
|
|
|
class PlanTool(Tool):
|
|
"""Plan tool implementation.
|
|
|
|
Responsibilities:
|
|
- Register with tool manager
|
|
- Provide plan functionality
|
|
- Handle strategy selection
|
|
"""
|
|
|
|
name = "plan"
|
|
version = "1.0.0"
|
|
dependencies = ["scan", "database"]
|
|
|
|
def __init__(self):
|
|
"""Initialize plan tool."""
|
|
self.description = "Create execution plan from scan results"
|
|
# Hook registration moved to initialize
|
|
|
|
def initialize(self, container: Any) -> None:
|
|
"""Initialize the tool."""
|
|
# Retrieve PM from container if available
|
|
# But global PM usage in this file is legacy.
|
|
# Ideally we use container.get_service('tool_manager')
|
|
|
|
def shutdown(self) -> None:
|
|
"""Shutdown the tool."""
|
|
|
|
def get_capabilities(self) -> Dict[str, Any]:
|
|
"""Get tool capabilities."""
|
|
return {'commands': ['plan'], 'strategies': ['newest', 'oldest', 'interactive']}
|
|
|
|
@property
|
|
def api_methods(self) -> Dict[str, Any]:
|
|
"""Dictionary of methods exposed via programmatic API."""
|
|
return {
|
|
'create_plan': self.execute_plan,
|
|
'get_strategies': lambda: ['newest', 'oldest', 'interactive'],
|
|
}
|
|
|
|
def run_standalone(self, args: list[str]) -> int:
|
|
"""Execute the tool in stand-alone mode."""
|
|
parser = argparse.ArgumentParser(description='Create execution plan from scan results')
|
|
parser.add_argument('--strategy', choices=['newest', 'oldest', 'interactive'],
|
|
default='newest', help='Strategy to select keeper file')
|
|
parser.add_argument('--output', '-o', default='plan.json', help='Output plan file path')
|
|
parsed = parser.parse_args(args)
|
|
return self.execute_plan(parsed)
|
|
|
|
def describe_usage(self) -> str:
|
|
"""Return human-readable usage description."""
|
|
return "Plan tool: Creates an execution plan for handling duplicate files. Use --strategy to choose how to select the keeper file (newest, oldest, or interactive)."
|
|
|
|
def _on_plan_start(self, **kwargs: Any) -> None:
|
|
"""Handle plan start event."""
|
|
print(f"[TOOL] Planning started with strategy: {kwargs.get('strategy', 'unknown')}")
|
|
|
|
def _on_plan_complete(self, **kwargs: Any) -> None:
|
|
"""Handle plan complete event."""
|
|
print(f"[TOOL] Planning completed. Actions generated: {kwargs.get('action_count', 0)}")
|
|
|
|
def register_commands(self, subparsers: Any) -> None:
|
|
"""Register plan command with argument parser.
|
|
|
|
Args:
|
|
subparsers: Argument parser subparsers
|
|
"""
|
|
parser = subparsers.add_parser('plan', help='Create execution plan from scan results')
|
|
parser.add_argument('--strategy', choices=['newest', 'oldest', 'interactive'],
|
|
default='newest', help='Strategy to select keeper file')
|
|
parser.add_argument('--output', '-o', default='plan.json', help='Output plan file path')
|
|
parser.set_defaults(func=self.execute_plan)
|
|
|
|
def execute_plan(self, args: argparse.Namespace) -> int:
|
|
"""Execute plan command.
|
|
|
|
Args:
|
|
args: Parsed arguments
|
|
|
|
Returns:
|
|
Exit code
|
|
"""
|
|
|
|
try:
|
|
print(f"[TOOL] Executing plan with strategy: {args.strategy}")
|
|
|
|
# 1. Get Services
|
|
container = getattr(args, 'container', None)
|
|
if not container:
|
|
from nodupe.core.container import container as global_container
|
|
container = global_container
|
|
|
|
db = container.get_service('database')
|
|
if not db:
|
|
print("[ERROR] Database service not available")
|
|
from nodupe.tools.databases.connection import DatabaseConnection
|
|
db = DatabaseConnection.get_instance()
|
|
|
|
from nodupe.tools.databases.files import FileRepository
|
|
repo = FileRepository(db)
|
|
files = repo.get_all_files()
|
|
|
|
if not files:
|
|
print("[TOOL] No files in database to plan.")
|
|
return 0
|
|
|
|
# 2. Group by Hash
|
|
print(f"[TOOL] Grouping {len(files)} files by hash...")
|
|
groups = {}
|
|
for f in files:
|
|
if not f.get('hash'):
|
|
continue
|
|
if f['hash'] not in groups:
|
|
groups[f['hash']] = []
|
|
groups[f['hash']].append(f)
|
|
|
|
action_plan = []
|
|
stats = {"total_groups": 0, "duplicates_found": 0, "reassigned": 0}
|
|
|
|
# 3. Apply Strategy
|
|
print(f"[TOOL] Applying strategy '{args.strategy}'...")
|
|
for _, group in groups.items():
|
|
if len(group) < 2:
|
|
continue
|
|
|
|
stats["total_groups"] += 1
|
|
|
|
# Sort group based on strategy
|
|
# The first item in sorted list will be the KEEPER (Original)
|
|
if args.strategy == 'newest':
|
|
# Keep newest modified: Sort descending by mtime
|
|
group.sort(key=lambda x: x.get('modified_time', 0), reverse=True)
|
|
elif args.strategy == 'oldest':
|
|
# Keep oldest modified: Sort ascending by mtime
|
|
group.sort(key=lambda x: x.get('modified_time', 0))
|
|
else:
|
|
# Default/Interactive: Keep shortest path length (preferred usually)
|
|
group.sort(key=lambda x: len(x['path']))
|
|
|
|
keeper = group[0]
|
|
duplicates = group[1:]
|
|
|
|
stats["duplicates_found"] += len(duplicates)
|
|
|
|
# 4. Generate Actions & Update DB
|
|
# Ensure keeper is NOT marked as duplicate
|
|
if keeper.get('is_duplicate'):
|
|
repo.mark_as_original(keeper['id'])
|
|
stats["reassigned"] += 1
|
|
|
|
action_plan.append({
|
|
"type": "KEEP",
|
|
"path": keeper['path'],
|
|
"reason": f"Selected by {args.strategy} strategy (id={keeper['id']})"
|
|
})
|
|
|
|
for dup in duplicates:
|
|
# Update DB to point to new keeper
|
|
repo.mark_as_duplicate(dup['id'], keeper['id'])
|
|
|
|
action_plan.append({
|
|
"type": "DELETE", # Or implies 'process'
|
|
"path": dup['path'],
|
|
"duplicate_of": keeper['path'],
|
|
"reason": f"Duplicate of {keeper['path']}"
|
|
})
|
|
|
|
# 5. Output JSON Plan
|
|
plan_data = {
|
|
"metadata": {
|
|
"strategy": args.strategy,
|
|
"version": "1.0",
|
|
"generated_at": "2025-12-14",
|
|
"stats": stats
|
|
},
|
|
"actions": action_plan
|
|
}
|
|
|
|
with open(args.output, 'w') as f:
|
|
json.dump(plan_data, f, indent=2)
|
|
|
|
print(f"[TOOL] Plan saved to {args.output}")
|
|
print(
|
|
f"[TOOL] Summary: {stats['duplicates_found']} duplicates identified in {stats['total_groups']} groups.")
|
|
if stats['reassigned'] > 0:
|
|
print(
|
|
f"[TOOL] Reassigned {stats['reassigned']} files as originals based on strategy.")
|
|
|
|
self._on_plan_complete(action_count=len(action_plan))
|
|
return 0
|
|
|
|
except Exception as e:
|
|
print(f"[TOOL ERROR] Plan failed: {e}")
|
|
return 1
|
|
|
|
|
|
# Create tool instance when module is loaded
|
|
plan_tool = PlanTool()
|
|
|
|
|
|
def register_tool():
|
|
"""Register tool with core system."""
|
|
return plan_tool
|
|
|
|
|
|
# Export tool interface
|
|
__all__ = ['plan_tool', 'register_tool']
|