Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2180c36
i18n: wrap ~76 user-facing strings with tr() across Schedules tab
Ski90Moo May 4, 2026
aff35f2
ci: trigger build
Ski90Moo May 4, 2026
b1c5733
i18n: add comprehensive Spanish translation proof-of-concept (issue #…
Ski90Moo May 9, 2026
bce5a70
test: add GTest suite for i18n translation (issue #680)
Ski90Moo May 9, 2026
cdf9504
i18n: add full translations for 18 languages + 4 new languages (issue…
Ski90Moo May 23, 2026
d239658
i18n: fix remaining macumber/Copilot review issues (issue #680)
Ski90Moo May 23, 2026
7eb3da0
chore: untrack Qt-generated moc_*.cpp files
Ski90Moo May 23, 2026
f03e2ee
chore: ignore Qt-generated moc_*.cpp files
Ski90Moo May 23, 2026
4808ad1
test: add IddCoverageForAllFields test (issue #680)
Ski90Moo May 23, 2026
59cad72
test: check IDD coverage across all language .ts files (issue #680)
Ski90Moo May 23, 2026
b2a2d08
docs: update PR 873 review responses
Ski90Moo May 23, 2026
7e3be47
ci: add GitHub Actions translation drift check (issue #680)
Ski90Moo May 23, 2026
518b74f
docs: update PR 873 responses with translation CI details
Ski90Moo May 23, 2026
d74d92b
i18n: wrap hardcoded 'Merging Models' dialog titles with tr() (issue …
Ski90Moo May 23, 2026
05a157e
refactor: replace QCoreApplication::translate lambda/verbose calls wi…
Ski90Moo May 23, 2026
8d3ebce
fix: add missing QStringList include to Translation_GTest.cpp
Ski90Moo May 24, 2026
dce7d74
chore: remove internal PR review tracking document
Ski90Moo May 24, 2026
37c8d6b
i18n: add dialog translations missing from PR #873
Ski90Moo May 26, 2026
95999ac
Merge branch 'openstudiocoalition:develop' into feat/i18n
Ski90Moo May 29, 2026
f498e3d
i18n: add tr() strings for 'Units Conversion' DView dialog (issue #680)
Ski90Moo May 30, 2026
69ba3d8
docs: add TRANSLATION_WORKFLOW.md with full lupdate pipeline
Ski90Moo May 30, 2026
4865c9e
fix: restore white menu bar background after language switch (Qt 6)
Ski90Moo May 31, 2026
0585d1f
style: apply clang-format 18.1.3 to PR-touched files
Ski90Moo Jun 1, 2026
3bce8c5
feat: add missing translation helper scripts
Ski90Moo Jun 1, 2026
4c22eb1
fix: register 4 new languages in CMakeLists and clean up .gitignore
Ski90Moo Jun 1, 2026
31f9508
fix: address jmarrec review — display, whitespace, newlines, loop dialog
Ski90Moo Jun 1, 2026
b70dc87
docs: add note to TRANSLATION_WORKFLOW.md clarifying AI-assisted scope
Ski90Moo Jun 1, 2026
3e1c01c
fix: correct de.pak typo in German WebEngine locales block
Ski90Moo Jun 2, 2026
d3b7090
feat: improve translation tooling from Danish language test
Ski90Moo Jun 2, 2026
f6642ba
docs: document adding a new language in TRANSLATION_WORKFLOW.md
Ski90Moo Jun 2, 2026
5ac6599
fix: normalize XML escaping in all 18 .ts translation files
Ski90Moo Jun 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/translation_check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Translation Check

on:
push:
branches: [ master, develop ]
pull_request:
branches: [ master, develop ]

jobs:
check:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Install Qt6 lupdate
run: |
sudo apt-get update -q
sudo apt-get install -y qt6-l10n-tools

- name: Run lupdate against all .ts files
run: |
# -locations none: suppress line-number comments so only genuine
# string additions/removals cause a git diff change.
lupdate6 src/ \
-ts translations/OpenStudioApp_*.ts \
-extensions cpp,hpp \
-locations none \
2>&1 | tee lupdate_output.txt

- name: Detect vanished or new unfinished strings
run: |
# If lupdate changed any .ts file, strings were either added to source
# (new type="unfinished" stubs) or removed from source (type="obsolete").
# Either way the commit that introduced the drift should not merge until
# the .ts files are updated and translations are provided.
python3 ci/check_translations.py

- name: Upload artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: translation-check-${{ github.sha }}
path: |
lupdate_output.txt
translation_check.patch
98 changes: 98 additions & 0 deletions add_idd_skeleton.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""
Scan the OpenStudio IDD for field names and add any missing entries to the
IDD context of every OpenStudioApp_*.ts file as unfinished skeleton stubs.

Run this after an SDK update that introduces new IDD objects or fields, then
run translate_skeleton.py to fill in translations for the new entries.
"""

import re
import glob
import os
import sys

SDK_PYTHON = r"C:\Users\ml\openstudioapplication\OpenStudio-3.11.0\OpenStudio-3.11.0+241b8abb4d-Windows\Python"
SDK_BIN = r"C:\Users\ml\openstudioapplication\OpenStudio-3.11.0\OpenStudio-3.11.0+241b8abb4d-Windows\bin"

sys.path.insert(0, SDK_PYTHON)
os.add_dll_directory(SDK_BIN)

import openstudio


def get_idd_field_names() -> list[str]:
"""Return sorted unique non-Handle field names from the OpenStudio IDD."""
idd = openstudio.IddFactory.instance().getIddFile(openstudio.IddFileType("OpenStudio"))
names: set[str] = set()
for obj in idd.objects():
nf = obj.numFields()
for i in range(nf):
fld = obj.getField(i)
if fld.is_initialized():
name = fld.get().name()
if name and name != "Handle":
names.add(name)
ext_size = len(obj.extensibleGroup())
for i in range(ext_size):
fld = obj.getField(nf + i)
if fld.is_initialized():
name = fld.get().name()
if name and name != "Handle":
names.add(name)
return sorted(names)


def get_idd_sources(ts_content: str) -> set[str]:
"""Return the set of <source> strings already in the IDD context."""
m = re.search(r'<name>IDD</name>(.*?)(?=<context>|\Z)', ts_content, re.DOTALL)
if not m:
return set()
return set(re.findall(r'<source>([^<]+)</source>', m.group(1)))


def add_missing_entries(ts_content: str, missing: list[str]) -> str:
"""Append skeleton messages for missing names before </context> in the IDD block."""
m = re.search(r'(<name>IDD</name>.*?)(</context>)', ts_content, re.DOTALL)
if not m:
return ts_content

new_messages = "\n".join(
f' <message>\n <source>{name}</source>\n'
f' <translation type="unfinished"></translation>\n </message>'
for name in missing
)
insert_pos = m.start(2)
return ts_content[:insert_pos] + new_messages + "\n" + ts_content[insert_pos:]


def main():
print("Fetching IDD field names from OpenStudio SDK...")
idd_fields = get_idd_field_names()
print(f" {len(idd_fields)} unique IDD field names")

ts_files = sorted(glob.glob("translations/OpenStudioApp_*.ts"))
print(f"Processing {len(ts_files)} .ts files...\n")

for ts_file in ts_files:
lang = os.path.basename(ts_file).replace("OpenStudioApp_", "").replace(".ts", "")
with open(ts_file, encoding="utf-8") as f:
content = f.read()

existing = get_idd_sources(content)
missing = [n for n in idd_fields if n not in existing]

if not missing:
print(f" [{lang}] no missing entries")
continue

new_content = add_missing_entries(content, missing)
with open(ts_file, "w", encoding="utf-8") as f:
f.write(new_content)
print(f" [{lang}] added {len(missing)} skeleton entries")

print("\nDone. Run translate_skeleton.py to fill in translations.")


if __name__ == "__main__":
main()
146 changes: 146 additions & 0 deletions add_language_to_menu.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""
Add a new language entry to MainMenu.cpp and MainMenu.hpp.

Usage:
python add_language_to_menu.py --lang da --name Danish

Run this before the CMake/lupdate steps when adding a new language.
After running, rebuild the application to see the language in
Preferences > Language.

Touch points handled automatically:
- MainMenu.hpp: QAction* member variable + slot declaration
- MainMenu.cpp: action registration block, setChecked(false) in all
existing language slots, new langXxxClicked() implementation
"""

import argparse
import re
import sys

MAIN_MENU_HPP = "src/openstudio_lib/MainMenu.hpp"
MAIN_MENU_CPP = "src/openstudio_lib/MainMenu.cpp"


def main():
parser = argparse.ArgumentParser(description="Add a language to the MainMenu")
parser.add_argument("--lang", required=True, help="Language code, e.g. da")
parser.add_argument("--name", required=True, help="Display name, e.g. Danish")
args = parser.parse_args()

lang_code = args.lang
lang_name = args.name
camel = lang_name.replace(" ", "")
member = f"m_lang{camel}Action"
slot = f"lang{camel}Clicked"

with open(MAIN_MENU_HPP, encoding="utf-8") as f:
hpp = f.read()
with open(MAIN_MENU_CPP, encoding="utf-8") as f:
cpp = f.read()

if member in hpp:
sys.exit(f"ERROR: {member} already exists in MainMenu.hpp — already added?")

# Extract ordered member list from an existing slot to use as the unchecked list
slot_body_m = re.search(
r'void MainMenu::langIndonesianClicked\(\)\s*\{(.*?)\}',
cpp, re.DOTALL
)
if not slot_body_m:
sys.exit("ERROR: Could not find langIndonesianClicked() in MainMenu.cpp")
existing_members = re.findall(r'(m_lang\w+Action)->setChecked', slot_body_m.group(1))

# --- MainMenu.hpp ---

# Member variable: insert after last m_lang...Action;
last_member_m = list(re.finditer(r' QAction\* m_lang\w+Action;', hpp))
if not last_member_m:
sys.exit("ERROR: Could not find m_lang...Action members in MainMenu.hpp")
pos = last_member_m[-1].end()
hpp = hpp[:pos] + f"\n QAction* {member};" + hpp[pos:]

# Slot declaration: insert after last lang...Clicked(); (before addingNewLanguageClicked)
last_slot_m = list(re.finditer(r' void lang\w+Clicked\(\);', hpp))
if not last_slot_m:
sys.exit("ERROR: Could not find lang...Clicked() slots in MainMenu.hpp")
pos = last_slot_m[-1].end()
hpp = hpp[:pos] + f"\n void {slot}();" + hpp[pos:]

# --- MainMenu.cpp ---

# Action registration block: insert before "Add a new language"
add_new_pos = cpp.find(' action = new QAction(tr("Add a new language")')
if add_new_pos == -1:
sys.exit('ERROR: Could not find "Add a new language" action in MainMenu.cpp')
block = (
f' {member} = new QAction(tr("{lang_name}"), this);\n'
f' m_preferencesActions.push_back({member});\n'
f' {member}->setCheckable(true);\n'
f' langMenu->addAction({member});\n'
f' connect({member}, &QAction::triggered, this, &MainMenu::{slot}, Qt::QueuedConnection);\n\n'
)
cpp = cpp[:add_new_pos] + block + cpp[add_new_pos:]

# setChecked(false) in all existing language clicked slots only.
# We match the Indonesian line that appears inside a langXxxClicked() function body,
# i.e. where Indonesian is set to false (all slots except langIndonesianClicked itself).
# We also handle langIndonesianClicked (where it's set to true) separately.
# Simplest safe approach: replace only inside function bodies, not the constructor chain.
# We locate all langXxxClicked() bodies and insert after the Indonesian line there.
def insert_unchecked_in_slots(text):
# Match each langXxxClicked function body and insert the new setChecked(false) line
func_pattern = re.compile(
r'(void MainMenu::lang\w+Clicked\(\)\s*\{)(.*?)(^\})',
re.DOTALL | re.MULTILINE
)
def patch_body(m):
body = m.group(2)
if f'm_langIndonesianAction->setChecked' not in body:
return m.group(0)
patched = re.sub(
r'( m_langIndonesianAction->setChecked\([^)]+\);)',
lambda mm: mm.group(0) + f"\n {member}->setChecked(false);",
body, count=1
)
return m.group(1) + patched + m.group(3)
return func_pattern.sub(patch_body, text)

cpp = insert_unchecked_in_slots(cpp)

# Initializer if/else chain: insert before the final } else { (English default)
# Pattern: the last } else if ... id ... branch, then the } else { default
cpp = re.sub(
r'(} else if \(m_currLang == "id"\) \{[^}]+\})\s*(\} else \{)',
lambda m: m.group(1) + f'\n }} else if (m_currLang == "{lang_code}") {{\n {member}->setChecked(true);\n ' + m.group(2),
cpp, count=1
)

# New slot implementation: insert before addingNewLanguageClicked()
adding_pos = cpp.find("void MainMenu::addingNewLanguageClicked()")
if adding_pos == -1:
sys.exit("ERROR: Could not find addingNewLanguageClicked() in MainMenu.cpp")
unchecked = "".join(f" {m}->setChecked(false);\n" for m in existing_members)
new_slot = (
f"void MainMenu::{slot}() {{\n"
f"{unchecked}"
f" {member}->setChecked(true);\n\n"
f' emit changeLanguageClicked("{lang_code}");\n'
f"}}\n\n"
)
cpp = cpp[:adding_pos] + new_slot + cpp[adding_pos:]

with open(MAIN_MENU_HPP, "w", encoding="utf-8") as f:
f.write(hpp)
with open(MAIN_MENU_CPP, "w", encoding="utf-8") as f:
f.write(cpp)

print(f"Done. Added '{lang_name}' (--lang {lang_code}) to the language menu.")
print(f" Member : {member}")
print(f" Slot : MainMenu::{slot}()")
print("Rebuild the application to see it in Preferences > Language.")


if __name__ == "__main__":
main()
73 changes: 73 additions & 0 deletions ci/check_translations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""
Called by translation_check.yml after lupdate has run.

Checks whether lupdate changed any OpenStudioApp_*.ts file by running
`git diff translations/`. If there are changes it means:

- type="unfinished" stubs were ADDED → new tr() calls in source with no
translation yet. Fix: run translate_skeleton.py / translate_all_languages.py.

- type="obsolete" entries were ADDED → tr() calls removed from source but
still present as dead entries in the .ts files. Fix: run
`lupdate -no-obsolete` and commit the cleaned-up .ts files.

If the diff is empty, all .ts files are in sync with the C++ source.
Exits with code 1 if drift is detected so CI fails.
"""

import re
import subprocess
import sys


def run(cmd: list[str]) -> str:
return subprocess.check_output(cmd, text=True)


def main() -> int:
# Produce a diff of everything lupdate may have touched.
diff = run(["git", "diff", "translations/"])

if not diff:
print("All .ts files are in sync with the C++ source. ✓")
return 0

# Save patch as artifact for inspection.
with open("translation_check.patch", "w") as f:
f.write(diff)

# Classify what changed so the error message is actionable.
added_unfinished = len(re.findall(r'^\+.*type="unfinished"', diff, re.MULTILINE))
added_obsolete = len(re.findall(r'^\+.*type="obsolete"', diff, re.MULTILINE))

# Summarise changed files.
stat = run(["git", "diff", "--stat", "translations/"])
print("lupdate changed the following .ts files:")
print(stat)

if added_unfinished:
print(f" {added_unfinished} new empty unfinished string(s) detected.")
print(" These are new tr() calls in C++ source that have no translation yet.")
print(" Fix: run translate_skeleton.py (Spanish) or translate_all_languages.py")
print(" to batch-translate the new strings, then commit the updated .ts files.")
print()

if added_obsolete:
print(f" {added_obsolete} new obsolete string(s) detected.")
print(" These are tr() calls that were removed from C++ source.")
print(" Fix: run `lupdate src/ -no-obsolete -ts translations/OpenStudioApp_*.ts`")
print(" then commit the cleaned-up .ts files.")
print()

if not added_unfinished and not added_obsolete:
# Location comments or other minor changes — still fail so the dev is aware.
print(" .ts files changed in an unexpected way (not unfinished/obsolete).")
print(" Review translation_check.patch for details.")

print("Translation check FAILED. Commit the updated .ts files to fix CI.")
return 1


if __name__ == "__main__":
sys.exit(main())
Loading
Loading