feat: language picker with flags, paging and follow mode, translation run in the floating window
Language picker with search and SVG flags, real pagination with locate, one global machine translation run shown next to uploads. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f6bd4508e
commit
7fa88e4025
82 files changed
+978
-187
No files matched your search
@@ -18,7 +18,6 @@ router = APIRouter(prefix="/api/versions/{version_id}/i18n", tags=["i18n"])
|
||||
Player = Annotated[User, Depends(require("games.play"))]
|
||||
Translator = Annotated[User, Depends(require("games.play", "translations.edit"))]
|
||||
|
||||
TODO_STATES = {"untranslated", "outdated", "fuzzy", "machine"}
|
||||
LANG_RE = r"^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$"
|
||||
FONT_EXTS = (".ttf", ".otf", ".woff", ".woff2")
|
||||
MAX_FONT_BYTES = 30 * 1024 * 1024
|
||||
@@ -84,56 +83,12 @@ async def overview(version_id: int, user: Player, db: DB) -> dict[str, Any]:
|
||||
|
||||
@router.get("/{lang}/entries")
|
||||
async def entries(version_id: int, lang: str, user: Player, db: DB, kind: str = "lines",
|
||||
state: str = "all", module: str = "", q: str = "", offset: int = 0,
|
||||
limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]:
|
||||
state: str = "all", module: str = "", q: str = "", offset: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200), locate: str = "") -> dict[str, Any]:
|
||||
v = await ready_version(db, user, version_id)
|
||||
needle = q.strip().lower()
|
||||
|
||||
def matches(st: str, *texts: str) -> bool:
|
||||
if state == "todo" and st not in TODO_STATES:
|
||||
return False
|
||||
if state not in ("all", "todo") and st != state:
|
||||
return False
|
||||
return not needle or any(needle in t.lower() for t in texts if t)
|
||||
|
||||
def build() -> dict[str, Any]:
|
||||
idx = tr.text_index(v.manifest)
|
||||
lines, strings, _ = tr.locale_tables(v.manifest, lang)
|
||||
files = v.manifest["files"]
|
||||
items: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
if kind == "strings":
|
||||
for src, usage in idx.strings.items():
|
||||
e = strings.get(src) or {}
|
||||
st = e.get("state", "translated") if e.get("t") else "untranslated"
|
||||
if not matches(st, src, e.get("t", "")):
|
||||
continue
|
||||
total += 1
|
||||
if offset < total <= offset + limit:
|
||||
items.append({"key": src, "usage": usage, "source": src,
|
||||
"translation": e.get("t", ""), "state": st})
|
||||
else:
|
||||
for info in idx.lines:
|
||||
if module and info.module != module:
|
||||
continue
|
||||
e = lines.get(info.tid)
|
||||
st = tr.line_state(e, info)
|
||||
if not matches(st, info.source, (e or {}).get("t", ""), info.who):
|
||||
continue
|
||||
total += 1
|
||||
if offset < total <= offset + limit:
|
||||
scene = files.get(info.scene) if info.scene else None
|
||||
items.append({
|
||||
"key": info.tid, "nodeId": info.node_id, "module": info.module,
|
||||
"label": info.label, "who": info.who, "source": info.source,
|
||||
"resource": info.resource,
|
||||
"prev": info.prev, "next": info.next,
|
||||
"scene": f"/blobs/{scene['hash']}" if scene else None,
|
||||
"translation": (e or {}).get("t", ""), "state": st,
|
||||
})
|
||||
return {"total": total, "items": items}
|
||||
|
||||
return await run_in_threadpool(build)
|
||||
return await run_in_threadpool(
|
||||
lambda: tr.entries_page(v.manifest, lang, kind=kind, state=state, module=module, q=q,
|
||||
offset=offset, limit=limit, locate=locate))
|
||||
|
||||
|
||||
@router.get("/{lang}/meta")
|
||||
|
||||
@@ -470,3 +470,65 @@ def parse_csv(text: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
elif row.get("kind") == "line":
|
||||
lines[row["key"]] = value
|
||||
return lines, strings
|
||||
|
||||
|
||||
TODO_STATES = {"untranslated", "outdated", "fuzzy", "machine"}
|
||||
|
||||
|
||||
def entries_page(manifest: dict[str, Any], lang: str, *, kind: str = "lines", state: str = "all",
|
||||
module: str = "", q: str = "", offset: int = 0, limit: int = 50,
|
||||
locate: str = "") -> dict[str, Any]:
|
||||
"""One page of the list the editor shows, in the order the editor shows it.
|
||||
|
||||
``locate`` is the key of a line: ``located`` is its position in this same filtered list
|
||||
(``None`` when the line is not in it), so the editor can open the page that holds it.
|
||||
"""
|
||||
needle = q.strip().lower()
|
||||
|
||||
def matches(st: str, *texts: str) -> bool:
|
||||
if state == "todo" and st not in TODO_STATES:
|
||||
return False
|
||||
if state not in ("all", "todo") and st != state:
|
||||
return False
|
||||
return not needle or any(needle in t.lower() for t in texts if t)
|
||||
|
||||
idx = text_index(manifest)
|
||||
lines, strings, _ = locale_tables(manifest, lang)
|
||||
files = manifest["files"]
|
||||
items: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
located: int | None = None
|
||||
if kind == "strings":
|
||||
for src, usage in idx.strings.items():
|
||||
e = strings.get(src) or {}
|
||||
st = e.get("state", "translated") if e.get("t") else "untranslated"
|
||||
if not matches(st, src, e.get("t", "")):
|
||||
continue
|
||||
total += 1
|
||||
if locate and src == locate:
|
||||
located = total - 1
|
||||
if offset < total <= offset + limit:
|
||||
items.append({"key": src, "usage": usage, "source": src,
|
||||
"translation": e.get("t", ""), "state": st})
|
||||
else:
|
||||
for info in idx.lines:
|
||||
if module and info.module != module:
|
||||
continue
|
||||
e = lines.get(info.tid)
|
||||
st = line_state(e, info)
|
||||
if not matches(st, info.source, (e or {}).get("t", ""), info.who):
|
||||
continue
|
||||
total += 1
|
||||
if locate and info.tid == locate:
|
||||
located = total - 1
|
||||
if offset < total <= offset + limit:
|
||||
scene = files.get(info.scene) if info.scene else None
|
||||
items.append({
|
||||
"key": info.tid, "nodeId": info.node_id, "module": info.module,
|
||||
"label": info.label, "who": info.who, "source": info.source,
|
||||
"resource": info.resource,
|
||||
"prev": info.prev, "next": info.next,
|
||||
"scene": f"/blobs/{scene['hash']}" if scene else None,
|
||||
"translation": (e or {}).get("t", ""), "state": st,
|
||||
})
|
||||
return {"total": total, "items": items, "located": located}
|
||||
@@ -80,3 +80,19 @@ def test_po_and_csv_roundtrip(manifest):
|
||||
st = tr.stats(m, "de")
|
||||
assert st["translated"] == 1 and st["stringsTranslated"] == 1
|
||||
assert json.dumps(st)
|
||||
|
||||
|
||||
def test_entries_page_locates_a_line_in_the_filtered_list(manifest):
|
||||
idx = tr.text_index(manifest)
|
||||
first, second = idx.lines[0], idx.lines[1]
|
||||
page = tr.entries_page(manifest, "ru", state="untranslated", limit=1, locate=second.tid)
|
||||
assert page["located"] == 1 and page["total"] == len(idx.lines) and len(page["items"]) == 1
|
||||
|
||||
m = tr.apply_changes(manifest, "ru", lines={first.tid: {"t": "Привет", "state": "machine"}})
|
||||
# a translated line leaves the "untranslated" list, so the next one moves up
|
||||
assert tr.entries_page(m, "ru", state="untranslated", locate=second.tid)["located"] == 0
|
||||
assert tr.entries_page(m, "ru", state="untranslated", locate=first.tid)["located"] is None
|
||||
# ...but stays in "todo" because machine translations still await a review
|
||||
assert tr.entries_page(m, "ru", state="todo", locate=first.tid)["located"] == 0
|
||||
assert tr.entries_page(m, "ru", locate="")["located"] is None
|
||||
assert tr.entries_page(m, "ru", kind="strings", locate="Эйлин")["located"] is not None
|
||||
Reference in new issue
Block a user