diff --git a/review-tool/index.html b/review-tool/index.html new file mode 100644 index 00000000000..e3cea2d1a7a --- /dev/null +++ b/review-tool/index.html @@ -0,0 +1,547 @@ + + + + + +Plane Hebrew Translation Review + + + +
+

Plane Hebrew Translation Review

+
+
+
+
+
+ A Accept + R Reject (keep English) + E Edit + J/K Navigate + Enter Save edit +
+
+ + + + + + +
+
+ + + +
+
+
+ + + + diff --git a/review-tool/server.py b/review-tool/server.py new file mode 100644 index 00000000000..9798a1f1c4c --- /dev/null +++ b/review-tool/server.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Translation review server. +Parses English and Hebrew TS translation files, serves a web UI for review, +and writes corrected translations back to the Hebrew TS files. + +Usage: python3 server.py [port] +""" + +import http.server +import json +import os +import re +import sys +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +LOCALES_DIR = Path(__file__).resolve().parent.parent / "packages" / "i18n" / "src" / "locales" +TRANSLATION_FILES = ["translations", "accessibility", "empty-state", "editor"] +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8888 + + +def parse_ts_to_dict(filepath: Path) -> dict: + """Parse a TS file that exports a default object literal into a Python dict.""" + text = filepath.read_text(encoding="utf-8") + # Strip copyright header, export default, and trailing "as const;" + text = re.sub(r"/\*[\s\S]*?\*/", "", text) + # Strip single-line comments (// ...) + text = re.sub(r"//[^\n]*", "", text) + text = re.sub(r"export\s+default\s+", "", text) + text = re.sub(r"\}\s*as\s+const\s*;?\s*$", "}", text.strip()) + # Convert JS object to JSON: add quotes around unquoted keys + # Handle keys that start with numbers or contain special chars (already quoted) + text = re.sub(r'(?<=[{,\n])\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r' "\1":', text) + # Remove trailing commas before } or ] + text = re.sub(r",\s*([}\]])", r"\1", text) + try: + return json.loads(text) + except json.JSONDecodeError as e: + print(f"Warning: Failed to parse {filepath}: {e}") + print(f"Problematic text around error (char {e.pos}):") + start = max(0, e.pos - 100) + end = min(len(text), e.pos + 100) + print(text[start:end]) + return {} + + +def flatten_dict(d: dict, prefix: str = "") -> list[tuple[str, str]]: + """Flatten nested dict into list of (dotted.path, value) pairs.""" + items = [] + for key, value in d.items(): + path = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + items.extend(flatten_dict(value, path)) + else: + items.append((path, str(value))) + return items + + +def unflatten_dict(pairs: list[tuple[str, str]]) -> dict: + """Convert list of (dotted.path, value) pairs back to nested dict.""" + result = {} + for path, value in pairs: + parts = path.split(".") + d = result + for part in parts[:-1]: + d = d.setdefault(part, {}) + d[parts[-1]] = value + return result + + +def dict_to_ts(d: dict, indent: int = 2) -> str: + """Convert a dict back to TypeScript object literal format.""" + def format_value(v, level): + pad = " " * (indent * level) + if isinstance(v, dict): + if not v: + return "{}" + lines = [] + lines.append("{") + items = list(v.items()) + for i, (key, val) in enumerate(items): + # Quote keys that start with numbers or have special chars + if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', key): + key_str = key + else: + key_str = f'"{key}"' + formatted = format_value(val, level + 1) + comma = "," if i < len(items) - 1 else "," + if isinstance(val, dict): + lines.append(f"{pad} {key_str}: {formatted}{comma}") + else: + lines.append(f"{pad} {key_str}: {formatted}{comma}") + lines.append(f"{pad}}}") + return "\n".join(lines) + else: + # Escape the string value for JS + escaped = str(v).replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + return f'"{escaped}"' + + return format_value(d, 0) + + +def build_review_data() -> dict: + """Build the review data structure from EN and HE files.""" + files = {} + for name in TRANSLATION_FILES: + en_path = LOCALES_DIR / "en" / f"{name}.ts" + he_path = LOCALES_DIR / "he" / f"{name}.ts" + if not en_path.exists(): + continue + en_dict = parse_ts_to_dict(en_path) + he_dict = parse_ts_to_dict(he_path) if he_path.exists() else {} + en_flat = flatten_dict(en_dict) + he_flat_dict = dict(flatten_dict(he_dict)) + + entries = [] + for path, en_value in en_flat: + he_value = he_flat_dict.get(path, en_value) + entries.append({ + "path": path, + "file": name, + "en": en_value, + "he": he_value, + "status": "pending", # pending, accepted, rejected, corrected + }) + files[name] = entries + return files + + +def save_translations(file_name: str, pairs: list[tuple[str, str]]): + """Save reviewed translations back to a Hebrew TS file.""" + he_path = LOCALES_DIR / "he" / f"{file_name}.ts" + d = unflatten_dict(pairs) + ts_content = dict_to_ts(d) + + content = f"""/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +export default {ts_content} as const; +""" + he_path.write_text(content, encoding="utf-8") + print(f"Saved {he_path}") + + +# Pre-build review data +print("Parsing translation files...") +review_data = build_review_data() +total = sum(len(entries) for entries in review_data.values()) +print(f"Loaded {total} translation entries across {len(review_data)} files") + +HTML_PATH = Path(__file__).resolve().parent / "index.html" + + +class ReviewHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + if parsed.path == "/" or parsed.path == "/index.html": + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.end_headers() + self.wfile.write(HTML_PATH.read_bytes()) + elif parsed.path == "/api/data": + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write(json.dumps(review_data, ensure_ascii=False).encode("utf-8")) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if self.path == "/api/save": + length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(length)) + file_name = body["file"] + pairs = [(e["path"], e["he"]) for e in body["entries"]] + save_translations(file_name, pairs) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"ok": True}).encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + pass # Suppress request logs + + +if __name__ == "__main__": + server = http.server.HTTPServer(("0.0.0.0", PORT), ReviewHandler) + print(f"\nReview UI: http://localhost:{PORT}") + print("Press Ctrl+C to stop\n") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nStopped.")