""" Persistent transclusion context for Information Spaces. This module extends the core TransclusionContext with database-backed variable storage and space integration. """ from pathlib import Path from typing import Dict, Any, Optional, List, Set from dataclasses import dataclass, field from markitect.packaging.transclusion.context import TransclusionContext from ..models import SpaceVariable from ..repositories.interfaces import IVariableRepository class VariableScope: """Defines variable scope levels in order of precedence (highest first).""" REQUEST = "request" # Temporary, per-request variables DOCUMENT = "document" # Document-level variables SPACE = "space" # Space-level variables (persisted) @dataclass class ScopedVariables: """ Manages variables across multiple scope layers. Variables are resolved in order: request > document > space This allows local overrides of space-level defaults. """ space_vars: Dict[str, Any] = field(default_factory=dict) document_vars: Dict[str, Any] = field(default_factory=dict) request_vars: Dict[str, Any] = field(default_factory=dict) def get(self, name: str, default: Any = None) -> Any: """ Get a variable, checking scopes in order of precedence. Args: name: Variable name default: Default if not found in any scope Returns: Variable value from highest precedence scope, or default """ if name in self.request_vars: return self.request_vars[name] if name in self.document_vars: return self.document_vars[name] if name in self.space_vars: return self.space_vars[name] return default def set(self, name: str, value: Any, scope: str = VariableScope.REQUEST) -> None: """ Set a variable in the specified scope. Args: name: Variable name value: Variable value scope: Target scope (request, document, or space) """ if scope == VariableScope.REQUEST: self.request_vars[name] = value elif scope == VariableScope.DOCUMENT: self.document_vars[name] = value elif scope == VariableScope.SPACE: self.space_vars[name] = value def get_all(self) -> Dict[str, Any]: """ Get all variables merged with proper precedence. Returns: Dictionary with all variables, higher scopes overriding lower """ merged = {} merged.update(self.space_vars) merged.update(self.document_vars) merged.update(self.request_vars) return merged def clear_scope(self, scope: str) -> None: """Clear all variables in a scope.""" if scope == VariableScope.REQUEST: self.request_vars.clear() elif scope == VariableScope.DOCUMENT: self.document_vars.clear() elif scope == VariableScope.SPACE: self.space_vars.clear() class SpaceTransclusionContext(TransclusionContext): """ Transclusion context integrated with Information Spaces. Extends the base TransclusionContext with: - Space-aware variable resolution with scope layers - Reference tracking for cache invalidation - Optional persistence of space-level variables """ def __init__( self, space_id: str, base_path: Optional[Path] = None, variables: Optional[Dict[str, Any]] = None, max_depth: int = 10, variable_repo: Optional[IVariableRepository] = None, ): """ Initialize a space-aware transclusion context. Args: space_id: The space ID this context belongs to base_path: Base path for relative file resolution variables: Initial request-level variables max_depth: Maximum inclusion depth variable_repo: Optional repository for persisting space variables """ # Initialize scoped vars BEFORE super().__init__() because # the base class sets self.variables which triggers our property setter self._scoped_vars = ScopedVariables() self.space_id = space_id self._variable_repo = variable_repo # Track references during processing self._current_document_id: Optional[str] = None self._references: List[tuple] = [] # (source_doc_id, target_doc_id) # Now call parent init (which may set variables via property) super().__init__(base_path=base_path, variables={}, max_depth=max_depth) # Load space variables from repository if available if variable_repo: self._load_space_variables() # Set initial request variables if variables: for name, value in variables.items(): self._scoped_vars.set(name, value, VariableScope.REQUEST) def _load_space_variables(self) -> None: """Load space-level variables from the repository.""" if not self._variable_repo: return space_vars = self._variable_repo.list_variables(self.space_id, scope="space") for var in space_vars: self._scoped_vars.set(var.name, var.value, VariableScope.SPACE) doc_vars = self._variable_repo.list_variables(self.space_id, scope="document") for var in doc_vars: self._scoped_vars.set(var.name, var.value, VariableScope.DOCUMENT) def set_current_document(self, document_id: str) -> None: """ Set the current document being processed. Args: document_id: The document ID """ self._current_document_id = document_id def get_current_document(self) -> Optional[str]: """Get the current document being processed.""" return self._current_document_id def track_reference(self, target_doc_id: str) -> None: """ Track a reference from current document to target. Args: target_doc_id: The document being referenced """ if self._current_document_id: self._references.append((self._current_document_id, target_doc_id)) def get_tracked_references(self) -> List[tuple]: """ Get all tracked references. Returns: List of (source_doc_id, target_doc_id) tuples """ return list(self._references) def clear_tracked_references(self) -> None: """Clear all tracked references.""" self._references.clear() # Override variable methods to use scoped storage def set_variable(self, name: str, value: Any, scope: str = VariableScope.REQUEST) -> None: """ Set a variable in the specified scope. Args: name: Variable name value: Variable value scope: Variable scope (request, document, or space) """ self._scoped_vars.set(name, value, scope) # Persist space-level variables if repository available if scope == VariableScope.SPACE and self._variable_repo: var = SpaceVariable( space_id=self.space_id, name=name, value=value, scope=scope, ) self._variable_repo.set_variable(var) def get_variable(self, name: str, default: Any = None) -> Any: """ Get a variable from the scoped storage. Args: name: Variable name default: Default value if not found Returns: Variable value or default """ return self._scoped_vars.get(name, default) def substitute_variables(self, text: str) -> str: """ Substitute variables in text using scoped variable resolution. Args: text: Text containing {{variable}} references Returns: Text with variables substituted """ import re def replace_var(match): var_name = match.group(1).strip() value = self._scoped_vars.get(var_name) return str(value) if value is not None else match.group(0) return re.sub(r'\{\{([^}]+)\}\}', replace_var, text) @property def variables(self) -> Dict[str, Any]: """Get all variables merged with proper precedence.""" return self._scoped_vars.get_all() @variables.setter def variables(self, value: Dict[str, Any]) -> None: """Set request-level variables.""" self._scoped_vars.request_vars = value def create_child_context( self, new_base_path: Optional[Path] = None ) -> "SpaceTransclusionContext": """ Create a child context for nested processing. Args: new_base_path: New base path for the child context Returns: New SpaceTransclusionContext with inherited state """ child = SpaceTransclusionContext( space_id=self.space_id, base_path=new_base_path or self.base_path, max_depth=self.max_depth, variable_repo=self._variable_repo, ) # Copy scoped variables child._scoped_vars.space_vars = self._scoped_vars.space_vars.copy() child._scoped_vars.document_vars = self._scoped_vars.document_vars.copy() child._scoped_vars.request_vars = self._scoped_vars.request_vars.copy() # Copy processing state child.current_depth = self.current_depth child.inclusion_stack = self.inclusion_stack.copy() child.processed_files = self.processed_files.copy() # Share reference tracking child._current_document_id = self._current_document_id child._references = self._references # Shared list return child class CrossSpaceResolver: """ Resolves references across space boundaries. Enables transclusion from one space to reference content in another space. """ def __init__(self, contexts: Dict[str, SpaceTransclusionContext]): """ Initialize the cross-space resolver. Args: contexts: Dictionary mapping space_id to SpaceTransclusionContext """ self._contexts = contexts def add_context(self, space_id: str, context: SpaceTransclusionContext) -> None: """Add a space context.""" self._contexts[space_id] = context def get_context(self, space_id: str) -> Optional[SpaceTransclusionContext]: """Get the context for a space.""" return self._contexts.get(space_id) def resolve_variable( self, space_id: str, var_name: str, default: Any = None, ) -> Any: """ Resolve a variable from a specific space. Args: space_id: The space to look up var_name: Variable name default: Default value if not found Returns: Variable value or default """ context = self._contexts.get(space_id) if context: return context.get_variable(var_name, default) return default def resolve_cross_space_reference( self, reference: str, current_space_id: str, ) -> Optional[tuple]: """ Parse and resolve a cross-space reference. Reference format: "space:other-space/path/to/doc.md" or just "path/to/doc.md" for current space. Args: reference: The reference string current_space_id: Current space ID for relative references Returns: Tuple of (space_id, path) or None if invalid """ if ":" in reference and reference.startswith("space:"): # Cross-space reference _, rest = reference.split(":", 1) if "/" in rest: space_name, path = rest.split("/", 1) return (space_name, "/" + path) return None else: # Same-space reference return (current_space_id, reference)