Spaces:
Running on Zero
Running on Zero
| """UI event handlers and state management for SimpleTranslationUI.""" | |
| import math | |
| import os | |
| import tempfile | |
| import threading | |
| import zipfile | |
| from pathlib import Path | |
| import gradio as gr | |
| from engine import ( | |
| export_to_docx, | |
| export_to_json, | |
| export_to_txt, | |
| load_source_file, | |
| parse_json, | |
| read_prompt, | |
| reset_prompt, | |
| save_prompt, | |
| translate_one as _engine_translate_one, | |
| translate_segments, | |
| using_openrouter, | |
| ) | |
| MAX_SLOTS = 25 | |
| # ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _make_state() -> dict: | |
| return {"page": 0, "segments": [], "has_translation": False, "label": "output"} | |
| def _save_page_edits(state: dict, sources: list, targets: list) -> dict: | |
| segments = state["segments"] | |
| start = state["page"] * MAX_SLOTS | |
| for i in range(MAX_SLOTS): | |
| idx = start + i | |
| if idx >= len(segments): | |
| break | |
| segments[idx]["source"] = sources[i] or "" | |
| segments[idx]["target"] = targets[i] or "" | |
| state["segments"] = segments | |
| return state | |
| # ββ Page rendering ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _load_page(state: dict) -> tuple: | |
| segments = state["segments"] | |
| total = len(segments) | |
| page = state["page"] | |
| if total == 0: | |
| return ( | |
| state, | |
| gr.update(value=""), | |
| gr.update(interactive=False), | |
| gr.update(interactive=False), | |
| *[gr.update(visible=False)] * MAX_SLOTS, | |
| *[gr.update(value="")] * MAX_SLOTS, | |
| *[gr.update(value="")] * MAX_SLOTS, | |
| ) | |
| total_pages = math.ceil(total / MAX_SLOTS) | |
| state["page"] = min(page, total_pages - 1) | |
| start = state["page"] * MAX_SLOTS | |
| end = min(start + MAX_SLOTS, total) | |
| n = end - start | |
| if total_pages > 1: | |
| nav = f"<p>Page {state['page'] + 1} of {total_pages} Β· Segments {start + 1}β{end} of {total}</p>" | |
| else: | |
| nav = f"<p>All {total} segments</p>" | |
| groups, sources, targets = [], [], [] | |
| for i in range(MAX_SLOTS): | |
| if i < n: | |
| seg = segments[start + i] | |
| groups.append(gr.update(visible=True)) | |
| sources.append(gr.update(value=seg.get("source", ""))) | |
| targets.append(gr.update(value=seg.get("target", ""))) | |
| else: | |
| groups.append(gr.update(visible=False)) | |
| sources.append(gr.update(value="")) | |
| targets.append(gr.update(value="")) | |
| return ( | |
| state, | |
| gr.update(value=nav), | |
| gr.update(interactive=(state["page"] > 0)), | |
| gr.update(interactive=(state["page"] < total_pages - 1)), | |
| *groups, *sources, *targets, | |
| ) | |
| # ββ File loading ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _load_source(source_file, state): | |
| if source_file is None: | |
| return (*_load_page(state), gr.update(visible=False)) | |
| path = source_file if isinstance(source_file, str) else source_file.name | |
| label = Path(path).stem | |
| try: | |
| segments = load_source_file(path) | |
| except Exception as e: | |
| print(f"[WARN] Failed to load source file: {e}") | |
| return (*_load_page(state), gr.update(visible=False)) | |
| state["segments"] = segments | |
| state["page"] = 0 | |
| state["label"] = label | |
| state["has_translation"] = False | |
| return (*_load_page(state), gr.update(visible=True)) | |
| def _load_resume_json(resume_file, state): | |
| """Merge translations from a previously-downloaded JSON export back in, | |
| matched by segment position (assumes the same source file/segmentation).""" | |
| if resume_file is None or not state.get("segments"): | |
| return _load_page(state) | |
| path = resume_file if isinstance(resume_file, str) else resume_file.name | |
| with open(path, "rb") as f: | |
| content = f.read() | |
| try: | |
| resumed = parse_json(content) | |
| except Exception as e: | |
| print(f"[WARN] Failed to parse resume JSON: {e}") | |
| return _load_page(state) | |
| for seg, r in zip(state["segments"], resumed): | |
| if r.get("target"): | |
| seg["target"] = r["target"] | |
| state["has_translation"] = any(seg.get("target") for seg in state["segments"]) | |
| state["page"] = 0 | |
| return _load_page(state) | |
| # ββ Downloads βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _make_downloads(segments: list[dict], label: str) -> dict: | |
| tmp = tempfile.gettempdir() | |
| def _write_text(content, ext): | |
| if not content.strip(): | |
| return None | |
| path = os.path.join(tmp, f"{label}.{ext}") | |
| Path(path).write_text(content, encoding="utf-8") | |
| return path | |
| def _write_bytes(content, ext): | |
| if not content: | |
| return None | |
| path = os.path.join(tmp, f"{label}.{ext}") | |
| Path(path).write_bytes(content) | |
| return path | |
| def _write_named(content, filename): | |
| if not content.strip(): | |
| return None | |
| path = os.path.join(tmp, filename) | |
| Path(path).write_text(content, encoding="utf-8") | |
| return path | |
| return { | |
| "txt": _write_text(export_to_txt(segments, "target"), "txt"), | |
| "docx": _write_bytes(export_to_docx(segments, "target"), "docx"), | |
| "json": _write_text(export_to_json(segments), "json"), | |
| "source_txt": _write_named(export_to_txt(segments, "source"), f"{label}_source.txt"), | |
| } | |
| _FORMAT_META = [ | |
| ("txt", "Translation (TXT)"), | |
| ("docx", "Translation (DOCX)"), | |
| ("json", "JSON (for resume)"), | |
| ("source_txt", "Source text (TXT)"), | |
| ] | |
| def _handle_save(state, *slot_values): | |
| sources = list(slot_values[:MAX_SLOTS]) | |
| targets = list(slot_values[MAX_SLOTS:]) | |
| state = _save_page_edits(state, sources, targets) | |
| return state, gr.update(value="Saved.", visible=True) | |
| def _handle_download_click(state, *slot_values): | |
| sources = list(slot_values[:MAX_SLOTS]) | |
| targets = list(slot_values[MAX_SLOTS:]) | |
| state = _save_page_edits(state, sources, targets) | |
| paths = _make_downloads(state["segments"], state.get("label", "output")) | |
| available = [label for key, label in _FORMAT_META if paths.get(key)] | |
| return ( | |
| state, | |
| gr.update(choices=available, value=available, visible=True), | |
| gr.update(visible=True), | |
| gr.update(value=None, visible=False), | |
| ) | |
| def _handle_get_files(state, selected_labels): | |
| segments = state["segments"] | |
| label = state.get("label", "output") | |
| paths = _make_downloads(segments, label) | |
| path_by_label = {fmt_label: paths[key] for key, fmt_label in _FORMAT_META} | |
| chosen = [path_by_label[l] for l in (selected_labels or []) if path_by_label.get(l)] | |
| if not chosen: | |
| return gr.update(value=None, visible=False), gr.update(visible=False), gr.update(visible=False) | |
| zip_path = os.path.join(tempfile.gettempdir(), f"{label}_export.zip") | |
| with zipfile.ZipFile(zip_path, "w") as zf: | |
| for path in chosen: | |
| zf.write(path, arcname=os.path.basename(path)) | |
| return gr.update(value=zip_path, visible=True), gr.update(visible=False), gr.update(visible=False) | |
| # ββ Translation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _translate_one(state: dict, slot_idx: int, source: str, api_key: str, model: str): | |
| try: | |
| text = _engine_translate_one(source, api_key, model) | |
| except Exception as e: | |
| text = f"[Translation error: {e}]" | |
| actual_idx = state["page"] * MAX_SLOTS + slot_idx | |
| segments = state["segments"] | |
| if actual_idx < len(segments): | |
| segments[actual_idx]["target"] = text | |
| state["segments"] = segments | |
| return state, gr.update(value=text) | |
| def _translate_all(state, api_key, model, *slot_values): | |
| sources = list(slot_values[:MAX_SLOTS]) | |
| targets = list(slot_values[MAX_SLOTS:]) | |
| state = _save_page_edits(state, sources, targets) | |
| backend = "OpenRouter" if using_openrouter(api_key) else "local CPU model (mlotsawa-ground-base)" | |
| stop = threading.Event() | |
| result = [None, None] | |
| progress = [0, 0] | |
| def _run(): | |
| def _on_progress(i, total): | |
| progress[0] = i | |
| progress[1] = total | |
| s, e = translate_segments( | |
| state["segments"], api_key, model, | |
| progress_callback=_on_progress, stop=stop, | |
| ) | |
| result[0] = s | |
| result[1] = e | |
| thread = threading.Thread(target=_run, daemon=True) | |
| thread.start() | |
| try: | |
| while thread.is_alive(): | |
| thread.join(timeout=0.5) | |
| if thread.is_alive(): | |
| i, total = progress | |
| prog = f" ({i + 1}/{total})" if total > 0 else "" | |
| yield (*_load_page(state), gr.update(value=f"Translating via {backend}β¦{prog}", visible=True)) | |
| except GeneratorExit: | |
| stop.set() | |
| raise | |
| segments = result[0] if result[0] is not None else state["segments"] | |
| errors = result[1] or [] | |
| state["segments"] = segments | |
| state["has_translation"] = True | |
| state["page"] = 0 | |
| count = len(segments) - len(errors) | |
| status = f"Translated {count} segment(s) via {backend}." | |
| if errors: | |
| status += f" {len(errors)} error(s)." | |
| yield (*_load_page(state), gr.update(value=status, visible=True)) | |
| # ββ Prompt management βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _read_prompt() -> str: | |
| return read_prompt() | |
| def _save_prompt(text: str): | |
| save_prompt(text) | |
| return gr.update(value="Prompt saved.") | |
| def _reset_prompt(): | |
| text = reset_prompt() | |
| return gr.update(value=text), gr.update(value="Prompt reset to default.") | |
| # ββ Navigation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _handle_prev(state, *slot_values): | |
| sources = list(slot_values[:MAX_SLOTS]) | |
| targets = list(slot_values[MAX_SLOTS:]) | |
| state = _save_page_edits(state, sources, targets) | |
| if state["page"] > 0: | |
| state["page"] -= 1 | |
| return _load_page(state) | |
| def _handle_next(state, *slot_values): | |
| sources = list(slot_values[:MAX_SLOTS]) | |
| targets = list(slot_values[MAX_SLOTS:]) | |
| state = _save_page_edits(state, sources, targets) | |
| total = len(state["segments"]) | |
| total_pages = math.ceil(total / MAX_SLOTS) if total else 1 | |
| if state["page"] < total_pages - 1: | |
| state["page"] += 1 | |
| return _load_page(state) | |