Phase 0 - Project Organization: - Create docs/PROJECT_STRUCTURE.md documenting codebase layout - Create markitect/core/ with parser, serializer, document_manager, workspace - Create markitect/schema/ consolidating 6 schema_*.py modules - Create markitect/storage/ with database module - Maintain backward compatibility via re-exports from original locations - Add docs/roadmap/information-space-service/ with README and WORKPLAN Phase 1 - Foundation (Weeks 1-3): - Week 1: Core domain models (InformationSpace, SpaceDocument, SpaceConfig, SpaceMetadata, SpaceVariable, TransclusionReference, SpaceStatus) - Week 2: Repository layer with interfaces (ISpaceRepository, IDocumentAssociationRepository, IVariableRepository, IReferenceRepository) and SQLite implementations with foreign key cascade deletes - Week 3: SpaceService orchestration layer with full CRUD, document, variable, and reference tracking operations Test coverage: 124 tests (25 model + 63 repository + 36 integration) Capabilities delivered: - CAP-001: InformationSpace entity with lifecycle management - CAP-002: SpaceRepository CRUD with SQLite backing - CAP-003: Document-Space associations with path-based organization - CAP-004: Space metadata and configuration schemas - CAP-005: Database schema with migrations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
476 lines
16 KiB
Python
476 lines
16 KiB
Python
"""
|
|
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 hashlib
|
|
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."""
|
|
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
|