Files
resolutionflow/backend/app/api/endpoints/tree_markdown.py
chihlasm eac6e184ec feat: add dual-mode tree editor with Code Mode, variables, and markdown sync
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>
2026-02-10 09:45:26 -05:00

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,
)