mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 18:44:05 +02:00
## Move editor under `frontend/editor/`
Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.
### Why
`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
lint config / Storybook.
### What moves
frontend/
├── editor/ ← NEW: everything editor-specific
│ ├── src/ ← was frontend/src/
│ ├── public/ ← was frontend/public/
│ ├── src-tauri/ ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
│ ├── tsconfig*.json, tailwind.config.js, postcss.config.js
│ ├── scripts/
│ ├── .env, .env.desktop, .env.saas
│ └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
├── .gitignore
└── README.md
### Wiring edits (40 files)
- `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
- `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
- `docker/frontend/Dockerfile`
- 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
`.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
- `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
`frontend/editor/DeveloperGuide.md`
Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
relative to script).
### Verification
| Check | Result |
|---|---|
| `task frontend:typecheck:all` (6 variants) | exit 0 |
| `task frontend:lint` (eslint + dpdm) | exit 0 |
| `task frontend:format:check` | exit 0 |
| `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
| `playwright test --list --project=stubbed` | 172 tests discovered |
`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
wrong it wouldn't have built.
### Test plan
- [ ] `frontend-validation.yml` green
- [ ] `e2e-stubbed.yml` green
- [ ] `tauri-build.yml` green on at least one platform
- [ ] `check_toml.yml` runs on a translation-touching PR
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
185 lines
6.3 KiB
Python
185 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Compact Translation Extractor for Character-Limited AI Translation
|
|
Outputs untranslated entries in minimal JSON format with whitespace stripped.
|
|
TOML format only.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
import argparse
|
|
import tomllib # Python 3.11+ (stdlib)
|
|
|
|
|
|
class CompactTranslationExtractor:
|
|
def __init__(
|
|
self,
|
|
locales_dir: str = "frontend/editor/public/locales",
|
|
ignore_file: str = "scripts/ignore_translation.toml",
|
|
):
|
|
self.locales_dir = Path(locales_dir)
|
|
self.golden_truth_file = self.locales_dir / "en-GB" / "translation.toml"
|
|
if not self.golden_truth_file.exists():
|
|
print(
|
|
f"Error: en-GB translation file not found at {self.golden_truth_file}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
self.golden_truth = self._load_translation_file(self.golden_truth_file)
|
|
self.ignore_file = Path(ignore_file)
|
|
self.ignore_patterns = self._load_ignore_patterns()
|
|
|
|
def _load_translation_file(self, file_path: Path) -> dict:
|
|
"""Load TOML translation file."""
|
|
try:
|
|
with open(file_path, "rb") as f:
|
|
return tomllib.load(f)
|
|
except FileNotFoundError:
|
|
print(f"Error: File not found: {file_path}", file=sys.stderr)
|
|
sys.exit(1)
|
|
except Exception as e:
|
|
print(f"Error: Invalid TOML file {file_path}: {e}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
def _load_ignore_patterns(self) -> dict:
|
|
"""Load ignore patterns from TOML file."""
|
|
if not self.ignore_file.exists():
|
|
return {}
|
|
|
|
try:
|
|
with open(self.ignore_file, "rb") as f:
|
|
ignore_data = tomllib.load(f)
|
|
return {
|
|
lang: set(data.get("ignore", [])) for lang, data in ignore_data.items()
|
|
}
|
|
except Exception as e:
|
|
print(
|
|
f"Warning: Could not load ignore file {self.ignore_file}: {e}",
|
|
file=sys.stderr,
|
|
)
|
|
return {}
|
|
|
|
def _flatten_dict(
|
|
self, d: dict, parent_key: str = "", separator: str = "."
|
|
) -> dict:
|
|
"""Flatten nested dictionary into dot-notation keys."""
|
|
items = []
|
|
for k, v in d.items():
|
|
new_key = f"{parent_key}{separator}{k}" if parent_key else k
|
|
if isinstance(v, dict):
|
|
items.extend(self._flatten_dict(v, new_key, separator).items())
|
|
else:
|
|
items.append((new_key, str(v)))
|
|
return dict(items)
|
|
|
|
def get_untranslated_entries(self, language: str) -> dict:
|
|
"""Get all untranslated entries for a language in compact format."""
|
|
lang_dir = self.locales_dir / language
|
|
target_file = lang_dir / "translation.toml"
|
|
|
|
if not target_file.exists():
|
|
print(
|
|
f"Error: Translation file not found for language: {language}",
|
|
file=sys.stderr,
|
|
)
|
|
sys.exit(1)
|
|
|
|
target_data = self._load_translation_file(target_file)
|
|
golden_flat = self._flatten_dict(self.golden_truth)
|
|
target_flat = self._flatten_dict(target_data)
|
|
|
|
lang_code = language.replace("-", "_")
|
|
ignore_set = self.ignore_patterns.get(lang_code, set())
|
|
|
|
# Find missing translations
|
|
missing_keys = set(golden_flat.keys()) - set(target_flat.keys()) - ignore_set
|
|
|
|
# Find untranslated entries (identical to en-GB or marked [UNTRANSLATED])
|
|
untranslated_keys = set()
|
|
for key in target_flat:
|
|
if key in golden_flat and key not in ignore_set:
|
|
target_value = target_flat[key]
|
|
golden_value = golden_flat[key]
|
|
|
|
if (
|
|
isinstance(target_value, str)
|
|
and target_value.startswith("[UNTRANSLATED]")
|
|
) or (
|
|
golden_value == target_value
|
|
and not self._is_expected_identical(key, golden_value)
|
|
):
|
|
untranslated_keys.add(key)
|
|
|
|
# Combine and create compact output
|
|
all_untranslated = missing_keys | untranslated_keys
|
|
|
|
compact_entries = {}
|
|
for key in sorted(all_untranslated):
|
|
if key in golden_flat:
|
|
compact_entries[key] = golden_flat[key]
|
|
|
|
return compact_entries
|
|
|
|
def _is_expected_identical(self, key: str, value: str) -> bool:
|
|
"""Check if a key-value pair is expected to be identical across languages."""
|
|
identical_patterns = ["language.direction"]
|
|
identical_values = {"ltr", "rtl", "True", "False", "true", "false", "unknown"}
|
|
|
|
if value.strip() in identical_values:
|
|
return True
|
|
|
|
for pattern in identical_patterns:
|
|
if pattern in key.lower():
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Extract untranslated entries in compact format for AI translation (TOML format only)"
|
|
)
|
|
parser.add_argument("language", help="Language code (e.g., de-DE, fr-FR)")
|
|
parser.add_argument(
|
|
"--locales-dir",
|
|
default="frontend/editor/public/locales",
|
|
help="Path to locales directory",
|
|
)
|
|
parser.add_argument(
|
|
"--ignore-file",
|
|
default="scripts/ignore_translation.toml",
|
|
help="Path to ignore patterns file",
|
|
)
|
|
parser.add_argument(
|
|
"--max-entries", type=int, help="Maximum number of entries to output"
|
|
)
|
|
parser.add_argument("--output", help="Output file (default: stdout)")
|
|
|
|
args = parser.parse_args()
|
|
|
|
extractor = CompactTranslationExtractor(args.locales_dir, args.ignore_file)
|
|
untranslated = extractor.get_untranslated_entries(args.language)
|
|
|
|
if args.max_entries:
|
|
# Take first N entries
|
|
keys = list(untranslated.keys())[: args.max_entries]
|
|
untranslated = {k: untranslated[k] for k in keys}
|
|
|
|
# Output compact JSON (no indentation, minimal whitespace)
|
|
output = json.dumps(untranslated, separators=(",", ":"), ensure_ascii=False)
|
|
|
|
if args.output:
|
|
with open(args.output, "w", encoding="utf-8") as f:
|
|
f.write(output)
|
|
print(
|
|
f"Extracted {len(untranslated)} untranslated entries to {args.output}",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
print(output)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|