Add output generation, cover image management, and back-translation tool
- Add cover image CRUD endpoints: GET/POST/DELETE /api/projects/{id}/cover
- Add output generation endpoints: POST /api/projects/{id}/output/{format}
- Add cover image and output queue service methods
- Add CoverImageResponse and CoverImageUploadResponse schemas
- Add umbra-api.js request handling for FormData (cover uploads)
- Add output generation buttons (EPUB/PDF/Audio) to Format UI in umbra-app.js
- Add backtranslate.py tool for verifying translation quality via back-translation
- Add cover image support to render.py EPUB generation
- Copy cover image to deal-hunter-zh source directory
- Spanish translation pipeline started (running in background)
This commit is contained in:
193
tools/translation/backtranslate.py
Normal file
193
tools/translation/backtranslate.py
Normal file
@@ -0,0 +1,193 @@
|
||||
# Copyright 2026 Umbra Tools (umbratools.com)
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Back-translation checker for verifying Chinese translation intent.
|
||||
|
||||
Uses a *different* LLM model than the drafter to translate Chinese back to
|
||||
English, then compares against the original English to verify that meaning,
|
||||
tone, and intent are preserved. This is an independent quality gate -- if the
|
||||
back-translation captures the same story beats, the Chinese is faithful.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
from llm import DEFAULT_MODEL, LLM, LLMError, Usage # noqa: E402
|
||||
|
||||
# A model *different* from the drafter (qwen/qwen3.5-9b) for independent
|
||||
# verification. gemma-4-12b-qat is a good choice: capable but architecturally
|
||||
# distinct from Qwen.
|
||||
BACKTRANSLATE_MODEL = "google/gemma-4-12b-qat"
|
||||
|
||||
SYSTEM_BACKTRANSLATE = """你是一位严格的双语审校。你的任务是把中文译文译回英文,然后与英文原文对比,判断原意是否被保留。
|
||||
|
||||
只输出 JSON,不要解释,不要 Markdown 代码围栏。输出结构:
|
||||
{
|
||||
"back_translation": "完整的英文回译",
|
||||
"intent_preserved": true/false,
|
||||
"confidence": 0.0-1.0,
|
||||
"issues": [
|
||||
{"id": "段落id", "problem": "具体问题描述", "severity": "block|major|minor"}
|
||||
],
|
||||
"notes": "整体评估(可选)
|
||||
}
|
||||
|
||||
只有当读者会因此理解错、人物声音被抹平、或事实被改变时才报告问题。不要因为“措辞不同”而报错。
|
||||
"""
|
||||
|
||||
|
||||
def backtrans_prompt(
|
||||
*,
|
||||
chapter_title: str,
|
||||
paragraphs: list[dict],
|
||||
drafts: dict[str, str],
|
||||
) -> str:
|
||||
pairs = []
|
||||
for p in paragraphs:
|
||||
if p.get("type") == "scene_break":
|
||||
continue
|
||||
pairs.append(
|
||||
{
|
||||
"id": p["id"],
|
||||
"speaker": p.get("speaker"),
|
||||
"type": p.get("type"),
|
||||
"en": p["text"],
|
||||
"zh": drafts.get(p["id"], ""),
|
||||
}
|
||||
)
|
||||
pairs_json = json.dumps(pairs, ensure_ascii=False, indent=2)
|
||||
return f"""书名:Deal Hunter
|
||||
本章:{chapter_title}
|
||||
|
||||
请把下面每段中文译文译回英文,并与英文原文对比,判断原意是否保留。
|
||||
输出 JSON:{{"back_translation": "...", "intent_preserved": true/false, "confidence": 0.0-1.0, "issues": [...], "notes": "..."}}
|
||||
|
||||
英文原文与中文译文如下:
|
||||
{pairs_json}"""
|
||||
|
||||
|
||||
def check_chapter(
|
||||
llm: LLM,
|
||||
chapter: dict,
|
||||
drafts: dict[str, str],
|
||||
sample_fraction: float = 0.3,
|
||||
) -> dict:
|
||||
"""Back-translate a sample of paragraphs from one chapter."""
|
||||
paras = [p for p in chapter["paragraphs"] if p.get("type") != "scene_break"]
|
||||
if not paras:
|
||||
return {"chapter_id": chapter["id"], "error": "no paragraphs"}
|
||||
|
||||
# Sample a subset for back-translation (full book back-translation is expensive)
|
||||
import random
|
||||
|
||||
random.seed(42) # deterministic sampling
|
||||
n_sample = max(1, int(len(paras) * sample_fraction))
|
||||
sample = random.sample(paras, min(n_sample, len(paras)))
|
||||
|
||||
prompt = backtrans_prompt(
|
||||
chapter_title=f"{chapter['title']} / {chapter['title_zh']}",
|
||||
paragraphs=sample,
|
||||
drafts=drafts,
|
||||
)
|
||||
|
||||
try:
|
||||
value, usage = llm.chat(
|
||||
SYSTEM_BACKTRANSLATE,
|
||||
prompt,
|
||||
model=BACKTRANSLATE_MODEL,
|
||||
temperature=0.1,
|
||||
max_tokens=8000,
|
||||
cache_key=f"{chapter['id']}.backtranslate.{BACKTRANSLATE_MODEL}",
|
||||
)
|
||||
except LLMError as exc:
|
||||
return {
|
||||
"chapter_id": chapter["id"],
|
||||
"chapter_title": chapter["title"],
|
||||
"error": str(exc),
|
||||
"sampled": len(sample),
|
||||
"total": len(paras),
|
||||
}
|
||||
|
||||
try:
|
||||
result = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
# Try to extract JSON from markdown fence or surrounding prose
|
||||
text = value.strip()
|
||||
if text.startswith("```"):
|
||||
text = re.sub(r"^```[a-zA-Z0-9_-]*\s*", "", text)
|
||||
text = re.sub(r"\s*```$", "", text).strip()
|
||||
try:
|
||||
result = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return {
|
||||
"chapter_id": chapter["id"],
|
||||
"chapter_title": chapter["title"],
|
||||
"error": f"could not parse back-translation response: {value[:200]}",
|
||||
"sampled": len(sample),
|
||||
"total": len(paras),
|
||||
"raw": value,
|
||||
}
|
||||
|
||||
result["chapter_id"] = chapter["id"]
|
||||
result["chapter_title"] = chapter["title"]
|
||||
result["sampled"] = len(sample)
|
||||
result["total"] = len(paras)
|
||||
result["model"] = BACKTRANSLATE_MODEL
|
||||
return result
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Back-translate Chinese to English for intent verification")
|
||||
ap.add_argument("book", help="Path to book.json")
|
||||
ap.add_argument("run_dir", help="Path to runs directory containing translation.json per chapter")
|
||||
ap.add_argument("chapters", nargs="*", help="Chapter IDs to check (default: all translated)")
|
||||
ap.add_argument("--sample", type=float, default=0.3, help="Fraction of paragraphs to back-translate (default 0.3)")
|
||||
ap.add_argument("--model", default=BACKTRANSLATE_MODEL, help="Back-translation model")
|
||||
args = ap.parse_args()
|
||||
|
||||
from llm import DEFAULT_MODEL, LLM, LLMError, Usage # noqa: E402
|
||||
|
||||
book = json.loads(Path(args.book).read_text(encoding="utf-8"))
|
||||
run_dir = Path(args.run_dir)
|
||||
llm = LLM(cache_dir=run_dir / "cache", usage_log=run_dir / "usage.jsonl")
|
||||
|
||||
wanted = set(args.chapters) if args.chapters else None
|
||||
chapters = [c for c in book["chapters"] if not wanted or c["id"] in wanted or str(c["index"]) in wanted]
|
||||
|
||||
results = []
|
||||
for ch in chapters:
|
||||
draft_path = run_dir / ch["id"] / "translation.json"
|
||||
if not draft_path.exists():
|
||||
print(f"SKIP {ch['id']}: no translation.json", flush=True)
|
||||
continue
|
||||
draft_data = json.loads(draft_path.read_text(encoding="utf-8"))
|
||||
drafts = draft_data.get("paragraphs", {})
|
||||
print(f"Checking {ch['id']} ({ch['title']})...", flush=True)
|
||||
result = check_chapter(llm, ch, drafts, sample_fraction=args.sample)
|
||||
results.append(result)
|
||||
print(f" verdict: {result.get('intent_preserved')}, confidence: {result.get('confidence')}, issues: {len(result.get('issues', []))}", flush=True)
|
||||
|
||||
# Summary
|
||||
total_issues = sum(len(r.get("issues", [])) for r in results)
|
||||
blocked = [r for r in results if any(i.get("severity") == "block" for i in r.get("issues", []))]
|
||||
print(f"\n=== BACK-TRANSLATION SUMMARY ===")
|
||||
print(f"Chapters checked: {len(results)}")
|
||||
print(f"Total issues: {total_issues}")
|
||||
print(f"Chapters with block-level issues: {len(blocked)}")
|
||||
for r in blocked:
|
||||
print(f" BLOCK: {r['chapter_id']} - {r['chapter_title']}")
|
||||
for i in r.get("issues", []):
|
||||
if i.get("severity") == "block":
|
||||
print(f" {i['id']}: {i['problem']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -541,9 +541,157 @@ def render(
|
||||
out = {"asciidoc": asciidoc, "fodt": fodt, "odt": odt}
|
||||
if pdf_path:
|
||||
out["pdf"] = pdf_path
|
||||
|
||||
# Enhance EPUB with cover image and metadata
|
||||
epub_path = out_dir / f"{stem}.epub"
|
||||
cover_path = book_path.parent / "covers" / "cover.png"
|
||||
enhance_epub(epub_path, book, cover_path if cover_path.exists() else None)
|
||||
out["epub"] = epub_path
|
||||
return out
|
||||
|
||||
|
||||
def enhance_epub(epub_path: Path, book: dict, cover_path: Path | None = None) -> None:
|
||||
"""Add cover image, ISBN, publisher, and translator metadata to EPUB.
|
||||
|
||||
Pandoc's ODT->EPUB conversion loses some metadata. This function post-processes
|
||||
the EPUB to add cover image and complete metadata.
|
||||
"""
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
from lxml import etree
|
||||
|
||||
epub_path = epub_path.resolve()
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="epub-enhance-"))
|
||||
try:
|
||||
with zipfile.ZipFile(epub_path, "r") as zin:
|
||||
zin.extractall(tmpdir)
|
||||
|
||||
# Find content.opf
|
||||
opf_path = None
|
||||
for f in tmpdir.rglob("*.opf"):
|
||||
if f.name == "content.opf":
|
||||
opf_path = f
|
||||
break
|
||||
if opf_path is None:
|
||||
return
|
||||
|
||||
tree = etree.parse(str(opf_path))
|
||||
root = tree.getroot()
|
||||
ns = {
|
||||
"opf": "http://www.idpf.org/2007/opf",
|
||||
"dc": "http://purl.org/dc/elements/1.1/",
|
||||
}
|
||||
|
||||
# Add ISBN metadata if present in book
|
||||
# ISBN is stored in copyright paragraph text; extract it
|
||||
import re
|
||||
for ch in book.get("chapters", []):
|
||||
if ch.get("matter") == "front_copyright":
|
||||
for p in ch.get("paragraphs", []):
|
||||
text = p.get("text", "")
|
||||
isbn_match = re.search(r"ISBN:\s*([\d-]+)", text)
|
||||
if isbn_match:
|
||||
isbn = isbn_match.group(1)
|
||||
# Add ISBN identifier
|
||||
id_elem = etree.SubElement(root.find("opf:metadata", ns), "{http://purl.org/dc/elements/1.1/}identifier")
|
||||
id_elem.set("{http://www.idpf.org/2007/opf}scheme", "ISBN")
|
||||
id_elem.set("id", "epub-isbn")
|
||||
id_elem.text = isbn
|
||||
break
|
||||
|
||||
# Add cover image if provided
|
||||
if cover_path and cover_path.exists():
|
||||
# Add cover image to manifest
|
||||
cover_href = "images/cover.png"
|
||||
cover_id = "cover-image"
|
||||
|
||||
# Copy cover image to EPUB images dir
|
||||
images_dir = tmpdir / "EPUB" / "images"
|
||||
images_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(cover_path, images_dir / "cover.png")
|
||||
|
||||
# Add manifest item
|
||||
manifest = root.find("opf:manifest", ns)
|
||||
cover_item = etree.SubElement(manifest, "{http://www.idpf.org/2007/opf}item")
|
||||
cover_item.set("id", cover_id)
|
||||
cover_item.set("href", cover_href)
|
||||
cover_item.set("media-type", "image/png")
|
||||
cover_item.set("properties", "cover-image")
|
||||
|
||||
# Add cover meta tag
|
||||
metadata = root.find("opf:metadata", ns)
|
||||
cover_meta = etree.SubElement(metadata, "{http://www.idpf.org/2007/opf}meta")
|
||||
cover_meta.set("name", "cover")
|
||||
cover_meta.text = cover_id
|
||||
|
||||
# Add CJK-optimized CSS
|
||||
css_path = tmpdir / "EPUB" / "styles" / "stylesheet1.css"
|
||||
if css_path.exists():
|
||||
css_content = css_path.read_text(encoding="utf-8")
|
||||
cjk_css = '''
|
||||
|
||||
/* CJK typography for Chinese body text */
|
||||
html {
|
||||
font-family: "Noto Serif CJK SC", "Source Han Serif SC", "WenQuanYi Micro Hei", serif;
|
||||
}
|
||||
html[lang="zh-CN"], body[lang="zh-CN"] {
|
||||
font-family: "Noto Serif CJK SC", "Source Han Serif SC", "WenQuanYi Micro Hei", serif;
|
||||
line-break: strict;
|
||||
word-break: normal;
|
||||
}
|
||||
p[lang="zh-CN"] {
|
||||
font-family: "Noto Serif CJK SC", "Source Han Serif SC", "WenQuanYi Micro Hei", serif;
|
||||
line-break: strict;
|
||||
word-break: normal;
|
||||
}
|
||||
/* Dialogue styling */
|
||||
.utterance__text {
|
||||
font-size: 1.1em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
/* Chapter headings */
|
||||
h1, h2 {
|
||||
page-break-before: always;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
/* Speaker labels */
|
||||
.utterance__speaker {
|
||||
font-size: 0.85em;
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
}
|
||||
/* Variation dropdowns */
|
||||
.utterance__variation select {
|
||||
font-size: 0.8em;
|
||||
}
|
||||
'''
|
||||
css_path.write_text(css_content + chr(10) + cjk_css, encoding="utf-8")
|
||||
|
||||
# Add publisher metadata
|
||||
metadata = root.find("opf:metadata", ns)
|
||||
publisher = etree.SubElement(metadata, "{http://purl.org/dc/elements/1.1/}publisher")
|
||||
publisher.text = "Umbra Audio"
|
||||
|
||||
# Add translator contributor
|
||||
translator = etree.SubElement(metadata, "{http://purl.org/dc/elements/1.1/}contributor")
|
||||
translator.set("{http://www.idpf.org/2007/opf}role", "trl")
|
||||
translator.text = "Umbra Tools Translation Team"
|
||||
|
||||
tree.write(str(opf_path), xml_declaration=True, encoding="UTF-8", standalone=True)
|
||||
|
||||
# Repackage EPUB
|
||||
with zipfile.ZipFile(epub_path, "w", zipfile.ZIP_DEFLATED) as zout:
|
||||
# mimetype must be first and uncompressed
|
||||
zout.write(tmpdir / "mimetype", "mimetype", compress_type=zipfile.ZIP_STORED)
|
||||
for f in tmpdir.rglob("*"):
|
||||
if f.is_file() and f.name != "mimetype":
|
||||
arcname = f.relative_to(tmpdir)
|
||||
zout.write(f, arcname)
|
||||
finally:
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
|
||||
@@ -25,6 +25,7 @@ from fastapi import APIRouter, Depends, Request
|
||||
from .app import get_service, require_principal
|
||||
from .models import Principal
|
||||
from .schemas import (
|
||||
CoverImageResponse,
|
||||
OrganizationCreateResponse,
|
||||
OrganizationCreateRequest,
|
||||
ProgramCreateResponse,
|
||||
@@ -162,3 +163,97 @@ def get_utterance_translations(
|
||||
UUID(str(project_id)),
|
||||
language_code,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/projects/{project_id}/cover",
|
||||
response_model=CoverImageResponse,
|
||||
)
|
||||
def get_cover_image(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
language_tag: str | None = None,
|
||||
principal: Principal = Depends(require_principal),
|
||||
) -> CoverImageResponse:
|
||||
"""Get cover image info for a project."""
|
||||
|
||||
service: AccountService = get_service(request)
|
||||
from uuid import UUID
|
||||
|
||||
return service.get_cover_image(
|
||||
principal,
|
||||
UUID(str(project_id)),
|
||||
language_tag,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/cover",
|
||||
response_model=CoverImageResponse,
|
||||
)
|
||||
async def set_cover_image(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
language_tag: str | None = None,
|
||||
principal: Principal = Depends(require_principal),
|
||||
) -> CoverImageResponse:
|
||||
"""Save cover image for a project. Uploads image/png data in the request body."""
|
||||
|
||||
service: AccountService = get_service(request)
|
||||
from uuid import UUID
|
||||
|
||||
image_data = await request.body()
|
||||
return service.set_cover_image(
|
||||
principal,
|
||||
UUID(str(project_id)),
|
||||
language_tag,
|
||||
image_data,
|
||||
content_type=request.headers.get("content-type", "image/png"),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/projects/{project_id}/cover",
|
||||
response_model=CoverImageResponse,
|
||||
)
|
||||
def delete_cover_image(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
language_tag: str | None = None,
|
||||
principal: Principal = Depends(require_principal),
|
||||
) -> CoverImageResponse:
|
||||
"""Delete cover image for a project."""
|
||||
|
||||
service: AccountService = get_service(request)
|
||||
from uuid import UUID
|
||||
|
||||
return service.delete_cover_image(
|
||||
principal,
|
||||
UUID(str(project_id)),
|
||||
language_tag,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/output/{format}",
|
||||
response_model=dict,
|
||||
)
|
||||
async def queue_output_generation(
|
||||
project_id: str,
|
||||
format: str,
|
||||
request: Request,
|
||||
principal: Principal = Depends(require_principal),
|
||||
) -> dict:
|
||||
"""Queue output generation (EPUB, PDF, or audio) for a language project."""
|
||||
service: AccountService = get_service(request)
|
||||
from uuid import UUID
|
||||
|
||||
body = await request.json()
|
||||
language_tag = body.get("language_tag")
|
||||
|
||||
return service.queue_output(
|
||||
principal,
|
||||
UUID(str(project_id)),
|
||||
language_tag,
|
||||
format,
|
||||
)
|
||||
|
||||
@@ -641,5 +641,23 @@ class SpeakerSyncResponse(ResponseModel):
|
||||
aliases_added: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cover image (controller integration endpoint)
|
||||
|
||||
|
||||
class CoverImageResponse(ResponseModel):
|
||||
project_id: UUID
|
||||
language_tag: str | None = None
|
||||
has_cover: bool
|
||||
cover_path: str | None = None
|
||||
content_type: str | None = None
|
||||
size_bytes: int | None = None
|
||||
|
||||
|
||||
class CoverImageUploadResponse(ResponseModel):
|
||||
project_id: UUID
|
||||
language_tag: str | None = None
|
||||
saved: bool
|
||||
cover_path: str
|
||||
size_bytes: int
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ from .tenancy import TenantScope, resolve_target, scope_for
|
||||
from .project_status import ProjectStatusAdapter
|
||||
from .schemas import (
|
||||
AudioQueueResponse,
|
||||
CoverImageResponse,
|
||||
FrontMatterResponse,
|
||||
FormatMetadataResponse,
|
||||
OrganizationCreateResponse,
|
||||
@@ -2964,6 +2965,200 @@ class AccountService:
|
||||
assigned = [view for view in projects if actor.user_id in view.assigned_account_ids]
|
||||
return WorkspaceSnapshot(organization=organization, projects=projects, assigned_to_me=assigned, total=total)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Cover image (controller integration endpoint)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_cover_image(
|
||||
self,
|
||||
actor: Principal,
|
||||
project_id: UUID,
|
||||
language_tag: str | None = None,
|
||||
) -> CoverImageResponse:
|
||||
"""Get cover image info for a project (optionally per-language)."""
|
||||
|
||||
scope = scope_for(actor)
|
||||
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
|
||||
if project is None:
|
||||
if scope.unrestricted:
|
||||
raise NotFoundError("project_not_found")
|
||||
raise ForbiddenError("cross_organization_forbidden")
|
||||
|
||||
prod_dir = self._productions_dir_for(project)
|
||||
if prod_dir is None:
|
||||
return CoverImageResponse(
|
||||
project_id=project_id,
|
||||
language_tag=language_tag,
|
||||
has_cover=False,
|
||||
)
|
||||
|
||||
cover_dir = prod_dir / "source" / "covers"
|
||||
cover_path = cover_dir / "cover.png"
|
||||
if cover_path.is_file():
|
||||
return CoverImageResponse(
|
||||
project_id=project_id,
|
||||
language_tag=language_tag,
|
||||
has_cover=True,
|
||||
cover_path=str(cover_path),
|
||||
content_type="image/png",
|
||||
size_bytes=cover_path.stat().st_size,
|
||||
)
|
||||
return CoverImageResponse(
|
||||
project_id=project_id,
|
||||
language_tag=language_tag,
|
||||
has_cover=False,
|
||||
)
|
||||
|
||||
def set_cover_image(
|
||||
self,
|
||||
actor: Principal,
|
||||
project_id: UUID,
|
||||
language_tag: str | None,
|
||||
image_data: bytes,
|
||||
content_type: str = "image/png",
|
||||
) -> CoverImageResponse:
|
||||
"""Save cover image for a project (optionally per-language)."""
|
||||
|
||||
scope = scope_for(actor)
|
||||
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
|
||||
if project is None:
|
||||
if scope.unrestricted:
|
||||
raise NotFoundError("project_not_found")
|
||||
raise ForbiddenError("cross_organization_forbidden")
|
||||
|
||||
prod_dir = self._productions_dir_for(project)
|
||||
if prod_dir is None:
|
||||
raise NotFoundError("productions_not_configured")
|
||||
|
||||
cover_dir = prod_dir / "source" / "covers"
|
||||
cover_dir.mkdir(parents=True, exist_ok=True)
|
||||
cover_path = cover_dir / "cover.png"
|
||||
cover_path.write_bytes(image_data)
|
||||
|
||||
self._audit(
|
||||
action=ACTION_FORMAT_METADATA_SAVED,
|
||||
outcome="success",
|
||||
actor=actor,
|
||||
organization_id=project.organization_id,
|
||||
detail={
|
||||
"project_slug": project.slug,
|
||||
"language_tag": language_tag,
|
||||
"action": "cover_image_saved",
|
||||
},
|
||||
)
|
||||
return CoverImageResponse(
|
||||
project_id=project_id,
|
||||
language_tag=language_tag,
|
||||
saved=True,
|
||||
cover_path=str(cover_path),
|
||||
content_type=content_type,
|
||||
size_bytes=cover_path.stat().st_size,
|
||||
)
|
||||
|
||||
def delete_cover_image(
|
||||
self,
|
||||
actor: Principal,
|
||||
project_id: UUID,
|
||||
language_tag: str | None = None,
|
||||
) -> CoverImageResponse:
|
||||
"""Delete cover image for a project."""
|
||||
|
||||
scope = scope_for(actor)
|
||||
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
|
||||
if project is None:
|
||||
if scope.unrestricted:
|
||||
raise NotFoundError("project_not_found")
|
||||
raise ForbiddenError("cross_organization_forbidden")
|
||||
|
||||
prod_dir = self._productions_dir_for(project)
|
||||
if prod_dir is None:
|
||||
return CoverImageResponse(
|
||||
project_id=project_id,
|
||||
language_tag=language_tag,
|
||||
has_cover=False,
|
||||
)
|
||||
|
||||
cover_path = prod_dir / "source" / "covers" / "cover.png"
|
||||
if cover_path.is_file():
|
||||
cover_path.unlink()
|
||||
|
||||
self._audit(
|
||||
action=ACTION_FORMAT_METADATA_SAVED,
|
||||
outcome="success",
|
||||
actor=actor,
|
||||
organization_id=project.organization_id,
|
||||
detail={
|
||||
"project_slug": project.slug,
|
||||
"language_tag": language_tag,
|
||||
"action": "cover_image_deleted",
|
||||
},
|
||||
)
|
||||
return CoverImageResponse(
|
||||
project_id=project_id,
|
||||
language_tag=language_tag,
|
||||
has_cover=False,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Output generation (controller integration endpoint)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def queue_output(
|
||||
self,
|
||||
actor: Principal,
|
||||
project_id: UUID,
|
||||
language_tag: str | None,
|
||||
output_format: str,
|
||||
) -> dict:
|
||||
"""Queue output generation (EPUB, PDF, or audio) for a language project."""
|
||||
|
||||
scope = scope_for(actor)
|
||||
project = self._repository.get_project(project_id, organization_id=scope.organization_id)
|
||||
if project is None:
|
||||
if scope.unrestricted:
|
||||
raise NotFoundError("project_not_found")
|
||||
raise ForbiddenError("cross_organization_forbidden")
|
||||
|
||||
prod_dir = self._productions_dir_for(project)
|
||||
if prod_dir is None:
|
||||
raise NotFoundError("productions_not_configured")
|
||||
|
||||
# Queue the output generation job
|
||||
key = self._job_key(project_id, language_tag or "en", f"output_{output_format}")
|
||||
job_id = str(uuid4())
|
||||
_JOB_STORE[key] = {
|
||||
"job_id": job_id,
|
||||
"project_id": str(project_id),
|
||||
"language_tag": language_tag,
|
||||
"type": f"output_{output_format}",
|
||||
"state": "queued",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
self._audit(
|
||||
action=ACTION_FORMAT_METADATA_SAVED,
|
||||
outcome="success",
|
||||
actor=actor,
|
||||
organization_id=project.organization_id,
|
||||
detail={
|
||||
"project_slug": project.slug,
|
||||
"language_tag": language_tag,
|
||||
"output_format": output_format,
|
||||
"job_id": job_id,
|
||||
},
|
||||
)
|
||||
LOGGER.info(
|
||||
"output queued project_id=%s language_tag=%s format=%s job_id=%s",
|
||||
project_id, language_tag, output_format, job_id,
|
||||
)
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"project_id": str(project_id),
|
||||
"language_tag": language_tag,
|
||||
"format": output_format,
|
||||
"state": "queued",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Controller integration: organizations and programs
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -209,8 +209,13 @@
|
||||
}
|
||||
|
||||
if (opts.body !== undefined && opts.body !== null) {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(opts.body);
|
||||
if (opts.body instanceof FormData) {
|
||||
init.body = opts.body;
|
||||
// Let the browser set Content-Type with boundary for FormData
|
||||
} else {
|
||||
init.headers['Content-Type'] = 'application/json';
|
||||
init.body = JSON.stringify(opts.body);
|
||||
}
|
||||
}
|
||||
|
||||
return global.fetch(path + buildQuery(opts.params), init).then(
|
||||
|
||||
@@ -1110,6 +1110,7 @@
|
||||
|
||||
var program = state.programId ? (state.programById[String(state.programId)] || null) : null;
|
||||
var langLabel = state.languageCode ? languageTagLabel(state.languageCode) : '';
|
||||
var isSource = state.languageCode === 'en';
|
||||
|
||||
main.appendChild(el('div', { class: 'umbra-heading umbra-heading--row' }, [
|
||||
el('div', {}, [
|
||||
@@ -1125,8 +1126,25 @@
|
||||
|
||||
main.appendChild(el('div', { class: 'form-grid' }, [
|
||||
el('div', { class: 'form-grid__full' }, [
|
||||
el('label', { text: 'Cover' }),
|
||||
el('input', { class: 'input', type: 'text', placeholder: 'Cover image' })
|
||||
el('label', { text: 'Cover image' }),
|
||||
el('div', { class: 'cover-upload' }, [
|
||||
el('input', { class: 'input', type: 'file', accept: 'image/*', placeholder: 'Cover image' }),
|
||||
el('button', { class: 'btn btn--ghost', type: 'button', text: 'Upload', on: { click: function () {
|
||||
var fileInput = main.querySelector('input[type="file"]');
|
||||
if (fileInput && fileInput.files && fileInput.files[0]) {
|
||||
var file = fileInput.files[0];
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
api.request('/api/workspace/' + encodeURIComponent(state.programId) + '/cover', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(
|
||||
function () { api.showToast('Cover image uploaded', 'success'); },
|
||||
function (err) { api.showToast(api.describeError(err), 'error'); }
|
||||
);
|
||||
}
|
||||
} })
|
||||
])
|
||||
]),
|
||||
el('div', { class: 'form-grid__full' }, [
|
||||
el('label', { text: 'ISBN' }),
|
||||
@@ -1172,6 +1190,42 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Output generation section
|
||||
var outputPanel = el('div', { class: 'output-panel' });
|
||||
outputPanel.appendChild(el('h3', { text: 'Generate outputs' }));
|
||||
outputPanel.appendChild(el('div', { class: 'output-buttons' }, [
|
||||
el('button', { class: 'btn', type: 'button', text: 'Generate EPUB', 'data-format': 'epub', on: { click: function () {
|
||||
api.request('/api/projects/' + encodeURIComponent(state.programId) + '/output/epub', {
|
||||
method: 'POST',
|
||||
body: { language_tag: state.languageCode }
|
||||
}).then(
|
||||
function () { api.showToast('EPUB generation queued', 'success'); },
|
||||
function (err) { api.showToast(api.describeError(err), 'error'); }
|
||||
);
|
||||
} } }),
|
||||
el('button', { class: 'btn', type: 'button', text: 'Generate PDF', 'data-format': 'pdf', on: { click: function () {
|
||||
api.request('/api/projects/' + encodeURIComponent(state.programId) + '/output/pdf', {
|
||||
method: 'POST',
|
||||
body: { language_tag: state.languageCode }
|
||||
}).then(
|
||||
function () { api.showToast('PDF generation queued', 'success'); },
|
||||
function (err) { api.showToast(api.describeError(err), 'error'); }
|
||||
);
|
||||
} } }),
|
||||
el('button', { class: 'btn', type: 'button', text: 'Generate Audio', 'data-format': 'audio', on: { click: function () {
|
||||
api.request('/api/projects/' + encodeURIComponent(state.programId) + '/output/audio', {
|
||||
method: 'POST',
|
||||
body: { language_tag: state.languageCode }
|
||||
}).then(
|
||||
function () { api.showToast('Audio generation queued', 'success'); },
|
||||
function (err) { api.showToast(api.describeError(err), 'error'); }
|
||||
);
|
||||
} } })
|
||||
]));
|
||||
outputPanel.appendChild(el('div', { class: 'output-downloads', style: 'margin-top: 1rem;' }));
|
||||
|
||||
main.appendChild(outputPanel);
|
||||
|
||||
main.appendChild(el('div', { class: 'umbra-inline-actions' }, [
|
||||
el('button', { class: 'btn', type: 'button', text: 'Save', on: { click: function () {
|
||||
var cover = main.querySelector('input[placeholder="Cover image"]').value;
|
||||
|
||||
Reference in New Issue
Block a user