Implements API layer for Information Spaces: - GraphQL schema types for spaces, documents, variables - GraphQL queries and mutations for space operations - CLI command group with all space management commands - Resolver functions connecting GraphQL to SpaceService - 38 unit tests for API components Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
563 lines
16 KiB
Python
563 lines
16 KiB
Python
"""
|
|
GraphQL resolvers for Information Spaces.
|
|
|
|
Implements resolver functions for space queries and mutations.
|
|
"""
|
|
|
|
from typing import Optional, Dict, Any, List
|
|
from pathlib import Path
|
|
|
|
from .space_schema import (
|
|
InformationSpaceType,
|
|
SpaceDocumentType,
|
|
SpaceVariableType,
|
|
SpaceConfigType,
|
|
SpaceMetadataType,
|
|
RenderResultType,
|
|
SyncResultType,
|
|
SyncConflictType,
|
|
CreateSpacePayload,
|
|
UpdateSpacePayload,
|
|
DeleteSpacePayload,
|
|
AddDocumentPayload,
|
|
RemoveDocumentPayload,
|
|
SetVariablePayload,
|
|
RenderDocumentPayload,
|
|
SyncSpacePayload,
|
|
)
|
|
|
|
|
|
def get_space_service():
|
|
"""Get the space service instance."""
|
|
# This would be configured during app initialization
|
|
# For now, we create a new instance
|
|
from ..spaces.services.space_service import SpaceService
|
|
from ..spaces.repositories.sqlite import (
|
|
SqliteSpaceRepository,
|
|
SqliteDocumentRepository,
|
|
SqliteVariableRepository,
|
|
SqliteReferenceRepository,
|
|
)
|
|
|
|
# Use default database path
|
|
import os
|
|
db_path = Path(os.path.expanduser("~/.markitect/spaces.db"))
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
return SpaceService(
|
|
space_repo=SqliteSpaceRepository(db_path),
|
|
document_repo=SqliteDocumentRepository(db_path),
|
|
variable_repo=SqliteVariableRepository(db_path),
|
|
reference_repo=SqliteReferenceRepository(db_path),
|
|
)
|
|
|
|
|
|
def _space_to_graphql(space) -> InformationSpaceType:
|
|
"""Convert a space model to GraphQL type."""
|
|
config = None
|
|
if space.config:
|
|
config = SpaceConfigType(
|
|
default_variant=space.config.default_variant,
|
|
enable_caching=space.config.enable_caching,
|
|
theme=space.config.theme,
|
|
history_enabled=space.config.history_enabled,
|
|
variable_scope=space.config.variable_scope,
|
|
)
|
|
|
|
metadata = None
|
|
if space.metadata:
|
|
metadata = SpaceMetadataType(
|
|
tags=space.metadata.tags if hasattr(space.metadata, 'tags') else [],
|
|
author=space.metadata.author if hasattr(space.metadata, 'author') else None,
|
|
custom=space.metadata.custom if hasattr(space.metadata, 'custom') else {},
|
|
)
|
|
|
|
return InformationSpaceType(
|
|
id=space.id,
|
|
name=space.name,
|
|
description=space.description,
|
|
status=space.status.value if hasattr(space.status, 'value') else str(space.status),
|
|
config=config,
|
|
metadata=metadata,
|
|
parent_space_id=space.parent_space_id,
|
|
created_at=space.created_at,
|
|
updated_at=space.updated_at,
|
|
)
|
|
|
|
|
|
def _document_to_graphql(doc) -> SpaceDocumentType:
|
|
"""Convert a document model to GraphQL type."""
|
|
return SpaceDocumentType(
|
|
id=doc.id,
|
|
space_id=doc.space_id,
|
|
document_id=doc.document_id,
|
|
space_path=doc.space_path,
|
|
order_index=doc.order_index,
|
|
metadata=doc.metadata,
|
|
added_at=doc.added_at,
|
|
)
|
|
|
|
|
|
def _variable_to_graphql(var) -> SpaceVariableType:
|
|
"""Convert a variable model to GraphQL type."""
|
|
return SpaceVariableType(
|
|
space_id=var.space_id,
|
|
name=var.name,
|
|
value=var.value,
|
|
scope=var.scope,
|
|
)
|
|
|
|
|
|
# Query Resolvers
|
|
def resolve_space(root, info, id=None, name=None):
|
|
"""Resolve a single space by ID or name."""
|
|
service = get_space_service()
|
|
|
|
if id:
|
|
space = service.get_space(id)
|
|
elif name:
|
|
space = service.get_space_by_name(name)
|
|
else:
|
|
return None
|
|
|
|
if space:
|
|
return _space_to_graphql(space)
|
|
return None
|
|
|
|
|
|
def resolve_spaces(root, info, status=None, limit=50, offset=0):
|
|
"""Resolve list of spaces."""
|
|
service = get_space_service()
|
|
spaces = service.list_spaces(status=status, limit=limit, offset=offset)
|
|
return [_space_to_graphql(s) for s in spaces]
|
|
|
|
|
|
def resolve_space_documents(root, info, space_id):
|
|
"""Resolve documents in a space."""
|
|
service = get_space_service()
|
|
documents = service.list_documents(space_id)
|
|
return [_document_to_graphql(d) for d in documents]
|
|
|
|
|
|
def resolve_space_document(root, info, space_id, space_path):
|
|
"""Resolve a specific document in a space."""
|
|
service = get_space_service()
|
|
doc = service.get_document(space_id, space_path)
|
|
if doc:
|
|
return _document_to_graphql(doc)
|
|
return None
|
|
|
|
|
|
def resolve_space_variables(root, info, space_id, scope=None):
|
|
"""Resolve variables in a space."""
|
|
service = get_space_service()
|
|
variables = service.list_variables(space_id, scope=scope)
|
|
return [_variable_to_graphql(v) for v in variables]
|
|
|
|
|
|
def resolve_space_variable(root, info, space_id, name):
|
|
"""Resolve a specific variable."""
|
|
service = get_space_service()
|
|
var = service.get_variable(space_id, name)
|
|
if var:
|
|
return _variable_to_graphql(var)
|
|
return None
|
|
|
|
|
|
# Mutation Resolvers
|
|
def resolve_create_space(root, info, input):
|
|
"""Create a new space."""
|
|
try:
|
|
service = get_space_service()
|
|
|
|
# Build config and metadata from input
|
|
from ..spaces.models import SpaceConfig, SpaceMetadata
|
|
|
|
config = SpaceConfig()
|
|
if input.config:
|
|
if input.config.default_variant:
|
|
config.default_variant = input.config.default_variant
|
|
if input.config.enable_caching is not None:
|
|
config.enable_caching = input.config.enable_caching
|
|
if input.config.theme:
|
|
config.theme = input.config.theme
|
|
if input.config.history_enabled is not None:
|
|
config.history_enabled = input.config.history_enabled
|
|
|
|
metadata = SpaceMetadata()
|
|
if input.metadata:
|
|
if input.metadata.tags:
|
|
metadata.tags = input.metadata.tags
|
|
if input.metadata.author:
|
|
metadata.author = input.metadata.author
|
|
if input.metadata.custom:
|
|
metadata.custom = input.metadata.custom
|
|
|
|
space = service.create_space(
|
|
name=input.name,
|
|
description=input.description,
|
|
config=config,
|
|
metadata=metadata,
|
|
parent_space_id=input.parent_space_id,
|
|
)
|
|
|
|
return CreateSpacePayload(
|
|
space=_space_to_graphql(space),
|
|
success=True,
|
|
errors=[],
|
|
)
|
|
|
|
except Exception as e:
|
|
return CreateSpacePayload(
|
|
space=None,
|
|
success=False,
|
|
errors=[str(e)],
|
|
)
|
|
|
|
|
|
def resolve_update_space(root, info, id, input):
|
|
"""Update a space."""
|
|
try:
|
|
service = get_space_service()
|
|
|
|
updates = {}
|
|
if input.name:
|
|
updates["name"] = input.name
|
|
if input.description:
|
|
updates["description"] = input.description
|
|
|
|
space = service.update_space(id, **updates)
|
|
|
|
return UpdateSpacePayload(
|
|
space=_space_to_graphql(space),
|
|
success=True,
|
|
errors=[],
|
|
)
|
|
|
|
except Exception as e:
|
|
return UpdateSpacePayload(
|
|
space=None,
|
|
success=False,
|
|
errors=[str(e)],
|
|
)
|
|
|
|
|
|
def resolve_delete_space(root, info, id):
|
|
"""Delete a space."""
|
|
try:
|
|
service = get_space_service()
|
|
service.delete_space(id)
|
|
|
|
return DeleteSpacePayload(
|
|
success=True,
|
|
deleted_id=id,
|
|
errors=[],
|
|
)
|
|
|
|
except Exception as e:
|
|
return DeleteSpacePayload(
|
|
success=False,
|
|
deleted_id=None,
|
|
errors=[str(e)],
|
|
)
|
|
|
|
|
|
def resolve_activate_space(root, info, id):
|
|
"""Activate a draft space."""
|
|
try:
|
|
service = get_space_service()
|
|
space = service.activate_space(id)
|
|
|
|
return UpdateSpacePayload(
|
|
space=_space_to_graphql(space),
|
|
success=True,
|
|
errors=[],
|
|
)
|
|
|
|
except Exception as e:
|
|
return UpdateSpacePayload(
|
|
space=None,
|
|
success=False,
|
|
errors=[str(e)],
|
|
)
|
|
|
|
|
|
def resolve_archive_space(root, info, id):
|
|
"""Archive a space."""
|
|
try:
|
|
service = get_space_service()
|
|
space = service.archive_space(id)
|
|
|
|
return UpdateSpacePayload(
|
|
space=_space_to_graphql(space),
|
|
success=True,
|
|
errors=[],
|
|
)
|
|
|
|
except Exception as e:
|
|
return UpdateSpacePayload(
|
|
space=None,
|
|
success=False,
|
|
errors=[str(e)],
|
|
)
|
|
|
|
|
|
def resolve_add_document(root, info, space_id, input):
|
|
"""Add a document to a space."""
|
|
try:
|
|
service = get_space_service()
|
|
|
|
doc = service.add_document(
|
|
space_id=space_id,
|
|
document_id=input.document_id,
|
|
space_path=input.space_path,
|
|
order_index=input.order_index or 0,
|
|
metadata=input.metadata,
|
|
)
|
|
|
|
return AddDocumentPayload(
|
|
document=_document_to_graphql(doc),
|
|
success=True,
|
|
errors=[],
|
|
)
|
|
|
|
except Exception as e:
|
|
return AddDocumentPayload(
|
|
document=None,
|
|
success=False,
|
|
errors=[str(e)],
|
|
)
|
|
|
|
|
|
def resolve_remove_document(root, info, space_id, space_path):
|
|
"""Remove a document from a space."""
|
|
try:
|
|
service = get_space_service()
|
|
service.remove_document(space_id, space_path)
|
|
|
|
return RemoveDocumentPayload(
|
|
success=True,
|
|
removed_path=space_path,
|
|
errors=[],
|
|
)
|
|
|
|
except Exception as e:
|
|
return RemoveDocumentPayload(
|
|
success=False,
|
|
removed_path=None,
|
|
errors=[str(e)],
|
|
)
|
|
|
|
|
|
def resolve_set_variable(root, info, space_id, name, value, scope=None):
|
|
"""Set a variable in a space."""
|
|
try:
|
|
service = get_space_service()
|
|
|
|
var = service.set_variable(
|
|
space_id=space_id,
|
|
name=name,
|
|
value=value,
|
|
scope=scope or "space",
|
|
)
|
|
|
|
return SetVariablePayload(
|
|
variable=_variable_to_graphql(var),
|
|
success=True,
|
|
errors=[],
|
|
)
|
|
|
|
except Exception as e:
|
|
return SetVariablePayload(
|
|
variable=None,
|
|
success=False,
|
|
errors=[str(e)],
|
|
)
|
|
|
|
|
|
def resolve_delete_variable(root, info, space_id, name):
|
|
"""Delete a variable from a space."""
|
|
try:
|
|
service = get_space_service()
|
|
service.delete_variable(space_id, name)
|
|
|
|
return SetVariablePayload(
|
|
variable=None,
|
|
success=True,
|
|
errors=[],
|
|
)
|
|
|
|
except Exception as e:
|
|
return SetVariablePayload(
|
|
variable=None,
|
|
success=False,
|
|
errors=[str(e)],
|
|
)
|
|
|
|
|
|
def resolve_render_document(root, info, space_id, document_path, options=None):
|
|
"""Render a document to HTML."""
|
|
try:
|
|
from ..spaces.rendering import SpaceRenderingService, RenderConfig, ThemeConfig
|
|
|
|
service = get_space_service()
|
|
doc = service.get_document(space_id, document_path)
|
|
|
|
if not doc:
|
|
return RenderDocumentPayload(
|
|
result=None,
|
|
success=False,
|
|
errors=[f"Document not found: {document_path}"],
|
|
)
|
|
|
|
# Get render config from options
|
|
theme_name = "default"
|
|
include_toc = False
|
|
force_refresh = False
|
|
|
|
if options:
|
|
if options.theme:
|
|
theme_name = options.theme
|
|
if options.include_toc:
|
|
include_toc = options.include_toc
|
|
if options.force_refresh:
|
|
force_refresh = options.force_refresh
|
|
|
|
config = RenderConfig(
|
|
theme=ThemeConfig(name=theme_name, layers=[theme_name]),
|
|
include_toc=include_toc,
|
|
)
|
|
|
|
renderer = SpaceRenderingService()
|
|
# Would need actual content here
|
|
result = renderer.render_document(
|
|
content="# Placeholder", # Would get actual content
|
|
document_id=doc.document_id,
|
|
space_id=space_id,
|
|
force_refresh=force_refresh,
|
|
)
|
|
|
|
return RenderDocumentPayload(
|
|
result=RenderResultType(
|
|
content=result.content,
|
|
format=result.format.value,
|
|
content_hash=result.content_hash,
|
|
source_hash=result.source_hash,
|
|
document_id=result.document_id,
|
|
cached=False,
|
|
),
|
|
success=True,
|
|
errors=[],
|
|
)
|
|
|
|
except Exception as e:
|
|
return RenderDocumentPayload(
|
|
result=None,
|
|
success=False,
|
|
errors=[str(e)],
|
|
)
|
|
|
|
|
|
def resolve_sync_space(root, info, space_id, directory, options=None):
|
|
"""Sync a space with a directory."""
|
|
try:
|
|
from ..spaces.sync import (
|
|
BidirectionalSyncCoordinator,
|
|
SyncConfig,
|
|
SyncDirection,
|
|
ConflictResolution,
|
|
)
|
|
|
|
service = get_space_service()
|
|
space = service.get_space(space_id)
|
|
|
|
if not space:
|
|
return SyncSpacePayload(
|
|
result=None,
|
|
conflicts=[],
|
|
success=False,
|
|
errors=[f"Space not found: {space_id}"],
|
|
)
|
|
|
|
# Build sync config
|
|
direction = SyncDirection.BIDIRECTIONAL
|
|
conflict_res = ConflictResolution.NEWER_WINS
|
|
dry_run = False
|
|
|
|
if options:
|
|
if options.direction:
|
|
direction = SyncDirection(options.direction)
|
|
if options.conflict_resolution:
|
|
conflict_res = ConflictResolution(options.conflict_resolution)
|
|
if options.dry_run:
|
|
dry_run = options.dry_run
|
|
|
|
config = SyncConfig(
|
|
direction=direction,
|
|
conflict_resolution=conflict_res,
|
|
dry_run=dry_run,
|
|
)
|
|
|
|
coordinator = BidirectionalSyncCoordinator(config)
|
|
|
|
documents = service.list_documents(space_id)
|
|
|
|
def content_provider(doc_id):
|
|
return "" # Would get actual content
|
|
|
|
result = coordinator.sync(
|
|
space=space,
|
|
documents=documents,
|
|
content_provider=content_provider,
|
|
directory=Path(directory),
|
|
)
|
|
|
|
return SyncSpacePayload(
|
|
result=SyncResultType(
|
|
success=result.success,
|
|
created_count=result.created_count,
|
|
updated_count=result.updated_count,
|
|
deleted_count=result.deleted_count,
|
|
conflict_count=len(result.conflicts),
|
|
errors=list(result.errors.values()),
|
|
),
|
|
conflicts=[
|
|
SyncConflictType(
|
|
path=c.path,
|
|
space_hash=c.space_state.content_hash if c.space_state else None,
|
|
directory_hash=c.directory_state.content_hash if c.directory_state else None,
|
|
resolution=c.resolution.value if hasattr(c.resolution, 'value') else str(c.resolution),
|
|
winner=c.winner,
|
|
)
|
|
for c in result.conflicts
|
|
],
|
|
success=result.success,
|
|
errors=[],
|
|
)
|
|
|
|
except Exception as e:
|
|
return SyncSpacePayload(
|
|
result=None,
|
|
conflicts=[],
|
|
success=False,
|
|
errors=[str(e)],
|
|
)
|
|
|
|
|
|
def resolve_invalidate_cache(root, info, space_id, document_path=None):
|
|
"""Invalidate render cache."""
|
|
try:
|
|
from ..spaces.rendering import SpaceRenderingService
|
|
|
|
service = SpaceRenderingService()
|
|
|
|
if document_path:
|
|
service.invalidate_document(document_path, space_id)
|
|
else:
|
|
service.invalidate_space(space_id)
|
|
|
|
return True
|
|
|
|
except Exception:
|
|
return False
|