""" Worksheet Editor API - Backend Endpoints for Visual Worksheet Editor Provides endpoints for: - AI Image generation via Ollama/Stable Diffusion - Worksheet Save/Load - PDF Export Split modules: - worksheet_editor_models: Enums, Pydantic models, configuration - worksheet_editor_ai: AI image generation and AI worksheet modification - worksheet_editor_reconstruct: Document reconstruction from vocab sessions """ import os import io import json import logging from datetime import datetime, timezone import uuid from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse import httpx # Re-export everything from sub-modules for backward compatibility from worksheet_editor_models import ( # noqa: F401 AIImageStyle, WorksheetStatus, AIImageRequest, AIImageResponse, PageData, PageFormat, WorksheetSaveRequest, WorksheetResponse, AIModifyRequest, AIModifyResponse, ReconstructRequest, ReconstructResponse, worksheets_db, OLLAMA_URL, SD_MODEL, WORKSHEET_STORAGE_DIR, STYLE_PROMPTS, REPORTLAB_AVAILABLE, ) from worksheet_editor_ai import ( # noqa: F401 generate_ai_image_logic, _generate_placeholder_image, modify_worksheet_with_ai_logic, _handle_simple_modification, ) from worksheet_editor_reconstruct import ( # noqa: F401 reconstruct_document_logic, _detect_image_regions, ) logger = logging.getLogger(__name__) # ============================================= # ROUTER # ============================================= router = APIRouter(prefix="/api/v1/worksheet", tags=["Worksheet Editor"]) # ============================================= # AI IMAGE GENERATION # ============================================= @router.post("/ai-image", response_model=AIImageResponse) async def generate_ai_image(request: AIImageRequest): """ Generate an AI image using Ollama with a text-to-image model. Supported models: - stable-diffusion (via Ollama) - sd3.5-medium - llava (for image understanding, not generation) Falls back to a placeholder if Ollama is not available. """ return await generate_ai_image_logic(request) # ============================================= # WORKSHEET SAVE/LOAD # ============================================= @router.post("/save", response_model=WorksheetResponse) async def save_worksheet(request: WorksheetSaveRequest): """ Save a worksheet document. - If id is provided, updates existing worksheet - If id is not provided, creates new worksheet """ try: now = datetime.now(timezone.utc).isoformat() worksheet_id = request.id or f"ws_{uuid.uuid4().hex[:12]}" worksheet = { "id": worksheet_id, "title": request.title, "description": request.description, "pages": [p.dict() for p in request.pages], "pageFormat": (request.pageFormat or PageFormat()).dict(), "createdAt": worksheets_db.get(worksheet_id, {}).get("createdAt", now), "updatedAt": now } worksheets_db[worksheet_id] = worksheet filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json") with open(filepath, 'w', encoding='utf-8') as f: json.dump(worksheet, f, ensure_ascii=False, indent=2) logger.info(f"Saved worksheet: {worksheet_id}") return WorksheetResponse(**worksheet) except Exception as e: logger.error(f"Failed to save worksheet: {e}") raise HTTPException(status_code=500, detail=f"Failed to save: {str(e)}") @router.get("/{worksheet_id}", response_model=WorksheetResponse) async def get_worksheet(worksheet_id: str): """Load a worksheet document by ID.""" try: if worksheet_id in worksheets_db: return WorksheetResponse(**worksheets_db[worksheet_id]) filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json") if os.path.exists(filepath): with open(filepath, 'r', encoding='utf-8') as f: worksheet = json.load(f) worksheets_db[worksheet_id] = worksheet return WorksheetResponse(**worksheet) raise HTTPException(status_code=404, detail="Worksheet not found") except HTTPException: raise except Exception as e: logger.error(f"Failed to load worksheet {worksheet_id}: {e}") raise HTTPException(status_code=500, detail=f"Failed to load: {str(e)}") @router.get("/list/all") async def list_worksheets(): """List all available worksheets.""" try: worksheets = [] for filename in os.listdir(WORKSHEET_STORAGE_DIR): if filename.endswith('.json'): filepath = os.path.join(WORKSHEET_STORAGE_DIR, filename) try: with open(filepath, 'r', encoding='utf-8') as f: worksheet = json.load(f) worksheets.append({ "id": worksheet.get("id"), "title": worksheet.get("title"), "description": worksheet.get("description"), "pageCount": len(worksheet.get("pages", [])), "updatedAt": worksheet.get("updatedAt"), "createdAt": worksheet.get("createdAt") }) except Exception as e: logger.warning(f"Failed to load {filename}: {e}") worksheets.sort(key=lambda x: x.get("updatedAt", ""), reverse=True) return {"worksheets": worksheets, "total": len(worksheets)} except Exception as e: logger.error(f"Failed to list worksheets: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/{worksheet_id}") async def delete_worksheet(worksheet_id: str): """Delete a worksheet document.""" try: if worksheet_id in worksheets_db: del worksheets_db[worksheet_id] filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json") if os.path.exists(filepath): os.remove(filepath) logger.info(f"Deleted worksheet: {worksheet_id}") return {"status": "deleted", "id": worksheet_id} raise HTTPException(status_code=404, detail="Worksheet not found") except HTTPException: raise except Exception as e: logger.error(f"Failed to delete worksheet {worksheet_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) # ============================================= # PDF EXPORT # ============================================= @router.post("/{worksheet_id}/export-pdf") async def export_worksheet_pdf(worksheet_id: str): """ Export worksheet as PDF. Note: This creates a basic PDF. For full canvas rendering, the frontend should use pdf-lib with canvas.toDataURL(). """ if not REPORTLAB_AVAILABLE: raise HTTPException(status_code=501, detail="PDF export not available (reportlab not installed)") try: from reportlab.lib.pagesizes import A4 from reportlab.pdfgen import canvas worksheet = worksheets_db.get(worksheet_id) if not worksheet: filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json") if os.path.exists(filepath): with open(filepath, 'r', encoding='utf-8') as f: worksheet = json.load(f) else: raise HTTPException(status_code=404, detail="Worksheet not found") buffer = io.BytesIO() c = canvas.Canvas(buffer, pagesize=A4) page_width, page_height = A4 for page_data in worksheet.get("pages", []): if page_data.get("index", 0) == 0: c.setFont("Helvetica-Bold", 18) c.drawString(50, page_height - 50, worksheet.get("title", "Arbeitsblatt")) c.setFont("Helvetica", 10) c.drawString(50, page_height - 70, f"Erstellt: {worksheet.get('createdAt', '')[:10]}") canvas_json_str = page_data.get("canvasJSON", "{}") if canvas_json_str: try: canvas_data = json.loads(canvas_json_str) objects = canvas_data.get("objects", []) for obj in objects: obj_type = obj.get("type", "") if obj_type in ["text", "i-text", "textbox"]: text = obj.get("text", "") left = obj.get("left", 50) top = obj.get("top", 100) font_size = obj.get("fontSize", 12) pdf_x = left * 0.75 pdf_y = page_height - (top * 0.75) c.setFont("Helvetica", min(font_size, 24)) c.drawString(pdf_x, pdf_y, text[:100]) elif obj_type == "rect": left = obj.get("left", 0) * 0.75 top = obj.get("top", 0) * 0.75 width = obj.get("width", 50) * 0.75 height = obj.get("height", 30) * 0.75 c.rect(left, page_height - top - height, width, height) elif obj_type == "circle": left = obj.get("left", 0) * 0.75 top = obj.get("top", 0) * 0.75 radius = obj.get("radius", 25) * 0.75 c.circle(left + radius, page_height - top - radius, radius) except json.JSONDecodeError: pass c.showPage() c.save() buffer.seek(0) filename = f"{worksheet.get('title', 'worksheet').replace(' ', '_')}.pdf" return StreamingResponse( buffer, media_type="application/pdf", headers={"Content-Disposition": f"attachment; filename={filename}"} ) except HTTPException: raise except Exception as e: logger.error(f"PDF export failed: {e}") raise HTTPException(status_code=500, detail=str(e)) # ============================================= # AI WORKSHEET MODIFICATION # ============================================= @router.post("/ai-modify", response_model=AIModifyResponse) async def modify_worksheet_with_ai(request: AIModifyRequest): """ Modify a worksheet using AI based on natural language prompt. Uses Ollama with qwen2.5vl:32b to understand the canvas state and generate modifications based on the user's request. """ return await modify_worksheet_with_ai_logic(request) # ============================================= # HEALTH CHECK # ============================================= @router.get("/health/check") async def health_check(): """Check worksheet editor API health and dependencies.""" status = { "status": "healthy", "ollama": False, "storage": os.path.exists(WORKSHEET_STORAGE_DIR), "reportlab": REPORTLAB_AVAILABLE, "worksheets_count": len(worksheets_db) } try: async with httpx.AsyncClient(timeout=5.0) as client: response = await client.get(f"{OLLAMA_URL}/api/tags") status["ollama"] = response.status_code == 200 except Exception: pass return status # ============================================= # DOCUMENT RECONSTRUCTION FROM VOCAB SESSION # ============================================= @router.post("/reconstruct-from-session", response_model=ReconstructResponse) async def reconstruct_document_from_session(request: ReconstructRequest): """ Reconstruct a document from a vocab session into Fabric.js canvas format. Returns canvas JSON ready to load into the worksheet editor. """ try: return await reconstruct_document_logic(request) except HTTPException: raise except Exception as e: logger.error(f"Document reconstruction failed: {e}") import traceback logger.error(traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e)) @router.get("/sessions/available") async def get_available_sessions(): """Get list of available vocab sessions that can be reconstructed.""" try: from vocab_worksheet_api import _sessions available = [] for session_id, session in _sessions.items(): if session.get("pdf_data"): available.append({ "id": session_id, "name": session.get("name", "Unnamed"), "description": session.get("description"), "vocabulary_count": len(session.get("vocabulary", [])), "page_count": session.get("pdf_page_count", 1), "status": session.get("status", "unknown"), "created_at": session.get("created_at", "").isoformat() if session.get("created_at") else None }) return {"sessions": available, "total": len(available)} except Exception as e: logger.error(f"Failed to list sessions: {e}") raise HTTPException(status_code=500, detail=str(e))