diff --git a/alembic/versions/0083_post_translation.py b/alembic/versions/0083_post_translation.py
new file mode 100644
index 0000000..d491d03
--- /dev/null
+++ b/alembic/versions/0083_post_translation.py
@@ -0,0 +1,73 @@
+"""post-text translation via Interpreter (milestone 143) — Post columns + settings
+
+Post gains the translated title/description + the detected source language,
+Interpreter engine_version (cache key), and translated_at — filled by the
+translate sweep. ImportSettings gains translation_enabled (OFF by default),
+interpreter_base_url (EMPTY — the operator sets their own, behind a reverse
+proxy), and translation_target_lang (en).
+
+Revision ID: 0083
+Revises: 0082
+Create Date: 2026-07-07
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0083"
+down_revision: Union[str, None] = "0082"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "post", sa.Column("post_title_translated", sa.Text(), nullable=True)
+ )
+ op.add_column(
+ "post", sa.Column("description_translated", sa.Text(), nullable=True)
+ )
+ op.add_column(
+ "post",
+ sa.Column("translated_source_lang", sa.String(8), nullable=True),
+ )
+ op.add_column(
+ "post",
+ sa.Column("translation_engine_version", sa.String(128), nullable=True),
+ )
+ op.add_column(
+ "post",
+ sa.Column("translated_at", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.add_column(
+ "import_settings",
+ sa.Column(
+ "translation_enabled", sa.Boolean(), nullable=False,
+ server_default=sa.text("false"),
+ ),
+ )
+ op.add_column(
+ "import_settings",
+ sa.Column(
+ "interpreter_base_url", sa.Text(), nullable=False, server_default="",
+ ),
+ )
+ op.add_column(
+ "import_settings",
+ sa.Column(
+ "translation_target_lang", sa.Text(), nullable=False,
+ server_default="en",
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("import_settings", "translation_target_lang")
+ op.drop_column("import_settings", "interpreter_base_url")
+ op.drop_column("import_settings", "translation_enabled")
+ op.drop_column("post", "translated_at")
+ op.drop_column("post", "translation_engine_version")
+ op.drop_column("post", "translated_source_lang")
+ op.drop_column("post", "description_translated")
+ op.drop_column("post", "post_title_translated")
diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py
index a8af431..12e850e 100644
--- a/backend/app/api/settings.py
+++ b/backend/app/api/settings.py
@@ -1,12 +1,23 @@
"""Settings API: import filters, system stats."""
+import asyncio
import secrets
from quart import Blueprint, jsonify, request
-from sqlalchemy import func, select
+from sqlalchemy import func, or_, select
from ..extensions import get_session
-from ..models import AppSetting, Artist, ImageRecord, ImportBatch, ImportSettings, ImportTask, Tag
+from ..models import (
+ AppSetting,
+ Artist,
+ ImageRecord,
+ ImportBatch,
+ ImportSettings,
+ ImportTask,
+ Post,
+ Tag,
+)
+from ..services import interpreter_client as ic
settings_bp = Blueprint("settings", __name__, url_prefix="/api")
@@ -32,6 +43,9 @@ _EDITABLE_FIELDS = (
"extdl_mediafire_enabled",
"extdl_dropbox_enabled",
"extdl_pixeldrain_enabled",
+ "translation_enabled",
+ "interpreter_base_url",
+ "translation_target_lang",
)
# Per-host external-download toggles — all plain booleans, validated uniformly.
@@ -69,6 +83,9 @@ async def get_import_settings():
"extdl_mediafire_enabled": row.extdl_mediafire_enabled,
"extdl_dropbox_enabled": row.extdl_dropbox_enabled,
"extdl_pixeldrain_enabled": row.extdl_pixeldrain_enabled,
+ "translation_enabled": row.translation_enabled,
+ "interpreter_base_url": row.interpreter_base_url,
+ "translation_target_lang": row.translation_target_lang,
})
@@ -128,6 +145,15 @@ async def update_import_settings():
for tog in _EXTDL_TOGGLE_FIELDS:
if tog in body and not isinstance(body[tog], bool):
return jsonify({"error": f"{tog} must be a boolean"}), 400
+ # Translation (#143): base URL may be empty (feature off until set — no
+ # default host; the operator points it at their own Interpreter proxy).
+ if "translation_enabled" in body and not isinstance(
+ body["translation_enabled"], bool
+ ):
+ return jsonify({"error": "translation_enabled must be a boolean"}), 400
+ for key in ("interpreter_base_url", "translation_target_lang"):
+ if key in body and not isinstance(body[key], str):
+ return jsonify({"error": f"{key} must be a string"}), 400
if "series_suggest_threshold" in body:
v = body["series_suggest_threshold"]
if not isinstance(v, (int, float)) or isinstance(v, bool) or v < 0 or v > 1:
@@ -270,3 +296,58 @@ async def rotate_extension_api_key():
row.value = new_value
await session.commit()
return jsonify({"key": new_value})
+
+
+# --- Translation (#143): live status + manual "Translate now" --------------
+
+
+@settings_bp.route("/settings/translation/status", methods=["GET"])
+async def translation_status():
+ """For the Settings card: is it on, is a URL set, is the service reachable,
+ and how many posts still await translation. Health runs the sync client in a
+ thread so the event loop isn't blocked."""
+ async with get_session() as session:
+ cfg = await ImportSettings.load(session)
+ untranslated = (await session.execute(
+ select(func.count(Post.id))
+ .where(Post.translated_source_lang.is_(None))
+ .where(or_(
+ Post.post_title.is_not(None), Post.description.is_not(None),
+ ))
+ )).scalar_one()
+ base_url = (cfg.interpreter_base_url or "").strip()
+ healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False
+ return jsonify({
+ "enabled": cfg.translation_enabled,
+ "base_url_set": bool(base_url),
+ "healthy": healthy,
+ "untranslated_count": int(untranslated),
+ })
+
+
+@settings_bp.route("/settings/translation/test", methods=["POST"])
+async def translation_test():
+ """On-demand reachability check for a GIVEN Interpreter base URL (the Settings
+ 'Test connection' button) — pings /v1/health without saving, so the operator
+ can verify a URL before enabling. Health runs in a thread (sync client)."""
+ body = await request.get_json()
+ base_url = ""
+ if isinstance(body, dict):
+ base_url = (body.get("base_url") or "").strip()
+ healthy = await asyncio.to_thread(ic.health, base_url) if base_url else False
+ return jsonify({"healthy": healthy})
+
+
+@settings_bp.route("/settings/translation/run", methods=["POST"])
+async def translation_run():
+ """Enqueue the translate sweep now (the Settings 'Translate now' button)."""
+ async with get_session() as session:
+ cfg = await ImportSettings.load(session)
+ if not cfg.translation_enabled or not (cfg.interpreter_base_url or "").strip():
+ return jsonify(
+ {"error": "translation is disabled or no base URL is set"}
+ ), 400
+ from ..tasks.translation import translate_posts
+
+ r = translate_posts.delay()
+ return jsonify({"celery_task_id": r.id}), 202
diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py
index c8cd723..0e47473 100644
--- a/backend/app/celery_app.py
+++ b/backend/app/celery_app.py
@@ -35,6 +35,7 @@ def make_celery() -> Celery:
"backend.app.tasks.backup",
"backend.app.tasks.admin",
"backend.app.tasks.library_audit",
+ "backend.app.tasks.translation",
],
)
app.conf.update(
@@ -63,6 +64,9 @@ def make_celery() -> Celery:
"backend.app.tasks.backup.*": {"queue": "maintenance_long"},
"backend.app.tasks.admin.*": {"queue": "maintenance_long"},
"backend.app.tasks.library_audit.*": {"queue": "maintenance_long"},
+ # Translation backfill hits the LLM (~1–6s/item) → the long lane so it
+ # never starves the quick self-healing sweeps (#143).
+ "backend.app.tasks.translation.*": {"queue": "maintenance_long"},
},
# Heavy ML tasks need fair dispatch — see ImageRepo's precedent.
task_acks_late=True,
@@ -161,6 +165,10 @@ def make_celery() -> Celery:
"task": "backend.app.tasks.ml.prune_presentation_reviews",
"schedule": 86400.0, # retention: drop resolved review flags >30d
},
+ "translate-posts-daily": {
+ "task": "backend.app.tasks.translation.translate_posts",
+ "schedule": 86400.0, # no-op unless translation configured + healthy
+ },
"snapshot-head-metrics-daily": {
"task": "backend.app.tasks.maintenance.snapshot_head_metrics",
"schedule": 86400.0,
diff --git a/backend/app/models/import_settings.py b/backend/app/models/import_settings.py
index 5b533fc..62c35ee 100644
--- a/backend/app/models/import_settings.py
+++ b/backend/app/models/import_settings.py
@@ -92,6 +92,21 @@ class ImportSettings(Base):
Boolean, nullable=False, default=True, server_default="true",
)
+ # -- Post-text translation via the Interpreter LAN service (milestone 143).
+ # Off by default with NO default host — it needs a reachable Interpreter
+ # service (the operator's, behind a reverse proxy), which not every install
+ # has; the operator sets the URL and flips it on. Empty base_url OR disabled
+ # → the translate sweep no-ops.
+ translation_enabled: Mapped[bool] = mapped_column(
+ Boolean, nullable=False, default=False, server_default="false",
+ )
+ interpreter_base_url: Mapped[str] = mapped_column(
+ Text, nullable=False, default="", server_default="",
+ )
+ translation_target_lang: Mapped[str] = mapped_column(
+ Text, nullable=False, default="en", server_default="en",
+ )
+
@classmethod
async def load(cls, session) -> ImportSettings:
"""The singleton settings row (id=1), via an async session."""
diff --git a/backend/app/models/post.py b/backend/app/models/post.py
index d9830a2..b3687eb 100644
--- a/backend/app/models/post.py
+++ b/backend/app/models/post.py
@@ -47,6 +47,24 @@ class Post(Base):
description: Mapped[str | None] = mapped_column(Text, nullable=True)
attachment_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ # -- Post-text translation (milestone 143). Filled by the translate_posts
+ # sweep via the Interpreter LAN service so viewing is instant.
+ # translated_source_lang is the DETECTED original language; "en" (or a
+ # passthrough) means nothing to translate and the *_translated columns stay
+ # NULL. engine_version keys the Interpreter cache — re-runs are ~1ms and a
+ # model upgrade re-translates instead of serving stale.
+ post_title_translated: Mapped[str | None] = mapped_column(Text, nullable=True)
+ description_translated: Mapped[str | None] = mapped_column(Text, nullable=True)
+ translated_source_lang: Mapped[str | None] = mapped_column(
+ String(8), nullable=True
+ )
+ translation_engine_version: Mapped[str | None] = mapped_column(
+ String(128), nullable=True
+ )
+ translated_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+
downloaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
diff --git a/backend/app/services/interpreter_client.py b/backend/app/services/interpreter_client.py
new file mode 100644
index 0000000..0e02029
--- /dev/null
+++ b/backend/app/services/interpreter_client.py
@@ -0,0 +1,88 @@
+"""Interpreter translation client (milestone 143) — a thin SYNC wrapper over the
+self-hosted Interpreter LAN service (LibreTranslate-compatible `/v1/translate`).
+
+Sync (requests) because the only caller is the sync celery translate sweep, and
+it mirrors FC's other platform clients. The service knows nothing about Curator —
+all Curator logic stays here. base_url is operator-configured (empty until set;
+behind a reverse proxy). Full API contract: Scribe note #1347.
+"""
+from __future__ import annotations
+
+import requests
+
+
+class InterpreterUnavailable(Exception):
+ """The translation engine is down / unreachable (HTTP 503 or a connection
+ error) — retry later, don't drop the item."""
+
+
+class InterpreterBadRequest(Exception):
+ """Bad request params (HTTP 400)."""
+
+
+def _url(base_url: str, path: str) -> str:
+ return f"{base_url.rstrip('/')}{path}"
+
+
+def health(base_url: str, *, timeout: float = 5.0) -> bool:
+ """True iff the Interpreter LLM engine is up. Any error (unset URL, network,
+ non-200, engine down) → False, so the sweep just no-ops rather than raising."""
+ if not base_url:
+ return False
+ try:
+ r = requests.get(_url(base_url, "/v1/health"), timeout=timeout)
+ except requests.RequestException:
+ return False
+ if r.status_code != 200:
+ return False
+ engines = (r.json() or {}).get("engines") or {}
+ return bool(engines.get("llm"))
+
+
+def translate(
+ texts: list[str], *, base_url: str, target: str = "en",
+ source: str = "auto", timeout: float = 120.0,
+) -> dict:
+ """Translate a batch. Returns::
+
+ {"translations": [str, ...], # SAME order & length as `texts`
+ "detected_lang": str | None, # aggregate: describes the FIRST item
+ "engine": str | None,
+ "engine_version": str | None}
+
+ Interpreter batch metadata is aggregate (first item only) — fine for one
+ post's ``[title, description]`` batch since they share a language. Passthrough
+ items (already target-language / emoji-only) come back UNCHANGED in their
+ slot. Raises InterpreterUnavailable on 503 / connection error (retry later),
+ InterpreterBadRequest on 400. (Scribe note #1347.)
+ """
+ if not texts:
+ return {"translations": [], "detected_lang": None,
+ "engine": None, "engine_version": None}
+ try:
+ r = requests.post(
+ _url(base_url, "/v1/translate"),
+ json={"q": list(texts), "source": source,
+ "target": target, "engine": "auto"},
+ timeout=timeout,
+ )
+ except requests.RequestException as e:
+ raise InterpreterUnavailable(str(e)) from e
+ if r.status_code == 503:
+ raise InterpreterUnavailable("interpreter engine unavailable")
+ if r.status_code == 400:
+ raise InterpreterBadRequest((r.json() or {}).get("error", "bad request"))
+ r.raise_for_status()
+ body = r.json() or {}
+ out = body.get("translatedText")
+ # q was an array → translatedText is an array (same order & length). Guard a
+ # scalar reply just in case (shouldn't happen for an array q).
+ if not isinstance(out, list):
+ out = [out]
+ interp = body.get("interpreter") or {}
+ return {
+ "translations": out,
+ "detected_lang": (body.get("detectedLanguage") or {}).get("language"),
+ "engine": interp.get("engine"),
+ "engine_version": interp.get("engine_version"),
+ }
diff --git a/backend/app/services/post_feed_service.py b/backend/app/services/post_feed_service.py
index d21f19d..0921ccd 100644
--- a/backend/app/services/post_feed_service.py
+++ b/backend/app/services/post_feed_service.py
@@ -200,6 +200,8 @@ class PostFeedService:
atts_map = await self._attachments_for([post.id])
item = self._to_dict(post, artist, source, thumbs_map, atts_map)
item["description_full"] = html_to_plain(post.description)
+ # Full (uncapped) translated description for the detail view (#143).
+ item["description_translated_full"] = post.description_translated
# Sanitized HTML body for faithful (semantic) rendering in the post view;
# detail-only (the feed list stays lightweight plain text). None when the
# post has no body. Inline `` sources are remapped to locally-served
@@ -371,6 +373,12 @@ class PostFeedService:
description_plain, truncated = None, False
else:
description_plain, truncated = truncate_at_word(plain_full, DESCRIPTION_LIMIT)
+ # Translation (#143): the stored translated description is already plain
+ # text; truncate it the same way for the card.
+ desc_trans = post.description_translated
+ desc_trans_short = (
+ truncate_at_word(desc_trans, DESCRIPTION_LIMIT)[0] if desc_trans else None
+ )
thumbs_entry = thumbs_map.get(post.id, {"thumbs": [], "more": 0})
# `source` is null for filesystem-imported posts with no live
# subscription (alembic 0030). Frontend renders that as a
@@ -384,6 +392,11 @@ class PostFeedService:
"downloaded_at": post.downloaded_at.isoformat(),
"description_plain": description_plain,
"description_truncated": truncated,
+ # Translation-forward fields (#143): shown by default when present;
+ # UI toggles back to the originals above. Source lang labels the toggle.
+ "post_title_translated": post.post_title_translated,
+ "description_translated": desc_trans_short,
+ "translated_source_lang": post.translated_source_lang,
"artist": {"id": artist.id, "name": artist.name, "slug": artist.slug},
"source": (
{"id": source.id, "platform": source.platform}
diff --git a/backend/app/services/provenance_service.py b/backend/app/services/provenance_service.py
index 4a82b43..f2c7236 100644
--- a/backend/app/services/provenance_service.py
+++ b/backend/app/services/provenance_service.py
@@ -29,6 +29,12 @@ def _post_dict(p: Post) -> dict:
"date": p.post_date.isoformat() if p.post_date else None,
"description_html": sanitize_post_html(p.description),
"attachment_count": p.attachment_count,
+ # Translation (#143): the English title/description shown by default when
+ # a translation exists; the UI toggles to the original. Source lang labels
+ # the original.
+ "title_translated": p.post_title_translated,
+ "description_translated": p.description_translated,
+ "translated_source_lang": p.translated_source_lang,
}
diff --git a/backend/app/tasks/translation.py b/backend/app/tasks/translation.py
new file mode 100644
index 0000000..89c7b77
--- /dev/null
+++ b/backend/app/tasks/translation.py
@@ -0,0 +1,101 @@
+"""Post-text translation Celery task (milestone 143).
+
+Backfills non-English post title/description to the target language via the
+self-hosted Interpreter LAN service, storing the result + engine_version so
+viewing is instant and re-runs are cache-fast. Sync (Celery workers are sync),
+same pattern as the other backfill sweeps. No-op unless translation is enabled,
+a base URL is set, and the service is healthy.
+"""
+import logging
+from datetime import UTC, datetime
+
+from celery.exceptions import SoftTimeLimitExceeded
+from sqlalchemy import or_, select
+
+from ..celery_app import celery
+from ..models import ImportSettings, Post
+from ..services import interpreter_client as ic
+from ..utils.text import html_to_plain
+from ._sync_engine import sync_session_factory as _sync_session_factory
+
+log = logging.getLogger(__name__)
+
+# Bound one run's work so it commits progress rather than dying at the time
+# limit; the rest resumes next cycle (idempotent — only untranslated posts are
+# picked, so an interrupted run just re-runs = rule-89 recovery).
+_MAX_POSTS_PER_RUN = 300
+
+
+@celery.task(
+ name="backend.app.tasks.translation.translate_posts",
+ soft_time_limit=1800, time_limit=2100,
+)
+def translate_posts() -> str:
+ """Translate untranslated non-English post title/description (default target
+ en) via Interpreter. Per-post [title, description] batch → per-post detected
+ language. Passthrough / already-target posts are marked handled (source ==
+ target) with no stored translation. 503 or a connection error interrupts the
+ run (retry next cycle); 400 stops it (fix config); posts done so far stay
+ committed. No-op unless configured + healthy. Returns a summary string."""
+ SessionLocal = _sync_session_factory()
+ with SessionLocal() as session:
+ cfg = ImportSettings.load_sync(session)
+ if not cfg.translation_enabled or not cfg.interpreter_base_url.strip():
+ return "disabled"
+ base_url = cfg.interpreter_base_url.strip()
+ target = (cfg.translation_target_lang or "en").strip() or "en"
+ if not ic.health(base_url):
+ return "interpreter unavailable"
+
+ posts = session.execute(
+ select(Post)
+ .where(Post.translated_source_lang.is_(None))
+ .where(or_(
+ Post.post_title.is_not(None), Post.description.is_not(None),
+ ))
+ .limit(_MAX_POSTS_PER_RUN)
+ ).scalars().all()
+
+ translated = 0
+ try:
+ for post in posts:
+ translated += _translate_one(session, post, base_url, target)
+ session.commit() # per-post → an interrupted run keeps progress
+ except ic.InterpreterUnavailable:
+ session.rollback()
+ return f"interrupted (service down) — translated={translated}"
+ except ic.InterpreterBadRequest as e:
+ session.rollback()
+ log.warning("translate_posts bad request: %s", e)
+ return f"stopped (bad request) — translated={translated}"
+ except SoftTimeLimitExceeded:
+ return f"time limit — translated={translated}"
+ return f"translated={translated} scanned={len(posts)}"
+
+
+def _translate_one(session, post, base_url: str, target: str) -> int:
+ """Translate one post's title/description in place. Returns 1 if it stored a
+ translation, 0 for passthrough/empty (which still marks the post handled)."""
+ title = (post.post_title or "").strip()
+ desc = (html_to_plain(post.description) if post.description else "") or ""
+ desc = desc.strip()
+ fields: list[tuple[str, str]] = []
+ if title:
+ fields.append(("title", title))
+ if desc:
+ fields.append(("desc", desc))
+ if not fields:
+ post.translated_source_lang = target # nothing to translate → handled
+ return 0
+ res = ic.translate([t for _, t in fields], base_url=base_url, target=target)
+ detected = res["detected_lang"] or target
+ if detected == target or res["engine"] == "none":
+ post.translated_source_lang = target # already target language → handled
+ return 0
+ by_field = {f: res["translations"][i] for i, (f, _) in enumerate(fields)}
+ post.post_title_translated = by_field.get("title")
+ post.description_translated = by_field.get("desc")
+ post.translated_source_lang = detected
+ post.translation_engine_version = res["engine_version"]
+ post.translated_at = datetime.now(UTC)
+ return 1
diff --git a/frontend/src/components/modal/ProvenancePanel.vue b/frontend/src/components/modal/ProvenancePanel.vue
index 2bd8309..3b8f9e5 100644
--- a/frontend/src/components/modal/ProvenancePanel.vue
+++ b/frontend/src/components/modal/ProvenancePanel.vue
@@ -42,13 +42,23 @@
· {{ e.post.attachment_count }} files
+
{{ e.post.description_translated }}
@@ -117,8 +127,12 @@ const effectiveImage = computed(() => props.image ?? modal.current) // 2026-05-28. Reset when the viewed image changes. const expanded = reactive({}) function toggleDesc(id) { expanded[id] = !expanded[id] } +// Per-entry "show the original (untranslated) text" toggle (#143). +const showOrig = reactive({}) +function toggleOrig(id) { showOrig[id] = !showOrig[id] } watch(() => effectiveId.value, () => { for (const k of Object.keys(expanded)) delete expanded[k] + for (const k of Object.keys(showOrig)) delete showOrig[k] }) watch( @@ -157,10 +171,23 @@ const show = computed(() => { const attachments = computed(() => state.value?.attachments || []) function postDate(e) { return formatPostDate(e.post.date) } +// Translation-forward (#143): show the English by default when present. +function hasTranslation(e) { + return !!(e.post.title_translated || e.post.description_translated) +} +function showTranslated(e) { + return hasTranslation(e) && !showOrig[e.provenance_id] +} +function langLabel(e) { + return e.post.translated_source_lang ? ` (${e.post.translated_source_lang})` : '' +} function postTitle(e) { // Titles can arrive as stored HTML (e.g. "…"); render // as plain text (the CSS makes it bold). - return toPlainText(e.post.title) || `Post ${e.post.external_post_id}` + const t = showTranslated(e) && e.post.title_translated + ? e.post.title_translated + : e.post.title + return toPlainText(t) || `Post ${e.post.external_post_id}` } function openPost(postId, artistId) { diff --git a/frontend/src/components/posts/PostCard.vue b/frontend/src/components/posts/PostCard.vue index 4fdce08..1b7837b 100644 --- a/frontend/src/components/posts/PostCard.vue +++ b/frontend/src/components/posts/PostCard.vue @@ -58,16 +58,23 @@(no description)
@@ -231,6 +238,32 @@ const canExpand = computed( () => props.post.description_truncated === true || cssOverflow.value, ) +// --- translation-forward (#143): the English is shown by default when a +// translation exists; a per-card toggle reveals the original. Translations are +// plain text, so showTranslated also decides HTML-vs-plain body rendering. +const showOriginal = ref(false) +const hasTranslation = computed( + () => !!(props.post.post_title_translated || props.post.description_translated), +) +const showTranslated = computed(() => hasTranslation.value && !showOriginal.value) +const sourceLangLabel = computed(() => + props.post.translated_source_lang ? ` (${props.post.translated_source_lang})` : '', +) +const displayTitle = computed(() => + showTranslated.value && props.post.post_title_translated + ? toPlainText(props.post.post_title_translated) + : plainTitle.value, +) +const descTextDisplay = computed(() => { + if (!showTranslated.value) return descText.value + return descExpanded.value + ? (detail.value?.description_translated_full || props.post.description_translated) + : props.post.description_translated +}) +const showHtmlBody = computed( + () => !showTranslated.value && descExpanded.value && !!descHtml.value, +) + function measureOverflow () { const el = descEl.value cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1 @@ -467,6 +500,19 @@ function formatBytes (n) { font-size: 0.85rem; font-weight: 600; } .fc-post-card__more:hover { text-decoration: underline; } +/* Translation toggle (#143) — quiet secondary affordance under the title. */ +.fc-post-card__lang { + display: block; margin: 2px 0 6px; padding: 0; + background: none; border: 0; cursor: pointer; + color: rgb(var(--v-theme-on-surface-variant)); + font-size: 0.75rem; font-weight: 600; +} +.fc-post-card__lang:hover { + text-decoration: underline; color: rgb(var(--v-theme-accent)); +} +.fc-post-card__lang:focus-visible { + outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px; +} .fc-post-card__atts { display: flex; flex-wrap: wrap; gap: 8px; diff --git a/frontend/src/components/settings/MaintenancePanel.vue b/frontend/src/components/settings/MaintenancePanel.vue index dfc5693..b379015 100644 --- a/frontend/src/components/settings/MaintenancePanel.vue +++ b/frontend/src/components/settings/MaintenancePanel.vue @@ -13,6 +13,7 @@+ Point this at your Interpreter service. It stays off until a URL is set, and + only translates posts whose text isn't already in the target language — the + English is shown by default on post cards, with a toggle back to the original. +
+ +かわいい
", + ) + p.post_title_translated = "cat" + p.description_translated = "cute" + p.translated_source_lang = "ja" + await db.commit() + + page = await PostFeedService(db).scroll(cursor=None, limit=10) + item = page["items"][0] + assert item["post_title_translated"] == "cat" + assert item["description_translated"] == "cute" + assert item["translated_source_lang"] == "ja" + assert item["post_title"] == "ねこ" # original kept for the toggle + assert item["description_plain"] == "かわいい" + + @pytest.mark.asyncio async def test_scroll_description_truncates_long_text(db): artist = await _seed_artist(db, "alice-long") diff --git a/tests/test_translate_posts.py b/tests/test_translate_posts.py new file mode 100644 index 0000000..c59580f --- /dev/null +++ b/tests/test_translate_posts.py @@ -0,0 +1,127 @@ +"""translate_posts sweep (#143) — integration, with a stubbed Interpreter client +and the task's session factory pointed at the test's db_sync.""" +import pytest +from sqlalchemy import select + +from backend.app.models import Artist, ImportSettings, Post +from backend.app.tasks.translation import translate_posts + +pytestmark = pytest.mark.integration + + +class _Ctx: + def __init__(self, s): + self.s = s + + def __enter__(self): + return self.s + + def __exit__(self, *a): + return False + + +def _sf(db_sync): + class _SM: + def __call__(self): + return _Ctx(db_sync) + + return _SM() + + +def _artist(db): + a = Artist(name="A", slug="a") + db.add(a) + db.flush() + return a + + +def _post(db, artist_id, *, title=None, desc=None, ext="p1"): + p = Post( + artist_id=artist_id, external_post_id=ext, + post_title=title, description=desc, + ) + db.add(p) + db.flush() + return p + + +def _enable(db, url="http://i.lan"): + cfg = db.execute( + select(ImportSettings).where(ImportSettings.id == 1) + ).scalar_one() + cfg.translation_enabled = True + cfg.interpreter_base_url = url + db.flush() + + +def _patch(monkeypatch, db_sync): + monkeypatch.setattr( + "backend.app.tasks.translation._sync_session_factory", + lambda: _sf(db_sync), + ) + + +def test_translate_posts_stores_translation(db_sync, monkeypatch): + _patch(monkeypatch, db_sync) + monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True) + monkeypatch.setattr( + "backend.app.tasks.translation.ic.translate", + lambda texts, **k: { + "translations": [f"EN:{t}" for t in texts], + "detected_lang": "ja", "engine": "llm", "engine_version": "v1", + }, + ) + a = _artist(db_sync) + p = _post(db_sync, a.id, title="ねこ", desc="かわいい") + _enable(db_sync) + db_sync.commit() + + assert "translated=1" in translate_posts() + db_sync.refresh(p) + assert p.post_title_translated == "EN:ねこ" + assert p.description_translated == "EN:かわいい" + assert p.translated_source_lang == "ja" + assert p.translation_engine_version == "v1" + + +def test_translate_posts_passthrough_english_marks_handled(db_sync, monkeypatch): + _patch(monkeypatch, db_sync) + monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: True) + monkeypatch.setattr( + "backend.app.tasks.translation.ic.translate", + lambda texts, **k: { + "translations": list(texts), "detected_lang": "en", + "engine": "none", "engine_version": None, + }, + ) + a = _artist(db_sync) + p = _post(db_sync, a.id, title="hello world", ext="p2") + _enable(db_sync) + db_sync.commit() + + translate_posts() + db_sync.refresh(p) + assert p.translated_source_lang == "en" # marked handled... + assert p.post_title_translated is None # ...but nothing to show + + +def test_translate_posts_disabled_is_noop(db_sync, monkeypatch): + _patch(monkeypatch, db_sync) + a = _artist(db_sync) + p = _post(db_sync, a.id, title="ねこ", ext="p3") + db_sync.commit() # translation_enabled defaults False + assert translate_posts() == "disabled" + db_sync.refresh(p) + assert p.translated_source_lang is None + + +def test_translate_posts_service_down_leaves_untranslated(db_sync, monkeypatch): + _patch(monkeypatch, db_sync) + monkeypatch.setattr("backend.app.tasks.translation.ic.health", lambda *a, **k: False) + a = _artist(db_sync) + p = _post(db_sync, a.id, title="ねこ", ext="p4") + _enable(db_sync) + db_sync.commit() + assert translate_posts() == "interpreter unavailable" + db_sync.refresh(p) + assert p.translated_source_lang is None # will retry next run