Implements the full dual-mode tree editor (Plan Phases 1-5): Backend: - JSONB↔Markdown bidirectional serializer/parser with mistune - Markdown validator with line/column error reporting - 3 API endpoints: export-markdown, import-markdown, validate-markdown - Variable extraction/resolution service ([USER_INPUT], [VAR], [SAVE_AS]) - Session variables JSONB column (migration 028) - 39 tree markdown tests + variable service tests (403 total passing) Frontend: - Monaco-based Code Mode with custom Monarch tokenizer and dark theme - Autocomplete for @node_id refs, type values, variable names - Debounced validation (800ms) with inline Monaco error markers - Syntax help panel (absolute overlay, toggleable) - Starter template for new trees with valid cross-references - Bidirectional metadata sync (name/description/category/tags frontmatter) - Synchronous tree→markdown serializer (fixes async race condition) - Pre-save validation blocks save on broken refs or missing tree name - Mode-aware undo/redo: Monaco native in Code Mode, throttled zundo in Flow Mode - Variable prompt modal and frontend resolver for session navigation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
133 lines
4.7 KiB
Python
133 lines
4.7 KiB
Python
"""API endpoints for tree markdown import/export."""
|
|
from typing import Annotated
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from app.core.database import get_db
|
|
from app.models.tree import Tree
|
|
from app.models.user import User
|
|
from app.api.deps import get_current_active_user, require_engineer_or_admin
|
|
from app.core.permissions import can_access_tree, can_edit_tree
|
|
from app.schemas.tree_markdown import (
|
|
TreeMarkdownExportResponse,
|
|
TreeMarkdownImportRequest,
|
|
TreeMarkdownValidationResponse,
|
|
MarkdownValidationError,
|
|
)
|
|
from app.services.tree_markdown_service import serialize_tree_to_markdown
|
|
from app.services.tree_markdown_parser import parse_markdown_to_tree
|
|
from app.services.tree_markdown_validator import validate_tree_markdown
|
|
|
|
router = APIRouter(prefix="/trees", tags=["tree-markdown"])
|
|
|
|
|
|
@router.get("/{tree_id}/export-markdown", response_model=TreeMarkdownExportResponse)
|
|
async def export_tree_markdown(
|
|
tree_id: UUID,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
):
|
|
"""Export a tree's JSONB structure as ResolutionFlow markdown."""
|
|
result = await db.execute(
|
|
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
|
|
)
|
|
tree = result.scalar_one_or_none()
|
|
if not tree:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
|
|
|
|
if not can_access_tree(current_user, tree):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
|
|
|
metadata = {
|
|
"name": tree.name or "",
|
|
"description": tree.description or "",
|
|
"category": tree.category or "",
|
|
}
|
|
markdown = serialize_tree_to_markdown(tree.tree_structure, metadata=metadata)
|
|
return TreeMarkdownExportResponse(markdown=markdown)
|
|
|
|
|
|
@router.put("/{tree_id}/import-markdown", response_model=TreeMarkdownValidationResponse)
|
|
async def import_tree_markdown(
|
|
tree_id: UUID,
|
|
body: TreeMarkdownImportRequest,
|
|
db: Annotated[AsyncSession, Depends(get_db)],
|
|
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
|
):
|
|
"""Parse markdown and update a tree's JSONB structure."""
|
|
result = await db.execute(
|
|
select(Tree).where(Tree.id == tree_id, Tree.deleted_at.is_(None))
|
|
)
|
|
tree = result.scalar_one_or_none()
|
|
if not tree:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tree not found")
|
|
|
|
if not can_edit_tree(current_user, tree):
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
|
|
|
parse_result = parse_markdown_to_tree(body.markdown)
|
|
|
|
has_errors = any(e.severity == "error" for e in parse_result.errors)
|
|
if has_errors:
|
|
return TreeMarkdownValidationResponse(
|
|
valid=False,
|
|
errors=[
|
|
MarkdownValidationError(
|
|
line=e.line, column=e.column, message=e.message, severity=e.severity
|
|
)
|
|
for e in parse_result.errors
|
|
],
|
|
tree_structure=None,
|
|
)
|
|
|
|
# Apply the parsed tree structure
|
|
tree.tree_structure = parse_result.tree_structure
|
|
await db.commit()
|
|
|
|
return TreeMarkdownValidationResponse(
|
|
valid=True,
|
|
errors=[
|
|
MarkdownValidationError(
|
|
line=e.line, column=e.column, message=e.message, severity=e.severity
|
|
)
|
|
for e in parse_result.errors
|
|
],
|
|
tree_structure=parse_result.tree_structure,
|
|
)
|
|
|
|
|
|
@router.post("/validate-markdown", response_model=TreeMarkdownValidationResponse)
|
|
async def validate_markdown(
|
|
body: TreeMarkdownImportRequest,
|
|
current_user: Annotated[User, Depends(get_current_active_user)],
|
|
):
|
|
"""Validate markdown without saving. Returns errors and preview JSONB."""
|
|
parse_result = parse_markdown_to_tree(body.markdown)
|
|
all_errors = validate_tree_markdown(body.markdown)
|
|
|
|
# Deduplicate errors by message
|
|
seen: set[str] = set()
|
|
unique_errors: list[MarkdownValidationError] = []
|
|
for e in all_errors:
|
|
key = f"{e.line}:{e.column}:{e.message}"
|
|
if key not in seen:
|
|
seen.add(key)
|
|
unique_errors.append(
|
|
MarkdownValidationError(
|
|
line=e.line, column=e.column, message=e.message, severity=e.severity
|
|
)
|
|
)
|
|
|
|
has_hard_errors = any(e.severity == "error" for e in unique_errors)
|
|
|
|
return TreeMarkdownValidationResponse(
|
|
valid=not has_hard_errors,
|
|
errors=unique_errors,
|
|
tree_structure=parse_result.tree_structure,
|
|
metadata=parse_result.metadata,
|
|
)
|