717 lines
25 KiB
Python
717 lines
25 KiB
Python
"""
|
|
rDesign — Document design & PDF generation service.
|
|
Headless Scribus automation API, following the blender-automation pattern.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import uuid
|
|
import glob as globmod
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import yaml
|
|
import aiofiles
|
|
from fastapi import FastAPI, BackgroundTasks, HTTPException, UploadFile, File, Query
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, JSONResponse
|
|
from pydantic import BaseModel, Field
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
BASE_URL = os.getenv("BASE_URL", "https://scribus.rspace.online")
|
|
SCRIBUS_PATH = os.getenv("SCRIBUS_PATH", "/usr/bin/scribus")
|
|
TEMPLATES_DIR = Path(os.getenv("TEMPLATES_DIR", "/app/templates"))
|
|
OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/app/output"))
|
|
JOBS_DIR = Path(os.getenv("JOBS_DIR", "/app/jobs"))
|
|
RSWAG_DESIGNS_PATH = Path(os.getenv("RSWAG_DESIGNS_PATH", "/app/rswag-designs"))
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# App
|
|
# ---------------------------------------------------------------------------
|
|
|
|
app = FastAPI(
|
|
title="rDesign",
|
|
description="Document design & PDF generation — headless Scribus automation",
|
|
version="0.1.0",
|
|
)
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=[
|
|
"https://scribus.rspace.online",
|
|
"https://rswag.online",
|
|
],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# In-memory job store (same pattern as blender-automation)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
jobs: dict[str, dict] = {}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class ExportRequest(BaseModel):
|
|
"""Generate a PDF from a Scribus template."""
|
|
template: str = Field(..., description="Template slug (directory name under /templates)")
|
|
output_format: str = Field("pdf", description="Output format: pdf, png, svg")
|
|
variables: dict[str, str] = Field(default_factory=dict, description="Template variables to substitute")
|
|
dpi: int = Field(300, ge=72, le=600)
|
|
|
|
|
|
class BatchExportRequest(BaseModel):
|
|
"""Generate multiple PDFs from a template + data rows (ScribusGenerator pattern)."""
|
|
template: str
|
|
output_format: str = Field("pdf", description="pdf or png")
|
|
rows: list[dict[str, str]] = Field(..., description="List of variable dicts, one per output document")
|
|
dpi: int = Field(300, ge=72, le=600)
|
|
|
|
|
|
class RswagExportRequest(BaseModel):
|
|
"""Export an rSwag design to print-ready PDF with bleed/crop marks."""
|
|
design_slug: str
|
|
category: str = Field("stickers", description="rSwag category: stickers, shirts, prints")
|
|
paper_size: str = Field("A4", description="Paper size: A4, A3, Letter, custom")
|
|
add_bleed: bool = Field(True, description="Add 3mm bleed")
|
|
add_crop_marks: bool = Field(True, description="Add crop/trim marks")
|
|
|
|
|
|
class ConvertIdmlRequest(BaseModel):
|
|
"""Convert an IDML file (InDesign interchange) to SLA and/or PDF."""
|
|
output_sla: bool = Field(True, description="Save as Scribus .sla file")
|
|
output_pdf: bool = Field(True, description="Export to PDF")
|
|
dpi: int = Field(300, ge=72, le=600)
|
|
|
|
|
|
class TemplateInfo(BaseModel):
|
|
slug: str
|
|
name: str
|
|
description: str
|
|
category: str
|
|
variables: list[str]
|
|
preview_url: Optional[str] = None
|
|
created: Optional[str] = None
|
|
|
|
|
|
class JobStatus(BaseModel):
|
|
job_id: str
|
|
status: str # queued, processing, completed, failed
|
|
progress: int = 0
|
|
result_url: Optional[str] = None
|
|
error: Optional[str] = None
|
|
created_at: str
|
|
completed_at: Optional[str] = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Template management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def scan_templates() -> list[TemplateInfo]:
|
|
"""Scan templates directory for available Scribus templates."""
|
|
templates = []
|
|
for meta_path in sorted(TEMPLATES_DIR.glob("*/metadata.yaml")):
|
|
try:
|
|
with open(meta_path) as f:
|
|
meta = yaml.safe_load(f)
|
|
slug = meta_path.parent.name
|
|
preview = None
|
|
for ext in ("png", "jpg", "webp"):
|
|
pf = meta_path.parent / f"preview.{ext}"
|
|
if pf.exists():
|
|
preview = f"{BASE_URL}/templates/{slug}/preview"
|
|
break
|
|
templates.append(TemplateInfo(
|
|
slug=slug,
|
|
name=meta.get("name", slug),
|
|
description=meta.get("description", ""),
|
|
category=meta.get("category", "general"),
|
|
variables=meta.get("variables", []),
|
|
preview_url=preview,
|
|
created=meta.get("created"),
|
|
))
|
|
except Exception:
|
|
continue
|
|
return templates
|
|
|
|
|
|
def find_template_sla(slug: str) -> Path:
|
|
"""Find the .sla file for a template slug."""
|
|
tpl_dir = TEMPLATES_DIR / slug
|
|
if not tpl_dir.is_dir():
|
|
raise HTTPException(404, f"Template '{slug}' not found")
|
|
sla_files = list(tpl_dir.glob("*.sla"))
|
|
if not sla_files:
|
|
raise HTTPException(404, f"No .sla file found in template '{slug}'")
|
|
return sla_files[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Scribus headless execution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def run_scribus_script(script_path: str, args: list[str], timeout: int = 120) -> tuple[str, str, int]:
|
|
"""Run a Scribus Python script in headless mode via xvfb-run."""
|
|
cmd = [
|
|
"xvfb-run", "--auto-servernum", "--server-args=-screen 0 1920x1080x24",
|
|
SCRIBUS_PATH, "-g", "-ns", "-py", script_path, "--", *args,
|
|
]
|
|
proc = await asyncio.create_subprocess_exec(
|
|
*cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
proc.kill()
|
|
await proc.communicate()
|
|
return "", "Process timed out", -1
|
|
return stdout.decode(), stderr.decode(), proc.returncode
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Job processing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def process_export_job(job_id: str, req: ExportRequest):
|
|
"""Process a single template export job."""
|
|
jobs[job_id]["status"] = "processing"
|
|
jobs[job_id]["progress"] = 10
|
|
|
|
try:
|
|
sla_path = find_template_sla(req.template)
|
|
timestamp = int(time.time())
|
|
output_filename = f"{req.template}_{timestamp}.{req.output_format}"
|
|
output_path = OUTPUT_DIR / output_filename
|
|
|
|
# Build script args
|
|
script_args = [
|
|
"--input", str(sla_path),
|
|
"--output", str(output_path),
|
|
"--format", req.output_format,
|
|
"--dpi", str(req.dpi),
|
|
]
|
|
# Pass variables as --var key=value
|
|
for k, v in req.variables.items():
|
|
script_args.extend(["--var", f"{k}={v}"])
|
|
|
|
jobs[job_id]["progress"] = 30
|
|
|
|
stdout, stderr, returncode = await run_scribus_script(
|
|
"/app/scripts/export_document.py", script_args
|
|
)
|
|
|
|
if returncode != 0 or not output_path.exists():
|
|
jobs[job_id]["status"] = "failed"
|
|
jobs[job_id]["error"] = stderr[:500] if stderr else "Export failed — no output produced"
|
|
return
|
|
|
|
jobs[job_id]["progress"] = 100
|
|
jobs[job_id]["status"] = "completed"
|
|
jobs[job_id]["result_url"] = f"{BASE_URL}/output/{output_filename}"
|
|
jobs[job_id]["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
except HTTPException as e:
|
|
jobs[job_id]["status"] = "failed"
|
|
jobs[job_id]["error"] = e.detail
|
|
except Exception as e:
|
|
jobs[job_id]["status"] = "failed"
|
|
jobs[job_id]["error"] = str(e)[:500]
|
|
|
|
|
|
async def process_batch_job(job_id: str, req: BatchExportRequest):
|
|
"""Process a batch export job (ScribusGenerator pattern)."""
|
|
jobs[job_id]["status"] = "processing"
|
|
total = len(req.rows)
|
|
results = []
|
|
|
|
try:
|
|
sla_path = find_template_sla(req.template)
|
|
|
|
for i, row in enumerate(req.rows):
|
|
jobs[job_id]["progress"] = int((i / total) * 100)
|
|
timestamp = int(time.time())
|
|
row_id = row.get("id", str(i))
|
|
output_filename = f"{req.template}_{row_id}_{timestamp}.{req.output_format}"
|
|
output_path = OUTPUT_DIR / output_filename
|
|
|
|
script_args = [
|
|
"--input", str(sla_path),
|
|
"--output", str(output_path),
|
|
"--format", req.output_format,
|
|
"--dpi", str(req.dpi),
|
|
]
|
|
for k, v in row.items():
|
|
script_args.extend(["--var", f"{k}={v}"])
|
|
|
|
stdout, stderr, returncode = await run_scribus_script(
|
|
"/app/scripts/export_document.py", script_args
|
|
)
|
|
|
|
if returncode == 0 and output_path.exists():
|
|
results.append({
|
|
"row_id": row_id,
|
|
"url": f"{BASE_URL}/output/{output_filename}",
|
|
"status": "ok",
|
|
})
|
|
else:
|
|
results.append({
|
|
"row_id": row_id,
|
|
"status": "failed",
|
|
"error": (stderr or "unknown error")[:200],
|
|
})
|
|
|
|
jobs[job_id]["progress"] = 100
|
|
jobs[job_id]["status"] = "completed"
|
|
jobs[job_id]["results"] = results
|
|
jobs[job_id]["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
except Exception as e:
|
|
jobs[job_id]["status"] = "failed"
|
|
jobs[job_id]["error"] = str(e)[:500]
|
|
|
|
|
|
async def process_rswag_export(job_id: str, req: RswagExportRequest):
|
|
"""Export an rSwag design to print-ready PDF."""
|
|
jobs[job_id]["status"] = "processing"
|
|
jobs[job_id]["progress"] = 10
|
|
|
|
try:
|
|
# Find the rSwag design
|
|
design_dir = RSWAG_DESIGNS_PATH / req.category / req.design_slug
|
|
if not design_dir.is_dir():
|
|
raise FileNotFoundError(f"rSwag design not found: {req.category}/{req.design_slug}")
|
|
|
|
# Find the design image
|
|
design_image = None
|
|
for candidate in [
|
|
design_dir / "exports" / "300dpi" / f"{req.design_slug}.png",
|
|
design_dir / f"{req.design_slug}.png",
|
|
]:
|
|
if candidate.exists():
|
|
design_image = candidate
|
|
break
|
|
|
|
# Fallback: find any PNG
|
|
if not design_image:
|
|
pngs = list(design_dir.glob("*.png"))
|
|
if pngs:
|
|
design_image = pngs[0]
|
|
|
|
if not design_image:
|
|
raise FileNotFoundError(f"No image found for design: {req.design_slug}")
|
|
|
|
# Load design metadata if available
|
|
meta_path = design_dir / "metadata.yaml"
|
|
meta = {}
|
|
if meta_path.exists():
|
|
with open(meta_path) as f:
|
|
meta = yaml.safe_load(f) or {}
|
|
|
|
jobs[job_id]["progress"] = 30
|
|
|
|
timestamp = int(time.time())
|
|
output_filename = f"rswag_{req.design_slug}_{timestamp}.pdf"
|
|
output_path = OUTPUT_DIR / output_filename
|
|
|
|
script_args = [
|
|
"--image", str(design_image),
|
|
"--output", str(output_path),
|
|
"--paper", req.paper_size,
|
|
"--dpi", str(300),
|
|
"--title", meta.get("name", req.design_slug),
|
|
]
|
|
if req.add_bleed:
|
|
script_args.append("--bleed")
|
|
if req.add_crop_marks:
|
|
script_args.append("--crop-marks")
|
|
|
|
stdout, stderr, returncode = await run_scribus_script(
|
|
"/app/scripts/rswag_export.py", script_args, timeout=180
|
|
)
|
|
|
|
if returncode != 0 or not output_path.exists():
|
|
jobs[job_id]["status"] = "failed"
|
|
jobs[job_id]["error"] = stderr[:500] if stderr else "rSwag export failed"
|
|
return
|
|
|
|
jobs[job_id]["progress"] = 100
|
|
jobs[job_id]["status"] = "completed"
|
|
jobs[job_id]["result_url"] = f"{BASE_URL}/output/{output_filename}"
|
|
jobs[job_id]["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
except Exception as e:
|
|
jobs[job_id]["status"] = "failed"
|
|
jobs[job_id]["error"] = str(e)[:500]
|
|
|
|
|
|
async def process_idml_convert(job_id: str, idml_path: Path, req: ConvertIdmlRequest):
|
|
"""Convert an IDML file to SLA and/or PDF."""
|
|
jobs[job_id]["status"] = "processing"
|
|
jobs[job_id]["progress"] = 10
|
|
|
|
try:
|
|
timestamp = int(time.time())
|
|
stem = idml_path.stem
|
|
results = {}
|
|
|
|
script_args = ["--input", str(idml_path)]
|
|
|
|
if req.output_sla:
|
|
sla_filename = f"{stem}_{timestamp}.sla"
|
|
sla_path = OUTPUT_DIR / sla_filename
|
|
script_args.extend(["--output-sla", str(sla_path)])
|
|
|
|
if req.output_pdf:
|
|
pdf_filename = f"{stem}_{timestamp}.pdf"
|
|
pdf_path = OUTPUT_DIR / pdf_filename
|
|
script_args.extend(["--output-pdf", str(pdf_path)])
|
|
|
|
script_args.extend(["--dpi", str(req.dpi)])
|
|
|
|
jobs[job_id]["progress"] = 30
|
|
|
|
stdout, stderr, returncode = await run_scribus_script(
|
|
"/app/scripts/convert_idml.py", script_args, timeout=300
|
|
)
|
|
|
|
if returncode != 0:
|
|
jobs[job_id]["status"] = "failed"
|
|
jobs[job_id]["error"] = stderr[:500] if stderr else "IDML conversion failed"
|
|
return
|
|
|
|
if req.output_sla and sla_path.exists():
|
|
results["sla_url"] = f"{BASE_URL}/output/{sla_filename}"
|
|
if req.output_pdf and pdf_path.exists():
|
|
results["pdf_url"] = f"{BASE_URL}/output/{pdf_filename}"
|
|
|
|
# Save as template too (SLA goes into templates dir)
|
|
if req.output_sla and sla_path.exists():
|
|
tpl_dir = TEMPLATES_DIR / stem
|
|
tpl_dir.mkdir(parents=True, exist_ok=True)
|
|
import shutil
|
|
shutil.copy2(sla_path, tpl_dir / f"{stem}.sla")
|
|
meta = {
|
|
"name": stem.replace("-", " ").replace("_", " ").title(),
|
|
"description": f"Imported from IDML: {idml_path.name}",
|
|
"category": "imported",
|
|
"variables": [],
|
|
"created": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
|
"source": {"format": "idml", "original": idml_path.name},
|
|
}
|
|
with open(tpl_dir / "metadata.yaml", "w") as f:
|
|
yaml.dump(meta, f, default_flow_style=False)
|
|
results["template_slug"] = stem
|
|
|
|
jobs[job_id]["progress"] = 100
|
|
jobs[job_id]["status"] = "completed"
|
|
jobs[job_id]["results"] = results
|
|
jobs[job_id]["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
except Exception as e:
|
|
jobs[job_id]["status"] = "failed"
|
|
jobs[job_id]["error"] = str(e)[:500]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Routes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@app.get("/", tags=["health"])
|
|
async def root():
|
|
return {"service": "rDesign", "version": "0.1.0", "status": "ok"}
|
|
|
|
|
|
@app.get("/health", tags=["health"])
|
|
async def health():
|
|
scribus_exists = os.path.isfile(SCRIBUS_PATH)
|
|
return {
|
|
"status": "ok" if scribus_exists else "degraded",
|
|
"scribus": "available" if scribus_exists else "missing",
|
|
"templates": len(scan_templates()),
|
|
}
|
|
|
|
|
|
# --- Templates ---
|
|
|
|
@app.get("/templates", response_model=list[TemplateInfo], tags=["templates"])
|
|
async def list_templates(category: Optional[str] = None):
|
|
"""List available Scribus templates."""
|
|
templates = scan_templates()
|
|
if category:
|
|
templates = [t for t in templates if t.category == category]
|
|
return templates
|
|
|
|
|
|
@app.get("/templates/{slug}", response_model=TemplateInfo, tags=["templates"])
|
|
async def get_template(slug: str):
|
|
"""Get template metadata."""
|
|
for t in scan_templates():
|
|
if t.slug == slug:
|
|
return t
|
|
raise HTTPException(404, f"Template '{slug}' not found")
|
|
|
|
|
|
@app.get("/templates/{slug}/preview", tags=["templates"])
|
|
async def get_template_preview(slug: str):
|
|
"""Get template preview image."""
|
|
tpl_dir = TEMPLATES_DIR / slug
|
|
if not tpl_dir.is_dir():
|
|
raise HTTPException(404, "Template not found")
|
|
for ext in ("png", "jpg", "webp"):
|
|
preview = tpl_dir / f"preview.{ext}"
|
|
if preview.exists():
|
|
return FileResponse(preview)
|
|
raise HTTPException(404, "No preview available")
|
|
|
|
|
|
@app.post("/templates/upload", tags=["templates"])
|
|
async def upload_template(
|
|
file: UploadFile = File(...),
|
|
name: str = Query(...),
|
|
description: str = Query(""),
|
|
category: str = Query("general"),
|
|
):
|
|
"""Upload a new Scribus template (.sla file)."""
|
|
if not file.filename or not file.filename.endswith(".sla"):
|
|
raise HTTPException(400, "File must be a .sla Scribus document")
|
|
|
|
slug = name.lower().replace(" ", "-").replace("_", "-")
|
|
tpl_dir = TEMPLATES_DIR / slug
|
|
tpl_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
sla_path = tpl_dir / file.filename
|
|
async with aiofiles.open(sla_path, "wb") as f:
|
|
content = await file.read()
|
|
await f.write(content)
|
|
|
|
# Create metadata
|
|
meta = {
|
|
"name": name,
|
|
"description": description,
|
|
"category": category,
|
|
"variables": [],
|
|
"created": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
|
}
|
|
meta_path = tpl_dir / "metadata.yaml"
|
|
with open(meta_path, "w") as f:
|
|
yaml.dump(meta, f, default_flow_style=False)
|
|
|
|
return {"slug": slug, "message": f"Template '{name}' uploaded", "path": str(sla_path)}
|
|
|
|
|
|
# --- IDML Import / Convert ---
|
|
|
|
@app.post("/convert/idml", tags=["convert"])
|
|
async def convert_idml(
|
|
file: UploadFile = File(...),
|
|
output_sla: bool = Query(True, description="Save as Scribus .sla"),
|
|
output_pdf: bool = Query(True, description="Export to PDF"),
|
|
dpi: int = Query(300, ge=72, le=600),
|
|
background_tasks: BackgroundTasks = None,
|
|
):
|
|
"""Upload an IDML file (InDesign interchange format) and convert to SLA/PDF.
|
|
|
|
IDML is Adobe's open interchange format — export from InDesign via
|
|
File → Save As → InDesign Markup (IDML). Scribus imports IDML natively.
|
|
|
|
Note: Native .indd files cannot be converted directly. Use IDML instead.
|
|
"""
|
|
if not file.filename or not file.filename.lower().endswith((".idml", ".idms")):
|
|
raise HTTPException(400, "File must be .idml or .idms (InDesign Markup). "
|
|
"Native .indd files are not supported — export as IDML from InDesign first.")
|
|
|
|
# Save uploaded file
|
|
upload_dir = JOBS_DIR / "uploads"
|
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
idml_path = upload_dir / f"{uuid.uuid4()}_{file.filename}"
|
|
async with aiofiles.open(idml_path, "wb") as f:
|
|
content = await file.read()
|
|
await f.write(content)
|
|
|
|
req = ConvertIdmlRequest(output_sla=output_sla, output_pdf=output_pdf, dpi=dpi)
|
|
|
|
job_id = str(uuid.uuid4())
|
|
jobs[job_id] = {
|
|
"job_id": job_id,
|
|
"status": "queued",
|
|
"progress": 0,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"source": file.filename,
|
|
"type": "idml_convert",
|
|
}
|
|
background_tasks.add_task(process_idml_convert, job_id, idml_path, req)
|
|
return {
|
|
"job_id": job_id,
|
|
"status": "queued",
|
|
"poll_url": f"{BASE_URL}/jobs/{job_id}",
|
|
"note": "IDML import may take a few minutes for complex documents.",
|
|
}
|
|
|
|
|
|
# --- Export (single document) ---
|
|
|
|
@app.post("/export", tags=["export"])
|
|
async def export_document(req: ExportRequest, background_tasks: BackgroundTasks):
|
|
"""Export a Scribus template to PDF/PNG (async job)."""
|
|
job_id = str(uuid.uuid4())
|
|
jobs[job_id] = {
|
|
"job_id": job_id,
|
|
"status": "queued",
|
|
"progress": 0,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"template": req.template,
|
|
}
|
|
background_tasks.add_task(process_export_job, job_id, req)
|
|
return {"job_id": job_id, "status": "queued", "poll_url": f"{BASE_URL}/jobs/{job_id}"}
|
|
|
|
|
|
@app.post("/export/sync", tags=["export"])
|
|
async def export_document_sync(req: ExportRequest):
|
|
"""Export a Scribus template synchronously (blocks until done)."""
|
|
job_id = str(uuid.uuid4())
|
|
jobs[job_id] = {
|
|
"job_id": job_id,
|
|
"status": "queued",
|
|
"progress": 0,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"template": req.template,
|
|
}
|
|
await process_export_job(job_id, req)
|
|
return jobs[job_id]
|
|
|
|
|
|
# --- Batch export ---
|
|
|
|
@app.post("/export/batch", tags=["export"])
|
|
async def export_batch(req: BatchExportRequest, background_tasks: BackgroundTasks):
|
|
"""Batch export: one template + multiple data rows = multiple PDFs."""
|
|
job_id = str(uuid.uuid4())
|
|
jobs[job_id] = {
|
|
"job_id": job_id,
|
|
"status": "queued",
|
|
"progress": 0,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"template": req.template,
|
|
"total_rows": len(req.rows),
|
|
}
|
|
background_tasks.add_task(process_batch_job, job_id, req)
|
|
return {"job_id": job_id, "status": "queued", "total_rows": len(req.rows), "poll_url": f"{BASE_URL}/jobs/{job_id}"}
|
|
|
|
|
|
# --- rSwag integration ---
|
|
|
|
@app.get("/rswag/designs", tags=["rswag"])
|
|
async def list_rswag_designs(category: Optional[str] = None):
|
|
"""List available rSwag designs that can be exported to print-ready PDFs."""
|
|
designs = []
|
|
search_dirs = [RSWAG_DESIGNS_PATH]
|
|
if category:
|
|
search_dirs = [RSWAG_DESIGNS_PATH / category]
|
|
|
|
for search_dir in search_dirs:
|
|
if not search_dir.is_dir():
|
|
continue
|
|
for cat_dir in sorted(search_dir.iterdir()):
|
|
if not cat_dir.is_dir():
|
|
continue
|
|
# If filtering by category, cat_dir IS the design dir
|
|
design_dirs = [cat_dir] if category else sorted(cat_dir.iterdir())
|
|
for ddir in design_dirs:
|
|
if not ddir.is_dir():
|
|
continue
|
|
meta_path = ddir / "metadata.yaml"
|
|
if not meta_path.exists():
|
|
continue
|
|
try:
|
|
with open(meta_path) as f:
|
|
meta = yaml.safe_load(f)
|
|
designs.append({
|
|
"slug": ddir.name,
|
|
"name": meta.get("name", ddir.name),
|
|
"category": cat_dir.name if not category else category,
|
|
"description": meta.get("description", ""),
|
|
"status": meta.get("status", "unknown"),
|
|
})
|
|
except Exception:
|
|
continue
|
|
return designs
|
|
|
|
|
|
@app.post("/rswag/export", tags=["rswag"])
|
|
async def export_rswag_design(req: RswagExportRequest, background_tasks: BackgroundTasks):
|
|
"""Export an rSwag design to a print-ready PDF with bleed and crop marks."""
|
|
job_id = str(uuid.uuid4())
|
|
jobs[job_id] = {
|
|
"job_id": job_id,
|
|
"status": "queued",
|
|
"progress": 0,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
"design": req.design_slug,
|
|
}
|
|
background_tasks.add_task(process_rswag_export, job_id, req)
|
|
return {"job_id": job_id, "status": "queued", "poll_url": f"{BASE_URL}/jobs/{job_id}"}
|
|
|
|
|
|
# --- Jobs ---
|
|
|
|
@app.get("/jobs/{job_id}", tags=["jobs"])
|
|
async def get_job(job_id: str):
|
|
"""Get job status."""
|
|
if job_id not in jobs:
|
|
raise HTTPException(404, "Job not found")
|
|
return jobs[job_id]
|
|
|
|
|
|
@app.get("/jobs", tags=["jobs"])
|
|
async def list_jobs(limit: int = Query(20, le=100)):
|
|
"""List recent jobs."""
|
|
sorted_jobs = sorted(jobs.values(), key=lambda j: j["created_at"], reverse=True)
|
|
return sorted_jobs[:limit]
|
|
|
|
|
|
@app.delete("/jobs/{job_id}", tags=["jobs"])
|
|
async def delete_job(job_id: str):
|
|
"""Delete a job and its output."""
|
|
if job_id not in jobs:
|
|
raise HTTPException(404, "Job not found")
|
|
job = jobs.pop(job_id)
|
|
if "result_url" in job:
|
|
filename = job["result_url"].split("/")[-1]
|
|
output_file = OUTPUT_DIR / filename
|
|
if output_file.exists():
|
|
output_file.unlink()
|
|
return {"message": "Job deleted"}
|
|
|
|
|
|
# --- Output files ---
|
|
|
|
@app.get("/output/{filename}", tags=["output"])
|
|
async def get_output(filename: str):
|
|
"""Serve an output file."""
|
|
file_path = OUTPUT_DIR / filename
|
|
# Path traversal protection
|
|
if not file_path.resolve().is_relative_to(OUTPUT_DIR.resolve()):
|
|
raise HTTPException(403, "Access denied")
|
|
if not file_path.exists():
|
|
raise HTTPException(404, "File not found")
|
|
return FileResponse(
|
|
file_path,
|
|
headers={"Cache-Control": "public, max-age=86400"},
|
|
)
|