feat(spaces): implement Phase 0-1 of Information Space Service
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>
This commit is contained in:
76
markitect/spaces/__init__.py
Normal file
76
markitect/spaces/__init__.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Information Spaces package for MarkiTect.
|
||||
|
||||
This package provides the Information Space abstraction, enabling:
|
||||
- First-class space entities with identity, metadata, and lifecycle
|
||||
- Event-driven change tracking and notifications
|
||||
- Persistent transclusion context with cross-space references
|
||||
- HTML rendering with caching and theme support
|
||||
- Bidirectional directory synchronization
|
||||
- Composable space hierarchies
|
||||
|
||||
Package Structure:
|
||||
- models: Core domain models (InformationSpace, SpaceDocument, SpaceConfig)
|
||||
- events: Event system (SpaceEvent, EventBus, handlers)
|
||||
- repositories: Data access layer (ISpaceRepository, SqliteSpaceRepository)
|
||||
- transclusion: Persistent transclusion context and reference tracking
|
||||
- rendering: Space rendering (HTML, themes)
|
||||
- sync: Directory synchronization (export, import, bidirectional)
|
||||
- services: Business logic (SpaceService)
|
||||
- history: Optional git-based version control
|
||||
|
||||
Usage:
|
||||
from markitect.spaces import SpaceService, InformationSpace
|
||||
|
||||
service = SpaceService()
|
||||
space = await service.create_space("my-docs")
|
||||
await service.add_document(space, "/intro.md", content="# Intro")
|
||||
await service.render(space, output_dir="./html/")
|
||||
"""
|
||||
|
||||
# Phase 1: Foundation
|
||||
from .models import (
|
||||
InformationSpace,
|
||||
SpaceDocument,
|
||||
SpaceConfig,
|
||||
SpaceMetadata,
|
||||
SpaceVariable,
|
||||
TransclusionReference,
|
||||
SpaceStatus,
|
||||
)
|
||||
from .services import SpaceService
|
||||
from .repositories import (
|
||||
ISpaceRepository,
|
||||
IDocumentAssociationRepository,
|
||||
IVariableRepository,
|
||||
IReferenceRepository,
|
||||
SqliteSpaceRepository,
|
||||
SqliteDocumentRepository,
|
||||
SqliteVariableRepository,
|
||||
SqliteReferenceRepository,
|
||||
initialize_space_tables,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Models
|
||||
"InformationSpace",
|
||||
"SpaceDocument",
|
||||
"SpaceConfig",
|
||||
"SpaceMetadata",
|
||||
"SpaceVariable",
|
||||
"TransclusionReference",
|
||||
"SpaceStatus",
|
||||
# Services
|
||||
"SpaceService",
|
||||
# Repository Interfaces
|
||||
"ISpaceRepository",
|
||||
"IDocumentAssociationRepository",
|
||||
"IVariableRepository",
|
||||
"IReferenceRepository",
|
||||
# SQLite Implementations
|
||||
"SqliteSpaceRepository",
|
||||
"SqliteDocumentRepository",
|
||||
"SqliteVariableRepository",
|
||||
"SqliteReferenceRepository",
|
||||
"initialize_space_tables",
|
||||
]
|
||||
16
markitect/spaces/events/__init__.py
Normal file
16
markitect/spaces/events/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Event system for Information Spaces.
|
||||
|
||||
This package provides event-driven architecture for space operations:
|
||||
- SpaceEvent: Event dataclass with type, payload, timestamp
|
||||
- EventBus: In-process publish/subscribe for space events
|
||||
- Event handlers and registration
|
||||
|
||||
Events emitted:
|
||||
- SPACE_CREATED, SPACE_UPDATED, SPACE_DELETED
|
||||
- DOCUMENT_ADDED, DOCUMENT_UPDATED, DOCUMENT_REMOVED
|
||||
- RENDER_COMPLETED, SYNC_COMPLETED
|
||||
"""
|
||||
|
||||
# Events will be implemented in Phase 2
|
||||
__all__ = []
|
||||
13
markitect/spaces/history/__init__.py
Normal file
13
markitect/spaces/history/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Git history tracking for Information Spaces (Optional Phase 8).
|
||||
|
||||
This package provides version control integration:
|
||||
- IHistoryBackend: Abstract history backend interface
|
||||
- GitHistoryBackend: Git implementation
|
||||
- Event-driven commit triggers
|
||||
- History query API (log, diff, branches)
|
||||
- Versioned read/render operations
|
||||
"""
|
||||
|
||||
# History tracking will be implemented in Phase 8
|
||||
__all__ = []
|
||||
329
markitect/spaces/models.py
Normal file
329
markitect/spaces/models.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Core domain models for Information Spaces.
|
||||
|
||||
This module provides the foundational data models for the Information Space
|
||||
abstraction, including the space entity, document associations, and configuration.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SpaceStatus(Enum):
|
||||
"""Lifecycle status of an Information Space."""
|
||||
DRAFT = "draft"
|
||||
ACTIVE = "active"
|
||||
ARCHIVED = "archived"
|
||||
DELETED = "deleted"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpaceMetadata:
|
||||
"""
|
||||
Extensible metadata for an Information Space.
|
||||
|
||||
Attributes:
|
||||
tags: List of tags for categorization
|
||||
author: Author identifier
|
||||
custom: Dictionary for custom metadata fields
|
||||
"""
|
||||
tags: List[str] = field(default_factory=list)
|
||||
author: Optional[str] = None
|
||||
custom: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert metadata to dictionary for serialization."""
|
||||
return {
|
||||
"tags": self.tags,
|
||||
"author": self.author,
|
||||
"custom": self.custom,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SpaceMetadata":
|
||||
"""Create metadata from dictionary."""
|
||||
return cls(
|
||||
tags=data.get("tags", []),
|
||||
author=data.get("author"),
|
||||
custom=data.get("custom", {}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpaceConfig:
|
||||
"""
|
||||
Configuration settings for an Information Space.
|
||||
|
||||
Attributes:
|
||||
default_variant: Default directory variant for export (flat/hierarchical/semantic)
|
||||
enable_caching: Whether to enable render caching
|
||||
theme: Theme name for HTML rendering
|
||||
history_enabled: Whether git history tracking is enabled (Phase 8)
|
||||
history_backend: History backend type (default: "git")
|
||||
history_options: Additional history backend options
|
||||
variable_scope: Default variable scope resolution strategy
|
||||
"""
|
||||
default_variant: str = "hierarchical"
|
||||
enable_caching: bool = True
|
||||
theme: Optional[str] = None
|
||||
history_enabled: bool = False
|
||||
history_backend: str = "git"
|
||||
history_options: Dict[str, Any] = field(default_factory=dict)
|
||||
variable_scope: str = "space" # space, document, request
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert config to dictionary for serialization."""
|
||||
return {
|
||||
"default_variant": self.default_variant,
|
||||
"enable_caching": self.enable_caching,
|
||||
"theme": self.theme,
|
||||
"history_enabled": self.history_enabled,
|
||||
"history_backend": self.history_backend,
|
||||
"history_options": self.history_options,
|
||||
"variable_scope": self.variable_scope,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SpaceConfig":
|
||||
"""Create config from dictionary."""
|
||||
return cls(
|
||||
default_variant=data.get("default_variant", "hierarchical"),
|
||||
enable_caching=data.get("enable_caching", True),
|
||||
theme=data.get("theme"),
|
||||
history_enabled=data.get("history_enabled", False),
|
||||
history_backend=data.get("history_backend", "git"),
|
||||
history_options=data.get("history_options", {}),
|
||||
variable_scope=data.get("variable_scope", "space"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpaceDocument:
|
||||
"""
|
||||
Represents a document's membership in an Information Space.
|
||||
|
||||
Attributes:
|
||||
id: Unique document membership identifier
|
||||
space_id: ID of the containing space
|
||||
document_id: Reference to the actual document
|
||||
space_path: Path within the space (e.g., "/intro.md")
|
||||
order_index: Ordering within the space
|
||||
metadata: Document-specific metadata
|
||||
content_hash: Hash of document content for change detection
|
||||
added_at: Timestamp when document was added
|
||||
"""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
space_id: str = ""
|
||||
document_id: str = ""
|
||||
space_path: str = ""
|
||||
order_index: int = 0
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
content_hash: Optional[str] = None
|
||||
added_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert document association to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"space_id": self.space_id,
|
||||
"document_id": self.document_id,
|
||||
"space_path": self.space_path,
|
||||
"order_index": self.order_index,
|
||||
"metadata": self.metadata,
|
||||
"content_hash": self.content_hash,
|
||||
"added_at": self.added_at.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SpaceDocument":
|
||||
"""Create document association from dictionary."""
|
||||
added_at = data.get("added_at")
|
||||
if isinstance(added_at, str):
|
||||
added_at = datetime.fromisoformat(added_at)
|
||||
elif added_at is None:
|
||||
added_at = datetime.now()
|
||||
|
||||
return cls(
|
||||
id=data.get("id", str(uuid.uuid4())),
|
||||
space_id=data.get("space_id", ""),
|
||||
document_id=data.get("document_id", ""),
|
||||
space_path=data.get("space_path", ""),
|
||||
order_index=data.get("order_index", 0),
|
||||
metadata=data.get("metadata", {}),
|
||||
content_hash=data.get("content_hash"),
|
||||
added_at=added_at,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InformationSpace:
|
||||
"""
|
||||
First-class Information Space abstraction.
|
||||
|
||||
An Information Space is a container for documents with transclusion
|
||||
relationships, persistent context, and lifecycle management.
|
||||
|
||||
Attributes:
|
||||
id: Unique space identifier
|
||||
name: Human-readable unique name
|
||||
description: Optional description
|
||||
metadata: Extensible metadata
|
||||
config: Space configuration
|
||||
parent_space_id: Optional parent space for inheritance
|
||||
status: Current lifecycle status
|
||||
created_at: Creation timestamp
|
||||
updated_at: Last update timestamp
|
||||
|
||||
Example:
|
||||
space = InformationSpace(
|
||||
name="api-docs",
|
||||
description="API Documentation",
|
||||
config=SpaceConfig(theme="technical")
|
||||
)
|
||||
"""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
name: str = ""
|
||||
description: Optional[str] = None
|
||||
metadata: SpaceMetadata = field(default_factory=SpaceMetadata)
|
||||
config: SpaceConfig = field(default_factory=SpaceConfig)
|
||||
parent_space_id: Optional[str] = None
|
||||
status: SpaceStatus = SpaceStatus.DRAFT
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
updated_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate space after initialization."""
|
||||
if not self.name:
|
||||
raise ValueError("Space name is required")
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert space to dictionary for serialization."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"metadata": self.metadata.to_dict() if isinstance(self.metadata, SpaceMetadata) else self.metadata,
|
||||
"config": self.config.to_dict() if isinstance(self.config, SpaceConfig) else self.config,
|
||||
"parent_space_id": self.parent_space_id,
|
||||
"status": self.status.value if isinstance(self.status, SpaceStatus) else self.status,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"updated_at": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "InformationSpace":
|
||||
"""Create space from dictionary."""
|
||||
created_at = data.get("created_at")
|
||||
if isinstance(created_at, str):
|
||||
created_at = datetime.fromisoformat(created_at)
|
||||
elif created_at is None:
|
||||
created_at = datetime.now()
|
||||
|
||||
updated_at = data.get("updated_at")
|
||||
if isinstance(updated_at, str):
|
||||
updated_at = datetime.fromisoformat(updated_at)
|
||||
elif updated_at is None:
|
||||
updated_at = datetime.now()
|
||||
|
||||
status = data.get("status", "draft")
|
||||
if isinstance(status, str):
|
||||
status = SpaceStatus(status)
|
||||
|
||||
metadata = data.get("metadata", {})
|
||||
if isinstance(metadata, dict):
|
||||
metadata = SpaceMetadata.from_dict(metadata)
|
||||
|
||||
config = data.get("config", {})
|
||||
if isinstance(config, dict):
|
||||
config = SpaceConfig.from_dict(config)
|
||||
|
||||
return cls(
|
||||
id=data.get("id", str(uuid.uuid4())),
|
||||
name=data["name"],
|
||||
description=data.get("description"),
|
||||
metadata=metadata,
|
||||
config=config,
|
||||
parent_space_id=data.get("parent_space_id"),
|
||||
status=status,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
|
||||
def activate(self) -> None:
|
||||
"""Activate the space."""
|
||||
self.status = SpaceStatus.ACTIVE
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def archive(self) -> None:
|
||||
"""Archive the space."""
|
||||
self.status = SpaceStatus.ARCHIVED
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def touch(self) -> None:
|
||||
"""Update the last modified timestamp."""
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SpaceVariable:
|
||||
"""
|
||||
Variable stored at space level for transclusion context.
|
||||
|
||||
Attributes:
|
||||
space_id: ID of the containing space
|
||||
name: Variable name
|
||||
value: Variable value (JSON-serializable)
|
||||
scope: Variable scope (space, document, request)
|
||||
"""
|
||||
space_id: str
|
||||
name: str
|
||||
value: Any
|
||||
scope: str = "space"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"space_id": self.space_id,
|
||||
"name": self.name,
|
||||
"value": self.value,
|
||||
"scope": self.scope,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SpaceVariable":
|
||||
"""Create from dictionary."""
|
||||
return cls(
|
||||
space_id=data["space_id"],
|
||||
name=data["name"],
|
||||
value=data["value"],
|
||||
scope=data.get("scope", "space"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransclusionReference:
|
||||
"""
|
||||
Tracks a transclusion reference between documents for cache invalidation.
|
||||
|
||||
Attributes:
|
||||
source_doc_id: ID of the document containing the transclusion
|
||||
target_doc_id: ID of the transcluded document
|
||||
space_id: ID of the space containing the reference
|
||||
created_at: When the reference was created
|
||||
"""
|
||||
source_doc_id: str
|
||||
target_doc_id: str
|
||||
space_id: str
|
||||
created_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
return {
|
||||
"source_doc_id": self.source_doc_id,
|
||||
"target_doc_id": self.target_doc_id,
|
||||
"space_id": self.space_id,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
}
|
||||
12
markitect/spaces/rendering/__init__.py
Normal file
12
markitect/spaces/rendering/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Rendering system for Information Spaces.
|
||||
|
||||
This package provides space rendering capabilities:
|
||||
- SpaceRenderer: Abstract renderer interface
|
||||
- MarkdownToHTMLRenderer: HTML output renderer
|
||||
- Theme support and customization
|
||||
- Render caching with invalidation
|
||||
"""
|
||||
|
||||
# Rendering will be implemented in Phase 4
|
||||
__all__ = []
|
||||
38
markitect/spaces/repositories/__init__.py
Normal file
38
markitect/spaces/repositories/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Repository layer for Information Spaces.
|
||||
|
||||
This package provides data access abstractions:
|
||||
- ISpaceRepository: Abstract interface for space CRUD operations
|
||||
- SqliteSpaceRepository: SQLite implementation
|
||||
- IDocumentAssociationRepository: Document-space association storage
|
||||
- IVariableRepository: Space variable storage
|
||||
- IReferenceRepository: Transclusion reference tracking
|
||||
"""
|
||||
|
||||
from .interfaces import (
|
||||
ISpaceRepository,
|
||||
IDocumentAssociationRepository,
|
||||
IVariableRepository,
|
||||
IReferenceRepository,
|
||||
)
|
||||
from .sqlite import (
|
||||
SqliteSpaceRepository,
|
||||
SqliteDocumentRepository,
|
||||
SqliteVariableRepository,
|
||||
SqliteReferenceRepository,
|
||||
initialize_space_tables,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Interfaces
|
||||
"ISpaceRepository",
|
||||
"IDocumentAssociationRepository",
|
||||
"IVariableRepository",
|
||||
"IReferenceRepository",
|
||||
# SQLite implementations
|
||||
"SqliteSpaceRepository",
|
||||
"SqliteDocumentRepository",
|
||||
"SqliteVariableRepository",
|
||||
"SqliteReferenceRepository",
|
||||
"initialize_space_tables",
|
||||
]
|
||||
409
markitect/spaces/repositories/interfaces.py
Normal file
409
markitect/spaces/repositories/interfaces.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Repository interfaces for Information Spaces.
|
||||
|
||||
This module defines abstract base classes for space data access,
|
||||
following the repository pattern for clean separation of concerns.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Optional, Dict, Any
|
||||
from ..models import (
|
||||
InformationSpace,
|
||||
SpaceDocument,
|
||||
SpaceVariable,
|
||||
TransclusionReference,
|
||||
)
|
||||
|
||||
|
||||
class ISpaceRepository(ABC):
|
||||
"""
|
||||
Abstract repository interface for InformationSpace persistence.
|
||||
|
||||
Implementations should handle CRUD operations for spaces,
|
||||
including proper transaction management and error handling.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create(self, space: InformationSpace) -> InformationSpace:
|
||||
"""
|
||||
Create a new space in the repository.
|
||||
|
||||
Args:
|
||||
space: The space to create
|
||||
|
||||
Returns:
|
||||
The created space with any generated fields populated
|
||||
|
||||
Raises:
|
||||
ValueError: If space with same name already exists
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_by_id(self, space_id: str) -> Optional[InformationSpace]:
|
||||
"""
|
||||
Retrieve a space by its ID.
|
||||
|
||||
Args:
|
||||
space_id: The unique space identifier
|
||||
|
||||
Returns:
|
||||
The space if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_by_name(self, name: str) -> Optional[InformationSpace]:
|
||||
"""
|
||||
Retrieve a space by its unique name.
|
||||
|
||||
Args:
|
||||
name: The space name
|
||||
|
||||
Returns:
|
||||
The space if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_all(self, include_archived: bool = False) -> List[InformationSpace]:
|
||||
"""
|
||||
List all spaces in the repository.
|
||||
|
||||
Args:
|
||||
include_archived: Whether to include archived spaces
|
||||
|
||||
Returns:
|
||||
List of all spaces
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update(self, space: InformationSpace) -> InformationSpace:
|
||||
"""
|
||||
Update an existing space.
|
||||
|
||||
Args:
|
||||
space: The space with updated values
|
||||
|
||||
Returns:
|
||||
The updated space
|
||||
|
||||
Raises:
|
||||
ValueError: If space does not exist
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, space_id: str) -> bool:
|
||||
"""
|
||||
Delete a space by ID.
|
||||
|
||||
Args:
|
||||
space_id: The space ID to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def exists(self, space_id: str) -> bool:
|
||||
"""
|
||||
Check if a space exists.
|
||||
|
||||
Args:
|
||||
space_id: The space ID to check
|
||||
|
||||
Returns:
|
||||
True if exists, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_children(self, parent_space_id: str) -> List[InformationSpace]:
|
||||
"""
|
||||
Get all child spaces of a parent space.
|
||||
|
||||
Args:
|
||||
parent_space_id: The parent space ID
|
||||
|
||||
Returns:
|
||||
List of child spaces
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class IDocumentAssociationRepository(ABC):
|
||||
"""
|
||||
Abstract repository interface for SpaceDocument associations.
|
||||
|
||||
Manages the relationship between documents and spaces.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def add_document(self, document: SpaceDocument) -> SpaceDocument:
|
||||
"""
|
||||
Add a document to a space.
|
||||
|
||||
Args:
|
||||
document: The document association to create
|
||||
|
||||
Returns:
|
||||
The created document association
|
||||
|
||||
Raises:
|
||||
ValueError: If document path already exists in space
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_document(self, document_id: str) -> Optional[SpaceDocument]:
|
||||
"""
|
||||
Get a document association by ID.
|
||||
|
||||
Args:
|
||||
document_id: The document association ID
|
||||
|
||||
Returns:
|
||||
The document if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_by_space_path(self, space_id: str, space_path: str) -> Optional[SpaceDocument]:
|
||||
"""
|
||||
Get a document by its path within a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
space_path: The path within the space (e.g., "/intro.md")
|
||||
|
||||
Returns:
|
||||
The document if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_by_space(self, space_id: str) -> List[SpaceDocument]:
|
||||
"""
|
||||
List all documents in a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
List of documents in the space, ordered by order_index
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_document(self, document: SpaceDocument) -> SpaceDocument:
|
||||
"""
|
||||
Update a document association.
|
||||
|
||||
Args:
|
||||
document: The document with updated values
|
||||
|
||||
Returns:
|
||||
The updated document
|
||||
|
||||
Raises:
|
||||
ValueError: If document does not exist
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def remove_document(self, document_id: str) -> bool:
|
||||
"""
|
||||
Remove a document from a space.
|
||||
|
||||
Args:
|
||||
document_id: The document association ID
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def move_document(self, document_id: str, new_space_path: str) -> SpaceDocument:
|
||||
"""
|
||||
Move a document to a new path within the space.
|
||||
|
||||
Args:
|
||||
document_id: The document association ID
|
||||
new_space_path: The new path within the space
|
||||
|
||||
Returns:
|
||||
The updated document
|
||||
|
||||
Raises:
|
||||
ValueError: If new path already exists
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def reorder_documents(self, space_id: str, document_ids: List[str]) -> None:
|
||||
"""
|
||||
Reorder documents within a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
document_ids: Ordered list of document IDs
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_content_hash(self, document_id: str, content_hash: str) -> None:
|
||||
"""
|
||||
Update the content hash for change detection.
|
||||
|
||||
Args:
|
||||
document_id: The document association ID
|
||||
content_hash: New content hash
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class IVariableRepository(ABC):
|
||||
"""
|
||||
Abstract repository interface for SpaceVariable storage.
|
||||
|
||||
Manages space-level variables for transclusion context.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def set_variable(self, variable: SpaceVariable) -> SpaceVariable:
|
||||
"""
|
||||
Set a variable value.
|
||||
|
||||
Args:
|
||||
variable: The variable to set
|
||||
|
||||
Returns:
|
||||
The saved variable
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_variable(self, space_id: str, name: str) -> Optional[SpaceVariable]:
|
||||
"""
|
||||
Get a variable by name.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
name: Variable name
|
||||
|
||||
Returns:
|
||||
The variable if found, None otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_variables(self, space_id: str, scope: Optional[str] = None) -> List[SpaceVariable]:
|
||||
"""
|
||||
List all variables in a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
scope: Optional scope filter
|
||||
|
||||
Returns:
|
||||
List of variables
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_variable(self, space_id: str, name: str) -> bool:
|
||||
"""
|
||||
Delete a variable.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
name: Variable name
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class IReferenceRepository(ABC):
|
||||
"""
|
||||
Abstract repository interface for TransclusionReference tracking.
|
||||
|
||||
Manages the dependency graph for cache invalidation.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def add_reference(self, reference: TransclusionReference) -> TransclusionReference:
|
||||
"""
|
||||
Add a transclusion reference.
|
||||
|
||||
Args:
|
||||
reference: The reference to add
|
||||
|
||||
Returns:
|
||||
The saved reference
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_references_from(self, source_doc_id: str, space_id: str) -> List[TransclusionReference]:
|
||||
"""
|
||||
Get all references from a source document.
|
||||
|
||||
Args:
|
||||
source_doc_id: The source document ID
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
List of references from this document
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_references_to(self, target_doc_id: str, space_id: str) -> List[TransclusionReference]:
|
||||
"""
|
||||
Get all references to a target document.
|
||||
|
||||
Args:
|
||||
target_doc_id: The target document ID
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
List of references to this document
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def clear_references_from(self, source_doc_id: str, space_id: str) -> int:
|
||||
"""
|
||||
Clear all references from a source document.
|
||||
|
||||
Args:
|
||||
source_doc_id: The source document ID
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
Number of references deleted
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_dependents(self, document_id: str, space_id: str) -> List[str]:
|
||||
"""
|
||||
Get all documents that depend on a given document.
|
||||
|
||||
Used for cache invalidation - returns documents that need
|
||||
to be re-rendered when the target document changes.
|
||||
|
||||
Args:
|
||||
document_id: The document ID
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
List of dependent document IDs
|
||||
"""
|
||||
pass
|
||||
713
markitect/spaces/repositories/sqlite.py
Normal file
713
markitect/spaces/repositories/sqlite.py
Normal file
@@ -0,0 +1,713 @@
|
||||
"""
|
||||
SQLite implementation of space repositories.
|
||||
|
||||
This module provides SQLite-backed implementations of the repository
|
||||
interfaces for persistent storage of Information Spaces.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from .interfaces import (
|
||||
ISpaceRepository,
|
||||
IDocumentAssociationRepository,
|
||||
IVariableRepository,
|
||||
IReferenceRepository,
|
||||
)
|
||||
from ..models import (
|
||||
InformationSpace,
|
||||
SpaceDocument,
|
||||
SpaceVariable,
|
||||
TransclusionReference,
|
||||
SpaceStatus,
|
||||
SpaceMetadata,
|
||||
SpaceConfig,
|
||||
)
|
||||
|
||||
|
||||
# SQL Schema for space tables
|
||||
SPACE_TABLES_SQL = """
|
||||
-- Information Spaces table
|
||||
CREATE TABLE IF NOT EXISTS spaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
metadata JSON,
|
||||
config JSON,
|
||||
parent_space_id TEXT REFERENCES spaces(id),
|
||||
status TEXT DEFAULT 'draft',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Space documents association table
|
||||
CREATE TABLE IF NOT EXISTS space_documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
document_id TEXT NOT NULL,
|
||||
space_path TEXT NOT NULL,
|
||||
order_index INTEGER DEFAULT 0,
|
||||
metadata JSON,
|
||||
content_hash TEXT,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(space_id, space_path)
|
||||
);
|
||||
|
||||
-- Space variables for transclusion context
|
||||
CREATE TABLE IF NOT EXISTS space_variables (
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
value JSON,
|
||||
scope TEXT DEFAULT 'space',
|
||||
PRIMARY KEY(space_id, name)
|
||||
);
|
||||
|
||||
-- Transclusion reference tracking for cache invalidation
|
||||
CREATE TABLE IF NOT EXISTS transclusion_references (
|
||||
source_doc_id TEXT NOT NULL,
|
||||
target_doc_id TEXT NOT NULL,
|
||||
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(source_doc_id, target_doc_id, space_id)
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_spaces_name ON spaces(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_spaces_parent ON spaces(parent_space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_spaces_status ON spaces(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_documents_space ON space_documents(space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_space_documents_path ON space_documents(space_id, space_path);
|
||||
CREATE INDEX IF NOT EXISTS idx_transclusion_refs_source ON transclusion_references(source_doc_id, space_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transclusion_refs_target ON transclusion_references(target_doc_id, space_id);
|
||||
"""
|
||||
|
||||
|
||||
def initialize_space_tables(db_path: str) -> None:
|
||||
"""
|
||||
Initialize the space-related database tables.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file
|
||||
"""
|
||||
# Ensure directory exists
|
||||
db_dir = Path(db_path).parent
|
||||
if db_dir and not db_dir.exists():
|
||||
db_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.executescript(SPACE_TABLES_SQL)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class SqliteSpaceRepository(ISpaceRepository):
|
||||
"""
|
||||
SQLite implementation of the space repository.
|
||||
|
||||
Provides persistent storage for InformationSpace entities
|
||||
using SQLite as the backend.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
"""
|
||||
Initialize the repository.
|
||||
|
||||
Args:
|
||||
db_path: Path to the SQLite database file
|
||||
"""
|
||||
self.db_path = db_path
|
||||
initialize_space_tables(db_path)
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get a database connection with foreign keys enabled."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
def _row_to_space(self, row: sqlite3.Row) -> InformationSpace:
|
||||
"""Convert a database row to an InformationSpace."""
|
||||
metadata_dict = json.loads(row["metadata"]) if row["metadata"] else {}
|
||||
config_dict = json.loads(row["config"]) if row["config"] else {}
|
||||
|
||||
return InformationSpace(
|
||||
id=row["id"],
|
||||
name=row["name"],
|
||||
description=row["description"],
|
||||
metadata=SpaceMetadata.from_dict(metadata_dict),
|
||||
config=SpaceConfig.from_dict(config_dict),
|
||||
parent_space_id=row["parent_space_id"],
|
||||
status=SpaceStatus(row["status"]),
|
||||
created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(),
|
||||
updated_at=datetime.fromisoformat(row["updated_at"]) if row["updated_at"] else datetime.now(),
|
||||
)
|
||||
|
||||
def create(self, space: InformationSpace) -> InformationSpace:
|
||||
"""Create a new space."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if name already exists
|
||||
cursor.execute("SELECT id FROM spaces WHERE name = ?", (space.name,))
|
||||
if cursor.fetchone():
|
||||
raise ValueError(f"Space with name '{space.name}' already exists")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO spaces (id, name, description, metadata, config, parent_space_id, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
space.id,
|
||||
space.name,
|
||||
space.description,
|
||||
json.dumps(space.metadata.to_dict() if isinstance(space.metadata, SpaceMetadata) else space.metadata),
|
||||
json.dumps(space.config.to_dict() if isinstance(space.config, SpaceConfig) else space.config),
|
||||
space.parent_space_id,
|
||||
space.status.value if isinstance(space.status, SpaceStatus) else space.status,
|
||||
space.created_at.isoformat(),
|
||||
space.updated_at.isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return space
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_by_id(self, space_id: str) -> Optional[InformationSpace]:
|
||||
"""Get a space by ID."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM spaces WHERE id = ?", (space_id,))
|
||||
row = cursor.fetchone()
|
||||
return self._row_to_space(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[InformationSpace]:
|
||||
"""Get a space by name."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM spaces WHERE name = ?", (name,))
|
||||
row = cursor.fetchone()
|
||||
return self._row_to_space(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_all(self, include_archived: bool = False) -> List[InformationSpace]:
|
||||
"""List all spaces."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
if include_archived:
|
||||
cursor.execute("SELECT * FROM spaces WHERE status != 'deleted' ORDER BY name")
|
||||
else:
|
||||
cursor.execute("SELECT * FROM spaces WHERE status NOT IN ('archived', 'deleted') ORDER BY name")
|
||||
return [self._row_to_space(row) for row in cursor.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def update(self, space: InformationSpace) -> InformationSpace:
|
||||
"""Update a space."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if space exists
|
||||
cursor.execute("SELECT id FROM spaces WHERE id = ?", (space.id,))
|
||||
if not cursor.fetchone():
|
||||
raise ValueError(f"Space with id '{space.id}' does not exist")
|
||||
|
||||
space.touch() # Update timestamp
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE spaces SET
|
||||
name = ?,
|
||||
description = ?,
|
||||
metadata = ?,
|
||||
config = ?,
|
||||
parent_space_id = ?,
|
||||
status = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
space.name,
|
||||
space.description,
|
||||
json.dumps(space.metadata.to_dict() if isinstance(space.metadata, SpaceMetadata) else space.metadata),
|
||||
json.dumps(space.config.to_dict() if isinstance(space.config, SpaceConfig) else space.config),
|
||||
space.parent_space_id,
|
||||
space.status.value if isinstance(space.status, SpaceStatus) else space.status,
|
||||
space.updated_at.isoformat(),
|
||||
space.id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return space
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete(self, space_id: str) -> bool:
|
||||
"""Delete a space."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM spaces WHERE id = ?", (space_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def exists(self, space_id: str) -> bool:
|
||||
"""Check if a space exists."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT 1 FROM spaces WHERE id = ?", (space_id,))
|
||||
return cursor.fetchone() is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_children(self, parent_space_id: str) -> List[InformationSpace]:
|
||||
"""Get child spaces."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT * FROM spaces WHERE parent_space_id = ? ORDER BY name",
|
||||
(parent_space_id,),
|
||||
)
|
||||
return [self._row_to_space(row) for row in cursor.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class SqliteDocumentRepository(IDocumentAssociationRepository):
|
||||
"""
|
||||
SQLite implementation of the document association repository.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
"""Initialize the repository."""
|
||||
self.db_path = db_path
|
||||
initialize_space_tables(db_path)
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get a database connection with foreign keys enabled."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
def _row_to_document(self, row: sqlite3.Row) -> SpaceDocument:
|
||||
"""Convert a database row to a SpaceDocument."""
|
||||
metadata_dict = json.loads(row["metadata"]) if row["metadata"] else {}
|
||||
|
||||
return SpaceDocument(
|
||||
id=row["id"],
|
||||
space_id=row["space_id"],
|
||||
document_id=row["document_id"],
|
||||
space_path=row["space_path"],
|
||||
order_index=row["order_index"],
|
||||
metadata=metadata_dict,
|
||||
content_hash=row["content_hash"],
|
||||
added_at=datetime.fromisoformat(row["added_at"]) if row["added_at"] else datetime.now(),
|
||||
)
|
||||
|
||||
def add_document(self, document: SpaceDocument) -> SpaceDocument:
|
||||
"""Add a document to a space."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if path already exists in space
|
||||
cursor.execute(
|
||||
"SELECT id FROM space_documents WHERE space_id = ? AND space_path = ?",
|
||||
(document.space_id, document.space_path),
|
||||
)
|
||||
if cursor.fetchone():
|
||||
raise ValueError(f"Document path '{document.space_path}' already exists in space")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO space_documents (id, space_id, document_id, space_path, order_index, metadata, content_hash, added_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
document.id,
|
||||
document.space_id,
|
||||
document.document_id,
|
||||
document.space_path,
|
||||
document.order_index,
|
||||
json.dumps(document.metadata),
|
||||
document.content_hash,
|
||||
document.added_at.isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return document
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_document(self, document_id: str) -> Optional[SpaceDocument]:
|
||||
"""Get a document by ID."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM space_documents WHERE id = ?", (document_id,))
|
||||
row = cursor.fetchone()
|
||||
return self._row_to_document(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_by_space_path(self, space_id: str, space_path: str) -> Optional[SpaceDocument]:
|
||||
"""Get a document by its path within a space."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT * FROM space_documents WHERE space_id = ? AND space_path = ?",
|
||||
(space_id, space_path),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return self._row_to_document(row) if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_by_space(self, space_id: str) -> List[SpaceDocument]:
|
||||
"""List all documents in a space."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT * FROM space_documents WHERE space_id = ? ORDER BY order_index, space_path",
|
||||
(space_id,),
|
||||
)
|
||||
return [self._row_to_document(row) for row in cursor.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def update_document(self, document: SpaceDocument) -> SpaceDocument:
|
||||
"""Update a document."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id FROM space_documents WHERE id = ?", (document.id,))
|
||||
if not cursor.fetchone():
|
||||
raise ValueError(f"Document with id '{document.id}' does not exist")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE space_documents SET
|
||||
document_id = ?,
|
||||
space_path = ?,
|
||||
order_index = ?,
|
||||
metadata = ?,
|
||||
content_hash = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
document.document_id,
|
||||
document.space_path,
|
||||
document.order_index,
|
||||
json.dumps(document.metadata),
|
||||
document.content_hash,
|
||||
document.id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return document
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def remove_document(self, document_id: str) -> bool:
|
||||
"""Remove a document from a space."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM space_documents WHERE id = ?", (document_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def move_document(self, document_id: str, new_space_path: str) -> SpaceDocument:
|
||||
"""Move a document to a new path."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get current document
|
||||
cursor.execute("SELECT * FROM space_documents WHERE id = ?", (document_id,))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Document with id '{document_id}' does not exist")
|
||||
|
||||
# Check if new path already exists
|
||||
cursor.execute(
|
||||
"SELECT id FROM space_documents WHERE space_id = ? AND space_path = ? AND id != ?",
|
||||
(row["space_id"], new_space_path, document_id),
|
||||
)
|
||||
if cursor.fetchone():
|
||||
raise ValueError(f"Document path '{new_space_path}' already exists")
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE space_documents SET space_path = ? WHERE id = ?",
|
||||
(new_space_path, document_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
document = self._row_to_document(row)
|
||||
document.space_path = new_space_path
|
||||
return document
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def reorder_documents(self, space_id: str, document_ids: List[str]) -> None:
|
||||
"""Reorder documents within a space."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
for index, doc_id in enumerate(document_ids):
|
||||
cursor.execute(
|
||||
"UPDATE space_documents SET order_index = ? WHERE id = ? AND space_id = ?",
|
||||
(index, doc_id, space_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def update_content_hash(self, document_id: str, content_hash: str) -> None:
|
||||
"""Update the content hash."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE space_documents SET content_hash = ? WHERE id = ?",
|
||||
(content_hash, document_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class SqliteVariableRepository(IVariableRepository):
|
||||
"""
|
||||
SQLite implementation of the variable repository.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
"""Initialize the repository."""
|
||||
self.db_path = db_path
|
||||
initialize_space_tables(db_path)
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get a database connection with foreign keys enabled."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
def set_variable(self, variable: SpaceVariable) -> SpaceVariable:
|
||||
"""Set a variable value."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO space_variables (space_id, name, value, scope)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
variable.space_id,
|
||||
variable.name,
|
||||
json.dumps(variable.value),
|
||||
variable.scope,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return variable
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_variable(self, space_id: str, name: str) -> Optional[SpaceVariable]:
|
||||
"""Get a variable by name."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT * FROM space_variables WHERE space_id = ? AND name = ?",
|
||||
(space_id, name),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return SpaceVariable(
|
||||
space_id=row["space_id"],
|
||||
name=row["name"],
|
||||
value=json.loads(row["value"]) if row["value"] else None,
|
||||
scope=row["scope"],
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def list_variables(self, space_id: str, scope: Optional[str] = None) -> List[SpaceVariable]:
|
||||
"""List variables in a space."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
if scope:
|
||||
cursor.execute(
|
||||
"SELECT * FROM space_variables WHERE space_id = ? AND scope = ?",
|
||||
(space_id, scope),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"SELECT * FROM space_variables WHERE space_id = ?",
|
||||
(space_id,),
|
||||
)
|
||||
return [
|
||||
SpaceVariable(
|
||||
space_id=row["space_id"],
|
||||
name=row["name"],
|
||||
value=json.loads(row["value"]) if row["value"] else None,
|
||||
scope=row["scope"],
|
||||
)
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_variable(self, space_id: str, name: str) -> bool:
|
||||
"""Delete a variable."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"DELETE FROM space_variables WHERE space_id = ? AND name = ?",
|
||||
(space_id, name),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class SqliteReferenceRepository(IReferenceRepository):
|
||||
"""
|
||||
SQLite implementation of the reference repository.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
"""Initialize the repository."""
|
||||
self.db_path = db_path
|
||||
initialize_space_tables(db_path)
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
"""Get a database connection with foreign keys enabled."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
def add_reference(self, reference: TransclusionReference) -> TransclusionReference:
|
||||
"""Add a transclusion reference."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO transclusion_references (source_doc_id, target_doc_id, space_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
reference.source_doc_id,
|
||||
reference.target_doc_id,
|
||||
reference.space_id,
|
||||
reference.created_at.isoformat(),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return reference
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_references_from(self, source_doc_id: str, space_id: str) -> List[TransclusionReference]:
|
||||
"""Get references from a source document."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT * FROM transclusion_references WHERE source_doc_id = ? AND space_id = ?",
|
||||
(source_doc_id, space_id),
|
||||
)
|
||||
return [
|
||||
TransclusionReference(
|
||||
source_doc_id=row["source_doc_id"],
|
||||
target_doc_id=row["target_doc_id"],
|
||||
space_id=row["space_id"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(),
|
||||
)
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_references_to(self, target_doc_id: str, space_id: str) -> List[TransclusionReference]:
|
||||
"""Get references to a target document."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT * FROM transclusion_references WHERE target_doc_id = ? AND space_id = ?",
|
||||
(target_doc_id, space_id),
|
||||
)
|
||||
return [
|
||||
TransclusionReference(
|
||||
source_doc_id=row["source_doc_id"],
|
||||
target_doc_id=row["target_doc_id"],
|
||||
space_id=row["space_id"],
|
||||
created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(),
|
||||
)
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def clear_references_from(self, source_doc_id: str, space_id: str) -> int:
|
||||
"""Clear references from a source document."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"DELETE FROM transclusion_references WHERE source_doc_id = ? AND space_id = ?",
|
||||
(source_doc_id, space_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_dependents(self, document_id: str, space_id: str) -> List[str]:
|
||||
"""Get documents that depend on this document."""
|
||||
conn = self._get_connection()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT DISTINCT source_doc_id FROM transclusion_references WHERE target_doc_id = ? AND space_id = ?",
|
||||
(document_id, space_id),
|
||||
)
|
||||
return [row["source_doc_id"] for row in cursor.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
14
markitect/spaces/services/__init__.py
Normal file
14
markitect/spaces/services/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Service layer for Information Spaces.
|
||||
|
||||
This package provides the main orchestration service:
|
||||
- SpaceService: Main API for space operations
|
||||
- RenderService: Rendering orchestration (Phase 4)
|
||||
- SyncService: Synchronization coordination (Phase 5)
|
||||
"""
|
||||
|
||||
from .space_service import SpaceService
|
||||
|
||||
__all__ = [
|
||||
"SpaceService",
|
||||
]
|
||||
659
markitect/spaces/services/space_service.py
Normal file
659
markitect/spaces/services/space_service.py
Normal file
@@ -0,0 +1,659 @@
|
||||
"""
|
||||
SpaceService - Main orchestration service for Information Spaces.
|
||||
|
||||
This module provides the primary API for space operations, coordinating
|
||||
between repositories, event handling, and transclusion context management.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
from ..models import (
|
||||
InformationSpace,
|
||||
SpaceDocument,
|
||||
SpaceVariable,
|
||||
TransclusionReference,
|
||||
SpaceStatus,
|
||||
SpaceConfig,
|
||||
SpaceMetadata,
|
||||
)
|
||||
from ..repositories.interfaces import (
|
||||
ISpaceRepository,
|
||||
IDocumentAssociationRepository,
|
||||
IVariableRepository,
|
||||
IReferenceRepository,
|
||||
)
|
||||
|
||||
|
||||
class SpaceService:
|
||||
"""
|
||||
Main orchestration service for Information Space operations.
|
||||
|
||||
Provides a high-level API for managing spaces, documents, variables,
|
||||
and transclusion references. This service coordinates between the
|
||||
repository layer and future event/rendering systems.
|
||||
|
||||
Usage:
|
||||
service = SpaceService(
|
||||
space_repo=SqliteSpaceRepository(db_path),
|
||||
document_repo=SqliteDocumentRepository(db_path),
|
||||
variable_repo=SqliteVariableRepository(db_path),
|
||||
reference_repo=SqliteReferenceRepository(db_path),
|
||||
)
|
||||
|
||||
# Create a space
|
||||
space = service.create_space(name="my-docs", description="My documentation")
|
||||
|
||||
# Add documents
|
||||
service.add_document(space.id, "/intro.md", document_id="doc-1")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
space_repo: ISpaceRepository,
|
||||
document_repo: IDocumentAssociationRepository,
|
||||
variable_repo: IVariableRepository,
|
||||
reference_repo: IReferenceRepository,
|
||||
):
|
||||
"""
|
||||
Initialize the SpaceService.
|
||||
|
||||
Args:
|
||||
space_repo: Repository for space CRUD operations
|
||||
document_repo: Repository for document associations
|
||||
variable_repo: Repository for space variables
|
||||
reference_repo: Repository for transclusion references
|
||||
"""
|
||||
self._space_repo = space_repo
|
||||
self._document_repo = document_repo
|
||||
self._variable_repo = variable_repo
|
||||
self._reference_repo = reference_repo
|
||||
|
||||
# =========================================================================
|
||||
# Space CRUD Operations
|
||||
# =========================================================================
|
||||
|
||||
def create_space(
|
||||
self,
|
||||
name: str,
|
||||
description: Optional[str] = None,
|
||||
config: Optional[SpaceConfig] = None,
|
||||
metadata: Optional[SpaceMetadata] = None,
|
||||
parent_space_id: Optional[str] = None,
|
||||
) -> InformationSpace:
|
||||
"""
|
||||
Create a new information space.
|
||||
|
||||
Args:
|
||||
name: Unique name for the space
|
||||
description: Optional description
|
||||
config: Optional configuration (defaults provided if None)
|
||||
metadata: Optional metadata (defaults provided if None)
|
||||
parent_space_id: Optional parent space for hierarchy
|
||||
|
||||
Returns:
|
||||
The created InformationSpace
|
||||
|
||||
Raises:
|
||||
ValueError: If name is empty or already exists
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
raise ValueError("Space name cannot be empty")
|
||||
|
||||
# Validate parent exists if specified
|
||||
if parent_space_id:
|
||||
parent = self._space_repo.get_by_id(parent_space_id)
|
||||
if not parent:
|
||||
raise ValueError(f"Parent space '{parent_space_id}' not found")
|
||||
|
||||
space = InformationSpace(
|
||||
name=name.strip(),
|
||||
description=description,
|
||||
config=config or SpaceConfig(),
|
||||
metadata=metadata or SpaceMetadata(),
|
||||
parent_space_id=parent_space_id,
|
||||
)
|
||||
|
||||
return self._space_repo.create(space)
|
||||
|
||||
def get_space(self, space_id: str) -> Optional[InformationSpace]:
|
||||
"""
|
||||
Get a space by its ID.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
The space if found, None otherwise
|
||||
"""
|
||||
return self._space_repo.get_by_id(space_id)
|
||||
|
||||
def get_space_by_name(self, name: str) -> Optional[InformationSpace]:
|
||||
"""
|
||||
Get a space by its name.
|
||||
|
||||
Args:
|
||||
name: The space name
|
||||
|
||||
Returns:
|
||||
The space if found, None otherwise
|
||||
"""
|
||||
return self._space_repo.get_by_name(name)
|
||||
|
||||
def list_spaces(self, include_archived: bool = False) -> List[InformationSpace]:
|
||||
"""
|
||||
List all spaces.
|
||||
|
||||
Args:
|
||||
include_archived: Whether to include archived spaces
|
||||
|
||||
Returns:
|
||||
List of spaces
|
||||
"""
|
||||
return self._space_repo.list_all(include_archived=include_archived)
|
||||
|
||||
def update_space(
|
||||
self,
|
||||
space_id: str,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
config: Optional[SpaceConfig] = None,
|
||||
metadata: Optional[SpaceMetadata] = None,
|
||||
) -> InformationSpace:
|
||||
"""
|
||||
Update a space's properties.
|
||||
|
||||
Args:
|
||||
space_id: The space ID to update
|
||||
name: New name (optional)
|
||||
description: New description (optional)
|
||||
config: New config (optional)
|
||||
metadata: New metadata (optional)
|
||||
|
||||
Returns:
|
||||
The updated space
|
||||
|
||||
Raises:
|
||||
ValueError: If space not found or name already taken
|
||||
"""
|
||||
space = self._space_repo.get_by_id(space_id)
|
||||
if not space:
|
||||
raise ValueError(f"Space '{space_id}' not found")
|
||||
|
||||
if name is not None:
|
||||
if not name.strip():
|
||||
raise ValueError("Space name cannot be empty")
|
||||
# Check if name is taken by another space
|
||||
existing = self._space_repo.get_by_name(name.strip())
|
||||
if existing and existing.id != space_id:
|
||||
raise ValueError(f"Space name '{name}' already exists")
|
||||
space.name = name.strip()
|
||||
|
||||
if description is not None:
|
||||
space.description = description
|
||||
|
||||
if config is not None:
|
||||
space.config = config
|
||||
|
||||
if metadata is not None:
|
||||
space.metadata = metadata
|
||||
|
||||
return self._space_repo.update(space)
|
||||
|
||||
def delete_space(self, space_id: str, cascade: bool = True) -> bool:
|
||||
"""
|
||||
Delete a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID to delete
|
||||
cascade: If True, delete all child spaces too
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
|
||||
Raises:
|
||||
ValueError: If space has children and cascade is False
|
||||
"""
|
||||
space = self._space_repo.get_by_id(space_id)
|
||||
if not space:
|
||||
return False
|
||||
|
||||
children = self._space_repo.get_children(space_id)
|
||||
if children and not cascade:
|
||||
raise ValueError(
|
||||
f"Space '{space_id}' has {len(children)} child spaces. "
|
||||
"Set cascade=True to delete them."
|
||||
)
|
||||
|
||||
# Delete children first (if cascade)
|
||||
if cascade:
|
||||
for child in children:
|
||||
self.delete_space(child.id, cascade=True)
|
||||
|
||||
return self._space_repo.delete(space_id)
|
||||
|
||||
def activate_space(self, space_id: str) -> InformationSpace:
|
||||
"""
|
||||
Activate a space (change status from draft to active).
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
The updated space
|
||||
|
||||
Raises:
|
||||
ValueError: If space not found
|
||||
"""
|
||||
space = self._space_repo.get_by_id(space_id)
|
||||
if not space:
|
||||
raise ValueError(f"Space '{space_id}' not found")
|
||||
|
||||
space.activate()
|
||||
return self._space_repo.update(space)
|
||||
|
||||
def archive_space(self, space_id: str) -> InformationSpace:
|
||||
"""
|
||||
Archive a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
The updated space
|
||||
|
||||
Raises:
|
||||
ValueError: If space not found
|
||||
"""
|
||||
space = self._space_repo.get_by_id(space_id)
|
||||
if not space:
|
||||
raise ValueError(f"Space '{space_id}' not found")
|
||||
|
||||
space.archive()
|
||||
return self._space_repo.update(space)
|
||||
|
||||
def get_child_spaces(self, parent_space_id: str) -> List[InformationSpace]:
|
||||
"""
|
||||
Get all child spaces of a parent.
|
||||
|
||||
Args:
|
||||
parent_space_id: The parent space ID
|
||||
|
||||
Returns:
|
||||
List of child spaces
|
||||
"""
|
||||
return self._space_repo.get_children(parent_space_id)
|
||||
|
||||
# =========================================================================
|
||||
# Document Operations
|
||||
# =========================================================================
|
||||
|
||||
def add_document(
|
||||
self,
|
||||
space_id: str,
|
||||
space_path: str,
|
||||
document_id: Optional[str] = None,
|
||||
order_index: int = 0,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
content_hash: Optional[str] = None,
|
||||
) -> SpaceDocument:
|
||||
"""
|
||||
Add a document to a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
space_path: Path within the space (e.g., "/intro.md")
|
||||
document_id: External document ID (optional)
|
||||
order_index: Position in space ordering
|
||||
metadata: Document metadata
|
||||
content_hash: Content hash for change detection
|
||||
|
||||
Returns:
|
||||
The created document association
|
||||
|
||||
Raises:
|
||||
ValueError: If space not found or path already exists
|
||||
"""
|
||||
if not self._space_repo.exists(space_id):
|
||||
raise ValueError(f"Space '{space_id}' not found")
|
||||
|
||||
# Normalize path
|
||||
if not space_path.startswith("/"):
|
||||
space_path = "/" + space_path
|
||||
|
||||
document = SpaceDocument(
|
||||
space_id=space_id,
|
||||
document_id=document_id or "",
|
||||
space_path=space_path,
|
||||
order_index=order_index,
|
||||
metadata=metadata or {},
|
||||
content_hash=content_hash,
|
||||
)
|
||||
|
||||
return self._document_repo.add_document(document)
|
||||
|
||||
def get_document(self, document_id: str) -> Optional[SpaceDocument]:
|
||||
"""
|
||||
Get a document by its association ID.
|
||||
|
||||
Args:
|
||||
document_id: The document association ID
|
||||
|
||||
Returns:
|
||||
The document if found, None otherwise
|
||||
"""
|
||||
return self._document_repo.get_document(document_id)
|
||||
|
||||
def get_document_by_path(
|
||||
self, space_id: str, space_path: str
|
||||
) -> Optional[SpaceDocument]:
|
||||
"""
|
||||
Get a document by its path within a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
space_path: The path within the space
|
||||
|
||||
Returns:
|
||||
The document if found, None otherwise
|
||||
"""
|
||||
# Normalize path
|
||||
if not space_path.startswith("/"):
|
||||
space_path = "/" + space_path
|
||||
return self._document_repo.get_by_space_path(space_id, space_path)
|
||||
|
||||
def list_documents(self, space_id: str) -> List[SpaceDocument]:
|
||||
"""
|
||||
List all documents in a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
List of documents ordered by order_index
|
||||
"""
|
||||
return self._document_repo.list_by_space(space_id)
|
||||
|
||||
def remove_document(self, document_id: str) -> bool:
|
||||
"""
|
||||
Remove a document from a space.
|
||||
|
||||
Args:
|
||||
document_id: The document association ID
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
"""
|
||||
# Clear any references from this document first
|
||||
document = self._document_repo.get_document(document_id)
|
||||
if document:
|
||||
self._reference_repo.clear_references_from(document_id, document.space_id)
|
||||
|
||||
return self._document_repo.remove_document(document_id)
|
||||
|
||||
def move_document(self, document_id: str, new_path: str) -> SpaceDocument:
|
||||
"""
|
||||
Move a document to a new path within its space.
|
||||
|
||||
Args:
|
||||
document_id: The document association ID
|
||||
new_path: The new path
|
||||
|
||||
Returns:
|
||||
The updated document
|
||||
|
||||
Raises:
|
||||
ValueError: If document not found or new path exists
|
||||
"""
|
||||
if not new_path.startswith("/"):
|
||||
new_path = "/" + new_path
|
||||
return self._document_repo.move_document(document_id, new_path)
|
||||
|
||||
def reorder_documents(self, space_id: str, document_ids: List[str]) -> None:
|
||||
"""
|
||||
Reorder documents within a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
document_ids: Ordered list of document IDs
|
||||
"""
|
||||
self._document_repo.reorder_documents(space_id, document_ids)
|
||||
|
||||
def update_document_hash(self, document_id: str, content_hash: str) -> None:
|
||||
"""
|
||||
Update the content hash for a document.
|
||||
|
||||
Args:
|
||||
document_id: The document association ID
|
||||
content_hash: The new content hash
|
||||
"""
|
||||
self._document_repo.update_content_hash(document_id, content_hash)
|
||||
|
||||
# =========================================================================
|
||||
# Variable Operations
|
||||
# =========================================================================
|
||||
|
||||
def set_variable(
|
||||
self,
|
||||
space_id: str,
|
||||
name: str,
|
||||
value: Any,
|
||||
scope: str = "space",
|
||||
) -> SpaceVariable:
|
||||
"""
|
||||
Set a variable in a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
name: Variable name
|
||||
value: Variable value (any JSON-serializable value)
|
||||
scope: Variable scope ("space" or "document")
|
||||
|
||||
Returns:
|
||||
The saved variable
|
||||
|
||||
Raises:
|
||||
ValueError: If space not found
|
||||
"""
|
||||
if not self._space_repo.exists(space_id):
|
||||
raise ValueError(f"Space '{space_id}' not found")
|
||||
|
||||
variable = SpaceVariable(
|
||||
space_id=space_id,
|
||||
name=name,
|
||||
value=value,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
return self._variable_repo.set_variable(variable)
|
||||
|
||||
def get_variable(self, space_id: str, name: str) -> Optional[SpaceVariable]:
|
||||
"""
|
||||
Get a variable by name.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
name: Variable name
|
||||
|
||||
Returns:
|
||||
The variable if found, None otherwise
|
||||
"""
|
||||
return self._variable_repo.get_variable(space_id, name)
|
||||
|
||||
def list_variables(
|
||||
self, space_id: str, scope: Optional[str] = None
|
||||
) -> List[SpaceVariable]:
|
||||
"""
|
||||
List all variables in a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
scope: Optional scope filter
|
||||
|
||||
Returns:
|
||||
List of variables
|
||||
"""
|
||||
return self._variable_repo.list_variables(space_id, scope)
|
||||
|
||||
def delete_variable(self, space_id: str, name: str) -> bool:
|
||||
"""
|
||||
Delete a variable.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
name: Variable name
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
return self._variable_repo.delete_variable(space_id, name)
|
||||
|
||||
def get_variables_dict(self, space_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all variables as a dictionary.
|
||||
|
||||
Useful for transclusion context.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
Dictionary of variable names to values
|
||||
"""
|
||||
variables = self._variable_repo.list_variables(space_id)
|
||||
return {var.name: var.value for var in variables}
|
||||
|
||||
# =========================================================================
|
||||
# Reference Operations
|
||||
# =========================================================================
|
||||
|
||||
def add_reference(
|
||||
self,
|
||||
source_doc_id: str,
|
||||
target_doc_id: str,
|
||||
space_id: str,
|
||||
) -> TransclusionReference:
|
||||
"""
|
||||
Add a transclusion reference.
|
||||
|
||||
Args:
|
||||
source_doc_id: The source document ID
|
||||
target_doc_id: The target document ID
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
The created reference
|
||||
"""
|
||||
reference = TransclusionReference(
|
||||
source_doc_id=source_doc_id,
|
||||
target_doc_id=target_doc_id,
|
||||
space_id=space_id,
|
||||
)
|
||||
return self._reference_repo.add_reference(reference)
|
||||
|
||||
def get_references_from(
|
||||
self, source_doc_id: str, space_id: str
|
||||
) -> List[TransclusionReference]:
|
||||
"""
|
||||
Get all references from a source document.
|
||||
|
||||
Args:
|
||||
source_doc_id: The source document ID
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
List of references
|
||||
"""
|
||||
return self._reference_repo.get_references_from(source_doc_id, space_id)
|
||||
|
||||
def get_references_to(
|
||||
self, target_doc_id: str, space_id: str
|
||||
) -> List[TransclusionReference]:
|
||||
"""
|
||||
Get all references to a target document.
|
||||
|
||||
Args:
|
||||
target_doc_id: The target document ID
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
List of references
|
||||
"""
|
||||
return self._reference_repo.get_references_to(target_doc_id, space_id)
|
||||
|
||||
def clear_references_from(self, source_doc_id: str, space_id: str) -> int:
|
||||
"""
|
||||
Clear all references from a source document.
|
||||
|
||||
Args:
|
||||
source_doc_id: The source document ID
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
Number of references cleared
|
||||
"""
|
||||
return self._reference_repo.clear_references_from(source_doc_id, space_id)
|
||||
|
||||
def get_dependents(self, document_id: str, space_id: str) -> List[str]:
|
||||
"""
|
||||
Get all documents that depend on a given document.
|
||||
|
||||
Used for cache invalidation - returns documents that need
|
||||
to be re-rendered when the target document changes.
|
||||
|
||||
Args:
|
||||
document_id: The document ID
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
List of dependent document IDs
|
||||
"""
|
||||
return self._reference_repo.get_dependents(document_id, space_id)
|
||||
|
||||
# =========================================================================
|
||||
# Convenience Methods
|
||||
# =========================================================================
|
||||
|
||||
def space_exists(self, space_id: str) -> bool:
|
||||
"""
|
||||
Check if a space exists.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
True if exists, False otherwise
|
||||
"""
|
||||
return self._space_repo.exists(space_id)
|
||||
|
||||
def get_space_stats(self, space_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about a space.
|
||||
|
||||
Args:
|
||||
space_id: The space ID
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics
|
||||
|
||||
Raises:
|
||||
ValueError: If space not found
|
||||
"""
|
||||
space = self._space_repo.get_by_id(space_id)
|
||||
if not space:
|
||||
raise ValueError(f"Space '{space_id}' not found")
|
||||
|
||||
documents = self._document_repo.list_by_space(space_id)
|
||||
variables = self._variable_repo.list_variables(space_id)
|
||||
children = self._space_repo.get_children(space_id)
|
||||
|
||||
return {
|
||||
"space_id": space_id,
|
||||
"name": space.name,
|
||||
"status": space.status.value,
|
||||
"document_count": len(documents),
|
||||
"variable_count": len(variables),
|
||||
"child_space_count": len(children),
|
||||
"created_at": space.created_at.isoformat(),
|
||||
"updated_at": space.updated_at.isoformat(),
|
||||
}
|
||||
13
markitect/spaces/sync/__init__.py
Normal file
13
markitect/spaces/sync/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""
|
||||
Directory synchronization for Information Spaces.
|
||||
|
||||
This package provides filesystem integration:
|
||||
- SpaceToDirectory exporter using VariantFactory
|
||||
- DirectoryToSpace importer
|
||||
- Bidirectional sync coordinator
|
||||
- Filesystem watcher for external changes
|
||||
- Conflict detection and resolution
|
||||
"""
|
||||
|
||||
# Directory sync will be implemented in Phase 5
|
||||
__all__ = []
|
||||
12
markitect/spaces/transclusion/__init__.py
Normal file
12
markitect/spaces/transclusion/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Persistent transclusion context for Information Spaces.
|
||||
|
||||
This package extends the existing TransclusionContext with:
|
||||
- Database persistence for context state
|
||||
- Cross-space reference resolution
|
||||
- Reference graph for dependency tracking
|
||||
- Variable scope layers (space, document, request)
|
||||
"""
|
||||
|
||||
# Transclusion extensions will be implemented in Phase 3
|
||||
__all__ = []
|
||||
Reference in New Issue
Block a user