Files

321 lines
11 KiB
Python
Raw Permalink Normal View History

"""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"))
LIVE_OVERRIDE_DIR = PRESENTATIONS_DIR / "live-override"
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())
def get_live_override() -> dict[str, Any] | None:
path = LIVE_OVERRIDE_DIR / "meta.json"
if not path.exists():
return None
try:
return json.loads(path.read_text())
except Exception:
return None
def save_live_override(body: dict[str, Any]) -> dict[str, Any]:
"""Persist user edits for the Live Cluster deck."""
LIVE_OVERRIDE_DIR.mkdir(parents=True, exist_ok=True)
(LIVE_OVERRIDE_DIR / "assets").mkdir(parents=True, exist_ok=True)
existing = get_live_override() or {"id": "live", "source": "live-override", "editable": True}
slides = _clean_slides(body.get("slides"))
payload = {
**existing,
"id": "live",
"source": "live-override",
"editable": True,
"title": str(body.get("title") or existing.get("title") or "Live Cluster")[:120],
"subtitle": str(body.get("subtitle") or existing.get("subtitle") or "")[:300],
"ts": datetime.now(timezone.utc).isoformat(),
"slides": slides,
"slide_count": len(slides),
}
(LIVE_OVERRIDE_DIR / "meta.json").write_text(json.dumps(payload, indent=2, default=str))
return payload
def clear_live_override() -> bool:
import shutil
if not LIVE_OVERRIDE_DIR.exists():
return True
shutil.rmtree(LIVE_OVERRIDE_DIR, ignore_errors=True)
return True
def _clean_slides(incoming_slides: Any) -> list[dict[str, Any]]:
clean_slides: list[dict[str, Any]] = []
for i, s in enumerate(incoming_slides or [], start=1):
if not isinstance(s, dict):
continue
bullets = [str(b).strip()[:400] for b in (s.get("bullets") or []) if str(b).strip()]
slide: dict[str, Any] = {
"id": str(s.get("id") or f"slide-{i}"),
"title": str(s.get("title") or f"Slide {i}")[:200],
"subtitle": str(s.get("subtitle") or "")[:300],
"bullets": bullets,
"image": str(s.get("image") or "")[:300],
"kind": str(s.get("kind") or "narrative")[:40],
}
if s.get("animation"):
slide["animation"] = str(s.get("animation"))[:40]
clean_slides.append(slide)
return clean_slides or [_blank_slide(1)]
def _blank_slide(idx: int = 1) -> dict[str, Any]:
return {
"id": f"slide-{idx}",
"title": f"Slide {idx}",
"subtitle": "",
"bullets": ["Double-click to edit this point"],
"image": "",
"kind": "narrative",
}
def create_deck(title: str = "Untitled deck", slides: list[dict[str, Any]] | None = None) -> dict[str, Any]:
"""Create a new editable deck (optionally seeded from existing slides)."""
_ensure_dir()
deck_id = str(uuid.uuid4())[:8]
deck_dir = PRESENTATIONS_DIR / deck_id
(deck_dir / "assets").mkdir(parents=True, exist_ok=True)
seeded = [dict(s) for s in (slides or [])] or [_blank_slide(1)]
payload = {
"id": deck_id,
"filename": "",
"source": "editor",
"editable": True,
"ts": datetime.now(timezone.utc).isoformat(),
"title": (title or "Untitled deck").strip()[:120] or "Untitled deck",
"subtitle": "Editable deck",
"slide_count": len(seeded),
"slides": seeded,
}
(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 save_deck(deck_id: str, body: dict[str, Any]) -> dict[str, Any] | None:
"""Overwrite an existing editable deck's content. Returns None if missing."""
deck_dir = PRESENTATIONS_DIR / deck_id
meta_path = deck_dir / "meta.json"
if not meta_path.exists():
return None
existing = json.loads(meta_path.read_text())
clean_slides = _clean_slides(body.get("slides"))
existing.update({
"title": str(body.get("title") or existing.get("title") or "Untitled deck")[:120],
"subtitle": str(body.get("subtitle") or existing.get("subtitle") or "")[:300],
"editable": True,
"source": existing.get("source") or "editor",
"ts": datetime.now(timezone.utc).isoformat(),
"slides": clean_slides,
"slide_count": len(clean_slides),
})
meta_path.write_text(json.dumps(existing, indent=2, default=str))
(deck_dir / "deck.json").write_text(json.dumps(existing, default=str))
return existing
def delete_deck(deck_id: str) -> bool:
import shutil
deck_dir = PRESENTATIONS_DIR / deck_id
if not deck_dir.exists():
return False
shutil.rmtree(deck_dir, ignore_errors=True)
return True
def save_image(deck_id: str, filename: str, content: bytes) -> dict[str, Any] | None:
"""Store an image in the deck's assets folder; return its served URL."""
if deck_id == "live":
deck_dir = LIVE_OVERRIDE_DIR
else:
deck_dir = PRESENTATIONS_DIR / deck_id
if not deck_dir.exists():
return None
assets = deck_dir / "assets"
assets.mkdir(parents=True, exist_ok=True)
ext = ""
if "." in filename:
ext = "." + _safe_name(filename.rsplit(".", 1)[-1].lower())[:8]
name = f"{uuid.uuid4().hex[:12]}{ext}"
(assets / name).write_bytes(content)
return {"name": name, "url": f"/api/presentation/decks/{deck_id}/assets/{name}"}
def get_asset_path(deck_id: str, name: str) -> Path | None:
safe = _safe_name(name)
base = LIVE_OVERRIDE_DIR if deck_id == "live" else PRESENTATIONS_DIR / deck_id
path = base / "assets" / safe
if not path.exists() or not path.is_file():
return None
return path