""" Workspace management functionality for Issue #144. This module provides workspace templates, multi-project support, and collaborative workspace features. """ import json import yaml import shutil import zipfile import tempfile from pathlib import Path from typing import Dict, Any, List, Optional from dataclasses import dataclass, field from datetime import datetime from markitect.assets import AssetManager @dataclass class TemplateMetadata: """Metadata for workspace templates.""" name: str description: str version: str created_at: datetime asset_count: int author: str = "Unknown" tags: List[str] = field(default_factory=list) @dataclass class TemplateResult: """Result of template creation.""" success: bool template_path: Path template_name: str error: Optional[Exception] = None @dataclass class WorkspaceCreationResult: """Result of workspace creation from template.""" success: bool workspace_path: Path project_name: str error: Optional[Exception] = None @dataclass class ProjectResult: """Result of project operations.""" success: bool project_path: Path project_name: str error: Optional[Exception] = None @dataclass class SyncResult: """Result of workspace synchronization.""" synchronized_count: int skipped_count: int error_count: int errors: List[Exception] = field(default_factory=list) @dataclass class BackupResult: """Result of workspace backup.""" success: bool backup_path: Path backup_size: int error: Optional[Exception] = None @dataclass class RestoreResult: """Result of workspace restore.""" success: bool restored_path: Path files_restored: int error: Optional[Exception] = None @dataclass class WorkspaceState: """Snapshot of workspace state.""" timestamp: datetime file_checksums: Dict[str, str] directory_structure: List[str] asset_hashes: List[str] @dataclass class ConflictInfo: """Information about a workspace conflict.""" file_path: Path conflict_type: str local_timestamp: datetime remote_timestamp: datetime @dataclass class MergeResult: """Result of conflict resolution.""" resolved_conflicts: int unresolved_conflicts: int merge_strategy: str class WorkspaceTemplate: """Workspace template management.""" def __init__(self, template_path: Path): """Initialize workspace template.""" self.template_path = template_path self.metadata_file = template_path / "template.json" def get_metadata(self) -> TemplateMetadata: """Get template metadata.""" if self.metadata_file.exists(): metadata_dict = json.loads(self.metadata_file.read_text()) return TemplateMetadata(**metadata_dict) else: return TemplateMetadata( name="Unknown", description="No description", version="1.0.0", created_at=datetime.now(), asset_count=0 ) class WorkspaceManager: """Workspace management system.""" def __init__(self, templates_dir: Optional[Path] = None): """Initialize workspace manager.""" self.templates_dir = templates_dir or Path.home() / ".markitect" / "templates" self.templates_dir.mkdir(parents=True, exist_ok=True) def create_template(self, name: str, source_path: Path, description: str = "", include_assets: bool = True, configuration: Optional[Dict] = None) -> TemplateResult: """Create a workspace template from existing workspace.""" try: template_path = self.templates_dir / name template_path.mkdir(exist_ok=True) # Copy workspace structure self._copy_workspace_structure(source_path, template_path, include_assets) # Count assets asset_count = 0 if include_assets and (source_path / "assets").exists(): asset_count = len(list((source_path / "assets").rglob("*"))) # Create template metadata metadata = { "name": name, "description": description, "version": "1.0.0", "created_at": datetime.now().isoformat(), "asset_count": asset_count, "author": "Unknown", "tags": [] } metadata_file = template_path / "template.json" metadata_file.write_text(json.dumps(metadata, indent=2)) # Save configuration if provided if configuration: config_file = template_path / "markitect.yaml" config_file.write_text(yaml.dump(configuration, indent=2)) return TemplateResult( success=True, template_path=template_path, template_name=name ) except Exception as e: return TemplateResult( success=False, template_path=Path(), template_name=name, error=e ) def get_template_metadata(self, template_name: str) -> TemplateMetadata: """Get metadata for a specific template.""" template_path = self.templates_dir / template_name template = WorkspaceTemplate(template_path) return template.get_metadata() def create_workspace_from_template(self, template_name: str, target_path: Path, project_name: str) -> WorkspaceCreationResult: """Create a new workspace from a template.""" try: template_path = self.templates_dir / template_name if not template_path.exists(): raise FileNotFoundError(f"Template '{template_name}' not found") # Create target directory target_path.mkdir(parents=True, exist_ok=True) # Copy template contents self._copy_workspace_structure(template_path, target_path, include_assets=True) # Update project-specific files self._customize_workspace(target_path, project_name) return WorkspaceCreationResult( success=True, workspace_path=target_path, project_name=project_name ) except Exception as e: return WorkspaceCreationResult( success=False, workspace_path=target_path, project_name=project_name, error=e ) def initialize_multi_project_workspace(self, workspace_root: Path): """Initialize a multi-project workspace.""" workspace_root.mkdir(parents=True, exist_ok=True) # Create shared directories (workspace_root / "shared_assets").mkdir(exist_ok=True) (workspace_root / "templates").mkdir(exist_ok=True) (workspace_root / "config").mkdir(exist_ok=True) # Create workspace configuration config = { "workspace_type": "multi_project", "shared_assets_enabled": True, "project_isolation": True, "created_at": datetime.now().isoformat() } config_file = workspace_root / "workspace.yaml" config_file.write_text(yaml.dump(config, indent=2)) def add_project(self, workspace_root: Path, project_name: str, template: Optional[str] = None) -> ProjectResult: """Add a project to multi-project workspace.""" try: project_path = workspace_root / project_name project_path.mkdir(exist_ok=True) if template: # Use template if specified result = self.create_workspace_from_template(template, project_path, project_name) if not result.success: raise result.error or Exception("Template creation failed") else: # Create basic project structure (project_path / "docs").mkdir(exist_ok=True) (project_path / "assets").mkdir(exist_ok=True) return ProjectResult( success=True, project_path=project_path, project_name=project_name ) except Exception as e: return ProjectResult( success=False, project_path=workspace_root / project_name, project_name=project_name, error=e ) def get_shared_asset_library(self, workspace_root: Path) -> Optional[AssetManager]: """Get shared asset library for multi-project workspace.""" shared_assets_path = workspace_root / "shared_assets" if shared_assets_path.exists(): return AssetManager(storage_path=shared_assets_path) return None def initialize_workspace(self, workspace_path: Path): """Initialize a single workspace.""" workspace_path.mkdir(parents=True, exist_ok=True) (workspace_path / "assets").mkdir(exist_ok=True) (workspace_path / "docs").mkdir(exist_ok=True) def synchronize_assets(self, source_workspace: Path, target_workspace: Path, sync_mode: str = "incremental") -> SyncResult: """Synchronize assets between workspaces.""" result = SyncResult( synchronized_count=0, skipped_count=0, error_count=0 ) try: source_assets = source_workspace / "assets" target_assets = target_workspace / "assets" if not source_assets.exists(): return result target_assets.mkdir(exist_ok=True) # Simple synchronization (copy new files) for asset_file in source_assets.rglob("*"): if asset_file.is_file(): relative_path = asset_file.relative_to(source_assets) target_file = target_assets / relative_path if not target_file.exists() or sync_mode == "overwrite": target_file.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(asset_file, target_file) result.synchronized_count += 1 else: result.skipped_count += 1 except Exception as e: result.error_count += 1 result.errors.append(e) return result def create_backup(self, workspace_path: Path, backup_path: Path, include_assets: bool = True, compression_level: int = 6) -> BackupResult: """Create a backup of workspace.""" try: with zipfile.ZipFile(backup_path, 'w', zipfile.ZIP_DEFLATED, compresslevel=compression_level) as backup_zip: for file_path in workspace_path.rglob("*"): if file_path.is_file(): # Skip assets if not included if not include_assets and "assets" in file_path.parts: continue arc_name = file_path.relative_to(workspace_path) backup_zip.write(file_path, arc_name) backup_size = backup_path.stat().st_size return BackupResult( success=True, backup_path=backup_path, backup_size=backup_size ) except Exception as e: return BackupResult( success=False, backup_path=backup_path, backup_size=0, error=e ) def restore_from_backup(self, backup_path: Path, target_path: Path) -> RestoreResult: """Restore workspace from backup.""" try: target_path.mkdir(parents=True, exist_ok=True) files_restored = 0 with zipfile.ZipFile(backup_path, 'r') as backup_zip: backup_zip.extractall(target_path) files_restored = len(backup_zip.namelist()) return RestoreResult( success=True, restored_path=target_path, files_restored=files_restored ) except Exception as e: return RestoreResult( success=False, restored_path=target_path, files_restored=0, error=e ) def capture_workspace_state(self, workspace_path: Path) -> WorkspaceState: """Capture current state of workspace.""" import hashlib file_checksums = {} directory_structure = [] asset_hashes = [] for item_path in workspace_path.rglob("*"): relative_path = str(item_path.relative_to(workspace_path)) if item_path.is_file(): # Calculate file checksum content = item_path.read_bytes() checksum = hashlib.md5(content).hexdigest() file_checksums[relative_path] = checksum # Track asset hashes if "assets" in item_path.parts: asset_hashes.append(checksum) directory_structure.append(relative_path) return WorkspaceState( timestamp=datetime.now(), file_checksums=file_checksums, directory_structure=directory_structure, asset_hashes=asset_hashes ) def detect_conflicts(self, state1: WorkspaceState, state2: WorkspaceState) -> List[ConflictInfo]: """Detect conflicts between workspace states.""" conflicts = [] # Find files that exist in both states but have different checksums for file_path, checksum1 in state1.file_checksums.items(): if file_path in state2.file_checksums: checksum2 = state2.file_checksums[file_path] if checksum1 != checksum2: conflict = ConflictInfo( file_path=Path(file_path), conflict_type="content_conflict", local_timestamp=state1.timestamp, remote_timestamp=state2.timestamp ) conflicts.append(conflict) return conflicts def resolve_conflicts(self, conflicts: List[ConflictInfo], resolution_strategy: str = "manual") -> MergeResult: """Resolve workspace conflicts.""" # Mock conflict resolution result = MergeResult( resolved_conflicts=len(conflicts), unresolved_conflicts=0, merge_strategy=resolution_strategy ) return result def _copy_workspace_structure(self, source: Path, target: Path, include_assets: bool): """Copy workspace structure from source to target.""" for item in source.rglob("*"): if item.is_file(): relative_path = item.relative_to(source) # Skip assets if not included if not include_assets and "assets" in relative_path.parts: continue # Skip template metadata if item.name == "template.json": continue target_path = target / relative_path target_path.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(item, target_path) def _customize_workspace(self, workspace_path: Path, project_name: str): """Customize workspace for specific project.""" # Update any configuration files with project name config_files = list(workspace_path.glob("*.yaml")) + list(workspace_path.glob("*.yml")) for config_file in config_files: try: content = config_file.read_text() # Replace placeholder project names content = content.replace("{{PROJECT_NAME}}", project_name) content = content.replace("New Project", project_name) config_file.write_text(content) except Exception: pass # Ignore errors in customization