Replace a translation delivery instead of merging into it
A re-delivery could leave a stale row behind for an utterance the new delivery does not cover: the upsert only touches rows the delivery mentions. --replace clears the language's draft rows first, so what the reviewer sees is what the delivery actually says. Confirmed rows are never deleted. A confirmed translation is an approval, and the import skips confirmed rows anyway, so the approved text survives a re-delivery. 2 new tests.
This commit is contained in:
@@ -20,9 +20,18 @@ second one.
|
||||
python scripts/import_translations.py --project "..." \
|
||||
--delivery /tmp/delivery.json --write --confirm
|
||||
|
||||
# replace this language's drafts, then write it
|
||||
python scripts/import_translations.py --project "..." \
|
||||
--delivery /tmp/delivery.json --write --replace
|
||||
|
||||
A delivery is a *draft* by default. A row that says the target language does
|
||||
not voice an utterance is kept, with the reason, so the gap stays visible:
|
||||
deleting it would lose the alignment between the source and the translation.
|
||||
|
||||
``--replace`` clears the language's existing *draft* rows first, so a
|
||||
re-delivery cannot leave a stale row behind for an utterance the new delivery
|
||||
does not cover. Confirmed rows are never deleted: a confirmed translation is
|
||||
an approval, and the import skips it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -57,12 +66,44 @@ def _describe(payload: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _clear_drafts(conn: sqlite3.Connection, code: str) -> int:
|
||||
"""Delete the language's draft rows. Confirmed rows are kept.
|
||||
|
||||
A confirmed translation is an approval; deleting it would destroy it.
|
||||
The import skips confirmed rows anyway, so they are left alone.
|
||||
"""
|
||||
from umbraaudio.translations import canonical_code
|
||||
|
||||
target = canonical_code(code) or str(code).strip().lower()
|
||||
rows = conn.execute("SELECT DISTINCT language_code FROM utterance_translations").fetchall()
|
||||
matching = [
|
||||
str(row[0])
|
||||
for row in rows
|
||||
if row[0] is not None and (canonical_code(row[0]) or str(row[0]).strip().lower()) == target
|
||||
]
|
||||
if not matching:
|
||||
return 0
|
||||
placeholders = ",".join("?" for _ in matching)
|
||||
cursor = conn.execute(
|
||||
f"DELETE FROM utterance_translations "
|
||||
f"WHERE language_code IN ({placeholders}) AND status <> ?",
|
||||
(*matching, "confirmed"),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Ingest a translation delivery from UmbraTrans.")
|
||||
parser.add_argument("--project", required=True, help="project directory name")
|
||||
parser.add_argument("--delivery", type=Path, required=True, help="delivery payload JSON")
|
||||
parser.add_argument("--write", action="store_true", help="write the rows (default: report)")
|
||||
parser.add_argument("--confirm", action="store_true", help="store as confirmed, not draft")
|
||||
parser.add_argument(
|
||||
"--replace",
|
||||
action="store_true",
|
||||
help="clear the language's draft rows first (confirmed rows are kept)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_config(args.project)
|
||||
@@ -93,6 +134,10 @@ def main() -> int:
|
||||
print("\nNothing written. Add --write to ingest the delivery.")
|
||||
return 0
|
||||
|
||||
if args.replace:
|
||||
removed = _clear_drafts(conn, code)
|
||||
print(f"\ncleared {removed} draft row(s) for {code}")
|
||||
|
||||
report = import_translations(conn, project_id, payload, confirm=args.confirm)
|
||||
after = coverage(conn, code)
|
||||
print("\nIMPORTED")
|
||||
|
||||
@@ -16,12 +16,20 @@ delivery:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from umbraaudio import translations as tr
|
||||
|
||||
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "import_translations.py"
|
||||
_spec = importlib.util.spec_from_file_location("import_translations", _SCRIPT)
|
||||
assert _spec is not None and _spec.loader is not None
|
||||
import_translations = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(import_translations)
|
||||
|
||||
|
||||
def _seed(conn: sqlite3.Connection) -> None:
|
||||
conn.executescript(
|
||||
@@ -83,7 +91,8 @@ def test_delivery_writes_one_row_per_utterance(conn: sqlite3.Connection) -> None
|
||||
assert report.accepted == 3
|
||||
assert report.not_voiced == 1
|
||||
rows = conn.execute(
|
||||
"SELECT utterance_id, text, voiced, status FROM utterance_translations ORDER BY utterance_id"
|
||||
"SELECT utterance_id, text, voiced, status FROM utterance_translations "
|
||||
"ORDER BY utterance_id"
|
||||
).fetchall()
|
||||
assert len(rows) == 3
|
||||
assert rows[0]["text"] == "飞船降落了。"
|
||||
@@ -106,9 +115,7 @@ def test_confirmed_rows_are_not_overwritten(conn: sqlite3.Connection) -> None:
|
||||
tr.import_translations(conn, 1, _delivery(), confirm=True)
|
||||
report = tr.import_translations(conn, 1, _delivery())
|
||||
assert report.skipped_confirmed == 3
|
||||
row = conn.execute(
|
||||
"SELECT text FROM utterance_translations WHERE utterance_id=1"
|
||||
).fetchone()
|
||||
row = conn.execute("SELECT text FROM utterance_translations WHERE utterance_id=1").fetchone()
|
||||
assert row["text"] == "飞船降落了。"
|
||||
|
||||
|
||||
@@ -120,9 +127,10 @@ def test_coverage_finds_a_delivery_stored_under_a_locale_code(
|
||||
) -> None:
|
||||
"""UmbraTrans writes ``zh-Hans``; the reviewer asks about ``zh``."""
|
||||
tr.import_translations(conn, 1, _delivery(target_language="zh-Hans"))
|
||||
assert conn.execute(
|
||||
"SELECT DISTINCT language_code FROM utterance_translations"
|
||||
).fetchone()[0] == "zh"
|
||||
assert (
|
||||
conn.execute("SELECT DISTINCT language_code FROM utterance_translations").fetchone()[0]
|
||||
== "zh"
|
||||
)
|
||||
report = tr.coverage(conn, "zh")
|
||||
assert report["delivered"] == 3
|
||||
assert report["voiced"] == 2
|
||||
@@ -162,3 +170,41 @@ def test_coverage_of_an_undelivered_language_is_zero(conn: sqlite3.Connection) -
|
||||
report = tr.coverage(conn, "de")
|
||||
assert report["delivered"] == 0
|
||||
assert report["missing"] == 5
|
||||
|
||||
|
||||
# --- replacing a delivery -------------------------------------------------
|
||||
|
||||
|
||||
def test_replace_clears_drafts_and_keeps_confirmed(conn: sqlite3.Connection) -> None:
|
||||
"""A re-delivery must not leave a stale row behind.
|
||||
|
||||
A confirmed translation is an approval, so it is never deleted - and the
|
||||
import skips it, so the approved text survives a re-delivery.
|
||||
"""
|
||||
tr.import_translations(conn, 1, _delivery(), confirm=True)
|
||||
conn.execute(
|
||||
"INSERT INTO utterance_translations "
|
||||
"(project_id, utterance_id, language_code, language_name, text, status, origin) "
|
||||
"VALUES (1, 4, 'zh', 'Chinese', '陈旧的草稿。', 'draft', 'umbratrans')"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
removed = import_translations._clear_drafts(conn, "zh")
|
||||
assert removed == 1
|
||||
|
||||
remaining = conn.execute(
|
||||
"SELECT utterance_id, status FROM utterance_translations ORDER BY utterance_id"
|
||||
).fetchall()
|
||||
assert [row["utterance_id"] for row in remaining] == [1, 2, 3]
|
||||
assert all(row["status"] == tr.STATUS_CONFIRMED for row in remaining)
|
||||
|
||||
|
||||
def test_replace_matches_a_locale_code(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO utterance_translations "
|
||||
"(project_id, utterance_id, language_code, language_name, text, status, origin) "
|
||||
"VALUES (1, 1, 'zh-Hans', 'Chinese', '飞船降落了。', 'draft', 'umbratrans')"
|
||||
)
|
||||
conn.commit()
|
||||
assert import_translations._clear_drafts(conn, "zh") == 1
|
||||
assert conn.execute("SELECT COUNT(*) FROM utterance_translations").fetchone()[0] == 0
|
||||
|
||||
Reference in New Issue
Block a user