diff --git a/src/blenderbim/Makefile b/src/blenderbim/Makefile index db77df524a..57096cfa49 100644 --- a/src/blenderbim/Makefile +++ b/src/blenderbim/Makefile @@ -696,6 +696,11 @@ endif # Required for Desktop icon and file association cp -r blenderbim/libs/desktop dist/blenderbim/libs/ + # generate translations module for BBIM build + git clone https://github.com/IfcOpenShell/blenderbim-translations.git dist/working + python blenderbim/scripts/bbim_translations.py -i "dist/working" -o "dist/blenderbim" + rm -rf dist/working + # Remove dependencies also bundled with Blender rm -rf dist/blenderbim/libs/site/packages/numpy rm -rf dist/blenderbim/libs/site/packages/numpy.libs diff --git a/src/blenderbim/blenderbim/bim/__init__.py b/src/blenderbim/blenderbim/bim/__init__.py index 7f59091075..aad6499f5c 100644 --- a/src/blenderbim/blenderbim/bim/__init__.py +++ b/src/blenderbim/blenderbim/bim/__init__.py @@ -24,6 +24,10 @@ import blenderbim import importlib from . import handler, ui, prop, operator, helper +try: + from blenderbim.translations import translations_dict +except ImportError: + translations_dict = {} cwd = os.path.dirname(os.path.realpath(__file__)) modules = { @@ -250,6 +254,8 @@ def register(): except: pass + bpy.app.translations.register("blenderbim", translations_dict) + def unregister(): global icons @@ -292,3 +298,5 @@ def unregister(): bpy.utils.unregister_class(override_panel) bpy.utils.register_class(original_panel) del overridden_scene_panels[panel] + + bpy.app.translations.unregister("blenderbim") diff --git a/src/blenderbim/blenderbim/bim/module/style/prop.py b/src/blenderbim/blenderbim/bim/module/style/prop.py index d7e73f4f3e..cf17b2ace2 100644 --- a/src/blenderbim/blenderbim/bim/module/style/prop.py +++ b/src/blenderbim/blenderbim/bim/module/style/prop.py @@ -32,6 +32,10 @@ from bpy.props import ( CollectionProperty, ) +import gettext + +_ = gettext.gettext + def get_style_types(self, context): if not StylesData.is_loaded: @@ -88,9 +92,9 @@ def update_shader_graph(self, context): UV_MODES = [ - ("UV", "UV", "Actual UV data presented on the geometry"), - ("Generated", "Generated", "Automatically-generated UV from the vertex positions of the mesh"), - ("Camera", "Camera", "UV from position coordinate in camera space"), + ("UV", "UV", _("Actual UV data presented on the geometry")), + ("Generated", "Generated", _("Automatically-generated UV from the vertex positions of the mesh")), + ("Camera", "Camera", _("UV from position coordinate in camera space")), ] diff --git a/src/blenderbim/scripts/bbim_translations.py b/src/blenderbim/scripts/bbim_translations.py new file mode 100644 index 0000000000..30604963ff --- /dev/null +++ b/src/blenderbim/scripts/bbim_translations.py @@ -0,0 +1,374 @@ +try: + import bpy + import bl_i18n_utils + import addon_utils + + BPY_IS_LOADED = True +except ModuleNotFoundError: + BPY_IS_LOADED = False + +import shutil +import tempfile +import importlib +import os +import re +from pathlib import Path +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +bl_info = { + "name": "BlenderBIM Translations", + "description": "", + "author": "IfcOpenShell Contributors", + "blender": (2, 80, 0), + "version": (0, 0, 999999), + "location": "Properties -> Render -> BBIM Update Translation", + "tracker_url": "https://github.com/IfcOpenShell/IfcOpenShell/issues", + "category": "System", +} + +# NOTE: list of available languages in Blender can be retrieved +# by the command below: +# bpy.context.preferences.view.language = "test" +ADDON_NAME = "blenderbim" +TRANSLATION_UI_IS_LOADED = False + + +def is_addon_loaded(addon_name) -> bool: + loaded_default, loaded_state = addon_utils.check(addon_name) + return loaded_state + + +def get_branches_directory(): + return Path(bpy.context.preferences.filepaths.i18n_branches_directory) + + +def get_addon_directory() -> Path: + addon_module = importlib.import_module(ADDON_NAME) + return Path(addon_module.__file__).parent + + +def rearrange_files_for_po_import(po_dir_path: Path, temp_directory: tempfile.TemporaryDirectory): + """I18n directory also has a bit different format then `ui_translate.export/import`, + every .po file has a parent folder with the same name. + So we rearrange the data that way""" + # NOTE: TemporaryDirectory is cleared automatically as variable goes out of scope + # so we expect it as an argument so it won't get cleared right away + + temp_po_dir_path = Path(temp_directory.name) + for file in po_dir_path.glob("**/*"): + if file.suffix != ".po": + continue + shutil.copy(file, temp_po_dir_path / file.name) + + +@dataclass +class Message: + msgid: str + msgctxt: str | None + sources: Optional[List[str]] = field(default_factory=list) + # mapping languages to translated strings + translations: Optional[Dict[str, str]] = field(default_factory=dict) + + +def blenderbim_strings_parse(addon_directory=None, po_directory=None): + # NOTE: we decided to use our own parser due bug in Blender parser + # as it tends to pick up strings from other addons and other Blender parts + # and this bug probably would be to low of a priority for Blender to fix + # ref: https://projects.blender.org/blender/blender/issues/116579 + + if addon_directory is None: + addon_directory = get_addon_directory() + directory = addon_directory / "bim" + + if po_directory is None: + po_directory = get_branches_directory() + + patterns = [ + r'bl_label\s*=\s*"(.*)"', # operator labels + r'bl_description\s*=\s*"(.*)"', # operator descriptions + r'text\s*=\s*"(.*?)"', # UI labels + r'Property\(.*?name\s*=\s*"(.*?)"', # property names + r'report\(\{.*?\}\s*,\s*"(.*?)"', # operator reports + r'info\(\{.*?\}\s*,\s*"(.*?)"', # operator info + r'\b_\("(.*?)"\)', # gettext called with `_` + ] + regexes = [re.compile(pattern) for pattern in patterns] + matched_dict: Dict[str, Message] = dict() + + # NOTE: currently there is no special handling same message with different contexts + for root, dirs, files in os.walk(directory): + root = Path(root) + for file in files: + if not file.endswith(".py"): + continue + filepath = root / file + source_rel_path = filepath.relative_to(addon_directory).as_posix() + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + for i, regex in enumerate(regexes): + is_operator = i < 2 + for regex_match in regex.finditer(content): + string = regex_match.group(1) + if string == "": + continue + elif "{" in string: + # Includes a formatting string. To fix. + # print(match.group(1)) + continue + elif is_operator: + ctx = "Operator" + else: + ctx = None + message = matched_dict.get(string) + if message is None: + message = Message(string, ctx) + matched_dict[string] = message + elif ctx != message.msgctxt and False: + print( + f'WARNING. Message "{string}" was already registered with different context {message.context}. ' + f"Current context: {ctx}. File: {source_rel_path}" + ) + message.sources.append(source_rel_path) + + pot_filepath = po_directory / "blenderbim.pot" + with open(pot_filepath, "w", encoding="utf-8") as fo: + for msg in matched_dict.values(): + # make sure sources are unique but keep the order + sources = list(dict.fromkeys(msg.sources)) + for source in sources: + fo.write(f"#: {source}\n") + if msg.msgctxt != None: + fo.write(f'msgctxt "{msg.msgctxt}"\n') + fo.write(f'msgid "{msg.msgid}"\n') + fo.write('msgstr ""\n') + fo.write("\n") + + +def update_translations_from_po(po_directory: Path, translations_module: Path): + translation_data: Dict[str, Message] = dict() + + def process_po_entry(language, current_chunk: list[str]): + sources = [] + msgid = None + msgstr = None + msgctxt = None + parse_message_attr = lambda line, attr_name: line.removeprefix(f'{attr_name} "').removesuffix('"') + + for line in current_chunk: + line = line.strip() + if line.startswith('msgid "'): + msgid = parse_message_attr(line, "msgid") + elif line.startswith('msgstr "'): + msgstr = parse_message_attr(line, "msgstr") + elif line.startswith('msgctxt "'): + msgctxt = parse_message_attr(line, "msgctxt") + elif line.startswith("#:"): + sources.append(line.removeprefix("# ").strip()) + + msg = translation_data.get(msgid) + if msg is None: + msg = Message(msgid, msgctxt, sources, {language: msgstr}) + translation_data[msgid] = msg + else: + msg.sources.extend(sources) + msg.translations[language] = msgstr + + # load data from .po files + langs = set() + for po_file_path in po_directory.glob("**/*.po"): + lang = po_file_path.stem + langs.add(lang) + with open(po_file_path, "r") as po_file: + current_chunk = [] + for line in po_file: + current_chunk.append(line) + if line.startswith("msgstr"): + process_po_entry(lang, current_chunk) + current_chunk = [] + + # generate translations.py file + tab = " " + default_context = "*" + ret = ["translations_dict = {"] + + for lang in langs: + ret.append(f'{tab}"{lang}": {{') + for msgid, msg in translation_data.items(): + if (msgstr := msg.translations[lang]) in (None, ""): + continue + msgctxt = msg.msgctxt + if not msgctxt: + msgctxt = default_context + ret.append(f"{tab*2}({msgctxt!r}, {msgid!r}): {msgstr!r},") + ret.append(f"{tab}}},") + + ret.append("}") + + with open(translations_module / "translations.py", "w") as fo: + fo.write("\n".join(ret)) + + +if BPY_IS_LOADED: + + class SetupTranslationUI(bpy.types.Operator): + bl_idname = "bim.setup_translation_ui" + bl_label = "Setup Translation UI" + bl_options = set() + + def execute(self, context): + if not is_addon_loaded("ui_translate"): + addon_utils.enable("ui_translate", default_set=True) + self.report({"INFO"}, '"Manage UI translations" addon was not enabled, it\'s enabled now.') + + addon_prefs = context.preferences.addons["ui_translate"].preferences + + def is_valid_path(path: Path): + return path.is_dir() and path.is_absolute() + + # check branches directory + branches_dir = get_branches_directory() + if not is_valid_path(branches_dir): + raise Exception( + f"I18n Branches directory is not set up or doesn't exist ({branches_dir}).\n" + "Setup I18n branches directory below (or in Preferences > File Paths > Development > I18n Branches) " + "to a folder containing (or that will contain) .po files." + ) + + # check translations directory + i18n_dir = Path(addon_prefs.I18N_DIR) + # we won't really use the translations directory, we just need addon to stop complaining about it + if not is_valid_path(i18n_dir): + addon_prefs.I18N_DIR = str(branches_dir) + self.report( + {"INFO"}, + f'Translations directory ({i18n_dir}) doesn\'t exist. It was reset to I18n branches directory: "{branches_dir}"', + ) + + # check source directory + source_dir = Path(addon_prefs.SOURCE_DIR) + default_source_path = Path(bpy.app.binary_path).parent / ".".join([str(i) for i in bpy.app.version[:2]]) + + if not is_valid_path(source_dir): + addon_prefs.SOURCE_DIR = str(default_source_path) + self.report( + {"INFO"}, + f'Source directory ({source_dir}) doesn\'t exist. It was reset to default directory: "{default_source_path}"', + ) + source_dir = default_source_path + + source_locale_path = source_dir / "locale/po" + # Blender UI translations also expect "scripts/presets/keyconfig" to be present in SOURCE_DIR + # but default directory has it by default + if not source_locale_path.is_dir(): + source_locale_path.mkdir(parents=True, exist_ok=True) + self.report( + {"INFO"}, + f"Couldn't find locale path in the source directory, creating dummy directory: {source_locale_path}.", + ) + + from ui_translate.settings import settings as ui_translate_settings + from ui_translate.update_ui import UI_OT_i18n_updatetranslation_init_settings + + i18n_settings = context.window_manager.i18n_update_settings + if not i18n_settings.is_init: + # if it's not loaded yet, we'll try to reload it one more time + # since we default values we set up during the current operator might helped + UI_OT_i18n_updatetranslation_init_settings.execute_static(context, ui_translate_settings) + if not i18n_settings.is_init: + raise Exception( + "UI Translation settings are not initalized. Make sure the following directories exist:\n" + f" - {ui_translate_settings.WORK_DIR}\n" + f" - {ui_translate_settings.BLENDER_I18N_PO_DIR}\n" + ) + + global TRANSLATION_UI_IS_LOADED + TRANSLATION_UI_IS_LOADED = True + + return {"FINISHED"} + + class ParseBlenderBIMStrings(bpy.types.Operator): + bl_idname = "bim.parse_blenderbim_strings" + bl_label = "Parse BlenderBIM strings To .pot" + bl_description = "Parse strings from BlenderBIM and save to .pot" + bl_options = set() + + def execute(self, context): + blenderbim_strings_parse() + self.report({"INFO"}, "String were parsed and saved to .pot file.") + return {"FINISHED"} + + class UpdateTranslationsFromPo(bpy.types.Operator): + bl_idname = "bim.update_translations_from_po" + bl_label = "Update Translations From .po" + bl_description = ( + "Load translation strings from po files at I18n Branches back to translations.py\n" + "Also updates current addon translations in UI" + ) + bl_options = set() + + def execute(self, context): + branches_dir = get_branches_directory() + update_translations_from_po(branches_dir, get_addon_directory()) + + if is_addon_loaded(ADDON_NAME): + bpy.app.translations.unregister(ADDON_NAME) + addon_module = importlib.import_module(ADDON_NAME) + translations_module = getattr(addon_module, "translations") + importlib.reload(translations_module) + bpy.app.translations.register(ADDON_NAME, translations_module.translations_dict) + + self.report({"INFO"}, f"Addon's translation updated from .po in {branches_dir}") + return {"FINISHED"} + + class BBIM_PT_translations(bpy.types.Panel): + bl_label = "BlenderBIM Translations" + bl_idname = "BBIM_PT_translations" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "render" + + def draw(self, context): + layout = self.layout + layout.prop(context.preferences.view, "language") + layout.prop(context.preferences.filepaths, "i18n_branches_directory") + + if not TRANSLATION_UI_IS_LOADED: + layout.operator("bim.setup_translation_ui") + return + + layout.label(text="Developer UI:") + layout.operator("bim.parse_blenderbim_strings", icon="FILE_REFRESH") + + layout.separator() + layout.label(text="Translator UI:") + layout.operator("bim.update_translations_from_po", icon="IMPORT") + + classes = ( + ParseBlenderBIMStrings, + UpdateTranslationsFromPo, + SetupTranslationUI, + BBIM_PT_translations, + ) + + def register(): + for cls in classes: + bpy.utils.register_class(cls) + + def unregister(): + for cls in classes: + bpy.utils.unregister_class(cls) + + +if __name__ == "__main__": + # Example: + # py src/blenderbim/scripts/bbim_translations.py -i "C:/blenderbim-translations" -o "C:/Blender/4.0/scripts/addons/blenderbim/translations.py" + import argparse + + parser = argparse.ArgumentParser(description="Converts .po files to translations.py") + parser.add_argument("-i", "--input", type=str, required=True, help="Directory with .po files") + parser.add_argument( + "-o", "--output", type=str, required=True, help="translations.py module location (file may not exist yet)" + ) + args = parser.parse_args() + update_translations_from_po(po_directory=Path(args.input), translations_module=Path(args.output))