From a3bc98a53c32f3b546f6017716d26dedc44d0e07 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 7 Jul 2026 12:20:31 -0400 Subject: [PATCH] feat(translation): Post translation columns + settings + migration (#143 step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post gains post_title_translated / description_translated / translated_source_lang / translation_engine_version / translated_at — filled by the translate sweep so viewing is instant. ImportSettings gains translation_enabled (OFF by default), interpreter_base_url (EMPTY — no default host; the operator points it at their own Interpreter proxy behind a reverse proxy) and translation_target_lang (en), exposed + validated via /settings/import. Migration 0083. Settings defaults + patch + validation test. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM --- alembic/versions/0083_post_translation.py | 73 +++++++++++++++++++++++ backend/app/api/settings.py | 15 +++++ backend/app/models/import_settings.py | 15 +++++ backend/app/models/post.py | 18 ++++++ tests/test_api_settings.py | 25 ++++++++ 5 files changed, 146 insertions(+) create mode 100644 alembic/versions/0083_post_translation.py 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..e415e36 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -32,6 +32,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 +72,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 +134,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: 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/tests/test_api_settings.py b/tests/test_api_settings.py index 8067c19..d6bfdc6 100644 --- a/tests/test_api_settings.py +++ b/tests/test_api_settings.py @@ -28,6 +28,31 @@ async def test_patch_import_settings(client): assert body["transparency_threshold"] == 0.7 +@pytest.mark.asyncio +async def test_translation_settings_defaults_and_patch(client): + # #143: translation is OFF by default with NO default host — the operator + # points it at their own Interpreter proxy and enables it. + body = await (await client.get("/api/settings/import")).get_json() + assert body["translation_enabled"] is False + assert body["interpreter_base_url"] == "" + assert body["translation_target_lang"] == "en" + + ok = await client.patch("/api/settings/import", json={ + "translation_enabled": True, + "interpreter_base_url": "http://interpreter.lan", + }) + assert ok.status_code == 200 + out = await ok.get_json() + assert out["translation_enabled"] is True + assert out["interpreter_base_url"] == "http://interpreter.lan" + + # Type validation. + assert (await client.patch( + "/api/settings/import", json={"translation_enabled": "yes"})).status_code == 400 + assert (await client.patch( + "/api/settings/import", json={"interpreter_base_url": 123})).status_code == 400 + + @pytest.mark.asyncio async def test_patch_rejects_non_object(client): resp = await client.patch("/api/settings/import", json=[1, 2, 3])