"""Upload PPT/PPTX decks and convert to presentation JSON + HTML.""" from __future__ import annotations import json import os import re import uuid from datetime import datetime, timezone from pathlib import Path from typing import Any import httpx PRESENTATIONS_DIR = Path(os.getenv("PRESENTATIONS_DIR", "/data/presentations")) DOCLING_URL = os.getenv("DOCLING_URL", "http://docling-serve:5001").rstrip("/") def _ensure_dir() -> Path: PRESENTATIONS_DIR.mkdir(parents=True, exist_ok=True) return PRESENTATIONS_DIR def list_decks() -> list[dict[str, Any]]: _ensure_dir() decks = [] for meta_path in sorted(PRESENTATIONS_DIR.glob("*/meta.json"), key=lambda p: p.stat().st_mtime, reverse=True): try: meta = json.loads(meta_path.read_text()) decks.append(meta) except Exception: continue return decks def _safe_name(name: str) -> str: return re.sub(r"[^a-zA-Z0-9._-]+", "_", name)[:80] def pptx_to_slides(path: Path) -> list[dict[str, Any]]: from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE prs = Presentation(str(path)) slides: list[dict[str, Any]] = [] for idx, slide in enumerate(prs.slides, start=1): bullets: list[str] = [] title = "" for shape in slide.shapes: if not hasattr(shape, "text"): continue text = (shape.text or "").strip() if not text: continue if shape.shape_type == MSO_SHAPE_TYPE.PLACEHOLDER and not title: title = text.split("\n")[0][:120] else: for line in text.split("\n"): line = line.strip() if line and line != title: bullets.append(line[:240]) if not title: title = f"Slide {idx}" slides.append({ "id": f"upload-{idx}", "title": title, "subtitle": "", "bullets": bullets[:12] or ["(empty slide)"], "kind": "upload", }) return slides async def docling_enrich(path: Path) -> list[dict[str, Any]] | None: """Optional: parse via Docling for richer structure.""" try: async with httpx.AsyncClient(timeout=120.0) as client: with path.open("rb") as f: r = await client.post( f"{DOCLING_URL}/v1/convert/file", files={"files": (path.name, f, "application/octet-stream")}, data={"to_formats": "md"}, ) if r.status_code >= 400: return None data = r.json() md = "" if isinstance(data, dict): doc = data.get("document") or data.get("result") or data if isinstance(doc, dict): md = doc.get("md_content") or doc.get("markdown") or "" elif isinstance(doc, str): md = doc if not md: return None slides = [] chunks = [c.strip() for c in re.split(r"\n#{1,2}\s+", md) if c.strip()] for i, chunk in enumerate(chunks[:40], start=1): lines = [ln.strip() for ln in chunk.split("\n") if ln.strip()] title = lines[0][:120] if lines else f"Slide {i}" bullets = [ln.lstrip("-•* ").strip() for ln in lines[1:13] if ln.strip()] slides.append({ "id": f"docling-{i}", "title": title, "subtitle": "Docling parsed", "bullets": bullets or ["—"], "kind": "upload", }) return slides if slides else None except Exception: return None async def save_upload(filename: str, content: bytes) -> dict[str, Any]: _ensure_dir() deck_id = str(uuid.uuid4())[:8] deck_dir = PRESENTATIONS_DIR / deck_id deck_dir.mkdir(parents=True, exist_ok=True) safe = _safe_name(filename) dest = deck_dir / safe dest.write_bytes(content) slides: list[dict[str, Any]] = [] source = "pptx" if safe.lower().endswith((".pptx", ".ppt")): slides = pptx_to_slides(dest) docling_slides = await docling_enrich(dest) if docling_slides and len(docling_slides) >= len(slides): slides = docling_slides source = "docling+pptx" else: docling_slides = await docling_enrich(dest) if docling_slides: slides = docling_slides source = "docling" if not slides: slides = [{ "id": "upload-1", "title": safe, "subtitle": "Uploaded file", "bullets": [f"File stored at {dest.name}", "Could not auto-parse slides — open in editor or re-upload PPTX"], "kind": "upload", }] payload = { "id": deck_id, "filename": safe, "source": source, "ts": datetime.now(timezone.utc).isoformat(), "title": safe.rsplit(".", 1)[0], "subtitle": "Uploaded presentation", "slide_count": len(slides), "slides": slides, } (deck_dir / "meta.json").write_text(json.dumps(payload, indent=2, default=str)) (deck_dir / "deck.json").write_text(json.dumps(payload, default=str)) return payload def get_deck(deck_id: str) -> dict[str, Any] | None: path = PRESENTATIONS_DIR / deck_id / "meta.json" if not path.exists(): return None return json.loads(path.read_text())