diff --git a/.gitignore b/.gitignore index 1bf01033f..0a76704b8 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,4 @@ I18N/ # AI Assistant Configuration CLAUDE.md +**/CustomLocalizable.strings diff --git a/AppDates/swiftgen.yml b/AppDates/swiftgen.yml index 66b3aaee3..2cfa39ee6 100644 --- a/AppDates/swiftgen.yml +++ b/AppDates/swiftgen.yml @@ -1,6 +1,9 @@ strings: inputs: - AppDates/en.lproj + # The filter below matches .strings or .stringsdict files + # BUT excludes any file named "CustomLocalizable.strings" + filter: '^(?!.*CustomLocalizable\.strings$).*\.(?i:strings|stringsdict)$' outputs: - templateName: structured-swift5 params: diff --git a/Authorization/swiftgen.yml b/Authorization/swiftgen.yml index fe3ca1aa1..473519ac7 100644 --- a/Authorization/swiftgen.yml +++ b/Authorization/swiftgen.yml @@ -1,6 +1,9 @@ strings: inputs: - Authorization/en.lproj + # The filter below matches .strings or .stringsdict files + # BUT excludes any file named "CustomLocalizable.strings" + filter: '^(?!.*CustomLocalizable\.strings$).*\.(?i:strings|stringsdict)$' outputs: - templateName: structured-swift5 params: diff --git a/Core/swiftgen.yml b/Core/swiftgen.yml index b6eee1ab6..63d9241cb 100644 --- a/Core/swiftgen.yml +++ b/Core/swiftgen.yml @@ -1,6 +1,9 @@ strings: inputs: - Core/en.lproj + # The filter below matches .strings or .stringsdict files + # BUT excludes any file named "CustomLocalizable.strings" + filter: '^(?!.*CustomLocalizable\.strings$).*\.(?i:strings|stringsdict)$' outputs: - templateName: structured-swift5 params: diff --git a/Course/swiftgen.yml b/Course/swiftgen.yml index 73a8fe1d0..22ac2dfd0 100644 --- a/Course/swiftgen.yml +++ b/Course/swiftgen.yml @@ -1,6 +1,9 @@ strings: inputs: - Course/en.lproj + # The filter below matches .strings or .stringsdict files + # BUT excludes any file named "CustomLocalizable.strings" + filter: '^(?!.*CustomLocalizable\.strings$).*\.(?i:strings|stringsdict)$' outputs: - templateName: structured-swift5 params: diff --git a/Dashboard/swiftgen.yml b/Dashboard/swiftgen.yml index 7d824c690..f2e07c965 100644 --- a/Dashboard/swiftgen.yml +++ b/Dashboard/swiftgen.yml @@ -1,6 +1,9 @@ strings: inputs: - Dashboard/en.lproj + # The filter below matches .strings or .stringsdict files + # BUT excludes any file named "CustomLocalizable.strings" + filter: '^(?!.*CustomLocalizable\.strings$).*\.(?i:strings|stringsdict)$' outputs: - templateName: structured-swift5 params: diff --git a/Discovery/swiftgen.yml b/Discovery/swiftgen.yml index 0a5e1f9f2..86c7e2708 100644 --- a/Discovery/swiftgen.yml +++ b/Discovery/swiftgen.yml @@ -1,6 +1,9 @@ strings: inputs: - Discovery/en.lproj + # The filter below matches .strings or .stringsdict files + # BUT excludes any file named "CustomLocalizable.strings" + filter: '^(?!.*CustomLocalizable\.strings$).*\.(?i:strings|stringsdict)$' outputs: - templateName: structured-swift5 params: diff --git a/Discussion/swiftgen.yml b/Discussion/swiftgen.yml index 7b8d0ed86..b04547a02 100644 --- a/Discussion/swiftgen.yml +++ b/Discussion/swiftgen.yml @@ -1,6 +1,9 @@ strings: inputs: - Discussion/en.lproj + # The filter below matches .strings or .stringsdict files + # BUT excludes any file named "CustomLocalizable.strings" + filter: '^(?!.*CustomLocalizable\.strings$).*\.(?i:strings|stringsdict)$' outputs: - templateName: structured-swift5 params: diff --git a/Downloads/swiftgen.yml b/Downloads/swiftgen.yml index 929eceda8..f91745c02 100644 --- a/Downloads/swiftgen.yml +++ b/Downloads/swiftgen.yml @@ -1,6 +1,9 @@ strings: inputs: - Downloads/en.lproj + # The filter below matches .strings or .stringsdict files + # BUT excludes any file named "CustomLocalizable.strings" + filter: '^(?!.*CustomLocalizable\.strings$).*\.(?i:strings|stringsdict)$' outputs: - templateName: structured-swift5 params: diff --git a/Gemfile b/Gemfile index 42dff7037..b97e1cacd 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,7 @@ source "https://rubygems.org" +gem "abbrev" +gem "base64" +gem "mutex_m" gem "fastlane" gem "xcode-install" diff --git a/Gemfile.lock b/Gemfile.lock index d9bc70b14..245deb9b7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,45 +1,49 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.7) - base64 - nkf - rexml - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + CFPropertyList (3.0.8) + abbrev (0.1.2) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.3.0) - aws-partitions (1.998.0) - aws-sdk-core (3.211.0) + aws-eventstream (1.4.0) + aws-partitions (1.1261.0) + aws-sdk-core (3.252.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.95.0) - aws-sdk-core (~> 3, >= 3.210.0) + logger + aws-sdk-kms (1.129.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.169.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-s3 (1.226.0) + aws-sdk-core (~> 3, >= 3.248.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.1) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) - base64 (0.2.0) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) + csv (3.3.5) declarative (0.0.20) - digest-crc (0.6.5) + digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) excon (0.112.0) - faraday (1.10.4) + faraday (1.10.5) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -51,32 +55,36 @@ GEM faraday-rack (~> 1.0) faraday-retry (~> 1.0) ruby2_keywords (>= 0.0.4) - faraday-cookie_jar (0.0.7) + faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) - http-cookie (~> 1.0.0) + http-cookie (>= 1.0.0) faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.0) + faraday-em_synchrony (1.0.1) faraday-excon (1.1.0) faraday-httpclient (1.0.1) - faraday-multipart (1.0.4) - multipart-post (~> 2) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.3.1) - fastlane (2.225.0) - CFPropertyList (>= 2.3, < 4.0.0) + fastimage (2.4.1) + fastlane (2.236.1) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) - aws-sdk-s3 (~> 1.0) + aws-sdk-s3 (~> 1.197) babosa (>= 1.0.3, < 2.0.0) - bundler (>= 1.12.0, < 3.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) colored (~> 1.2) commander (~> 4.6) + csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 1.0.0) @@ -84,20 +92,25 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) - fastlane-sirp (>= 1.0.0) + fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-env (>= 1.6.0, < 2.3.0) google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) json (< 3.0.0) - jwt (>= 2.1.0, < 3) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) naturally (~> 2.2) + nkf (~> 0.2) optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.5) @@ -108,86 +121,93 @@ GEM tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) xcodeproj (>= 1.13.0, < 2.0.0) - xcpretty (~> 0.3.0) + xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.54.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.3) + google-apis-androidpublisher_v3 (0.102.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) addressable (~> 2.5, >= 2.5.1) - googleauth (>= 0.16.2, < 2.a) - httpclient (>= 2.8.1, < 3.a) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) mini_mime (~> 1.0) + mutex_m representable (~> 3.0) retriable (>= 2.0, < 4.a) - rexml - google-apis-iamcredentials_v1 (0.17.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-playcustomapp_v1 (0.13.0) - google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.31.0) - google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.7.1) + google-apis-iamcredentials_v1 (0.27.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.17.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.63.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) - google-cloud-env (1.6.0) - faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.4.0) - google-cloud-storage (1.47.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.6.0) + google-cloud-storage (1.61.0) addressable (~> 2.8) digest-crc (~> 0.4) - google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.31.0) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) google-cloud-core (~> 1.6) - googleauth (>= 0.16.2, < 2.a) + googleauth (~> 1.9) mini_mime (~> 1.0) - googleauth (1.8.1) - faraday (>= 0.17.3, < 3.a) - jwt (>= 1.4, < 3.0) - multi_json (~> 1.11) + google-logging-utils (0.2.0) + googleauth (1.17.1) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) os (>= 0.9, < 2.0) + pstore (~> 0.1) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.7) + http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.8.3) + httpclient (2.9.0) + mutex_m jmespath (1.6.2) - json (2.7.5) - jwt (2.9.3) + json (2.19.9) + jwt (3.2.0) base64 + logger (1.7.0) mini_magick (4.13.2) mini_mime (1.1.5) - multi_json (1.15.0) + multi_json (1.21.1) multipart-post (2.4.1) + mutex_m (0.3.0) nanaimo (0.4.0) - naturally (2.2.1) - nkf (0.2.0) - optparse (0.5.0) + naturally (2.3.0) + nkf (0.3.0) + optparse (0.8.1) os (1.1.4) - plist (3.7.1) - public_suffix (6.0.1) - rake (13.2.1) + ostruct (0.6.3) + plist (3.7.2) + pstore (0.2.1) + public_suffix (7.0.5) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.1.2) - rexml (3.3.9) - rouge (2.0.7) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) ruby2_keywords (0.0.5) - rubyzip (2.3.2) + rubyzip (2.4.1) security (0.1.5) - signet (0.19.0) + signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) - jwt (>= 1.5, < 3.0) - multi_json (~> 1.10) + jwt (>= 1.5, < 4.0) simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) @@ -202,25 +222,29 @@ GEM xcode-install (2.8.1) claide (>= 0.9.1) fastlane (>= 2.1.0, < 3.0.0) - xcodeproj (1.26.0) + xcodeproj (1.27.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.4.0) rexml (>= 3.3.6, < 4.0) - xcpretty (0.3.0) - rouge (~> 2.0.7) + xcpretty (0.4.1) + rouge (~> 3.28.0) xcpretty-travis-formatter (1.0.1) xcpretty (~> 0.2, >= 0.0.7) PLATFORMS arm64-darwin-22 arm64-darwin-23 + arm64-darwin-24 DEPENDENCIES + abbrev + base64 fastlane + mutex_m xcode-install BUNDLED WITH - 2.4.10 + 2.6.2 diff --git a/Profile/swiftgen.yml b/Profile/swiftgen.yml index 552bcb3bc..d40eeed33 100644 --- a/Profile/swiftgen.yml +++ b/Profile/swiftgen.yml @@ -1,6 +1,9 @@ strings: inputs: - Profile/en.lproj + # The filter below matches .strings or .stringsdict files + # BUT excludes any file named "CustomLocalizable.strings" + filter: '^(?!.*CustomLocalizable\.strings$).*\.(?i:strings|stringsdict)$' outputs: - templateName: structured-swift5 params: diff --git a/WhatsNew/swiftgen.yml b/WhatsNew/swiftgen.yml index 9aa2d1c3b..10440e347 100644 --- a/WhatsNew/swiftgen.yml +++ b/WhatsNew/swiftgen.yml @@ -1,6 +1,9 @@ strings: inputs: - WhatsNew/en.lproj + # The filter below matches .strings or .stringsdict files + # BUT excludes any file named "CustomLocalizable.strings" + filter: '^(?!.*CustomLocalizable\.strings$).*\.(?i:strings|stringsdict)$' outputs: - templateName: structured-swift5 params: diff --git a/i18n_scripts/translation.py b/i18n_scripts/translation.py index eac8a32a1..4c5af524e 100644 --- a/i18n_scripts/translation.py +++ b/i18n_scripts/translation.py @@ -3,7 +3,8 @@ This script performs two jobs: 1- Combine the English translations from all modules in the repository to the I18N directory. After the English translation is combined, it will be pushed to the openedx-translations repository as described in OEP-58. -2- Split the pulled translation files from the openedx-translations repository into the iOS app modules. + 2- Split the pulled translation files from the openedx-translations repository into the iOS app modules + and merge/overwrite them with CustomLocalizable.strings if it exists. More detailed specifications are described in the docs/0002-atlas-translations-management.rst design doc. """ @@ -28,8 +29,8 @@ def parse_arguments(): """ This function is the argument parser for this script. - The script takes only one of the two arguments --split or --combine. - Additionally, the --replace-underscore argument can only be used with --split. + The script takes only one of the three main arguments: --split, --combine, or --clean. + Additionally, the --replace-underscore and --add-xcode-files arguments can only be used with --split. """ parser = argparse.ArgumentParser(description='Split or Combine translations.') group = parser.add_mutually_exclusive_group(required=True) @@ -50,10 +51,9 @@ def parse_arguments(): @contextmanager def change_directory(new_dir: Path): """ - Context manager to execute `os.chidir`. + Context manager to execute `os.chdir`. Usage: - with change_directory('/some/path'): do_stuff_here() @@ -69,7 +69,7 @@ def change_directory(new_dir: Path): def get_modules_dir(override: Path = None) -> Path: """ - Gets the modeles directory (repository root directory). + Gets the modules directory (repository root directory). """ if override: return override @@ -109,13 +109,13 @@ def get_translation_file_path(modules_dir: Path, module_name, lang_dir, create_d def get_modules_to_translate(modules_dir: Path): """ - Retrieve the names of modules that have translation files for a specified language. + Retrieve the names of modules that have translation files for the English language. Parameters: modules_dir (Path): The path to the directory containing all the modules. Returns: - list of str: A list of module names that have translation files for the specified language. + list of str: A list of module names that have English translation files. """ try: modules_list = [ @@ -128,32 +128,28 @@ def get_modules_to_translate(modules_dir: Path): ) ] return modules_list - except FileNotFoundError as e: - print(f"Directory not found: {e}", file=sys.stderr) - raise - except PermissionError as e: - print(f"Permission denied: {e}", file=sys.stderr) + except (FileNotFoundError, PermissionError) as e: + print(f"Error accessing modules directory: {e}", file=sys.stderr) raise def get_translations(modules_dir: Path): """ - Retrieve the translations from all modules in the modules_dir. + Retrieve the translations from all modules in the modules_dir and prefix keys with module name. Parameters: modules_dir (Path): The directory containing the modules. Returns: - dict: A dict containing a list of dictionaries containing the 'key', 'value', and 'comment' for each - translation line. The key of the outer dict is the name of the module where the translations are going - to be saved. + dict: A dict containing a list of dictionaries containing the 'key', 'value', and 'comment'. + The key of the outer dict is I18N_MODULE_NAME as it's intended for the combined file. """ translations = [] try: modules = get_modules_to_translate(modules_dir) for module in modules: translation_file = get_translation_file_path(modules_dir, module, lang_dir='en.lproj') - module_translation = localizable.parse_strings(filename=translation_file) + module_translation = localizable.parse_strings(filename=str(translation_file)) translations += [ { @@ -171,44 +167,52 @@ def get_translations(modules_dir: Path): def combine_translation_files(modules_dir=None): """ - Combine translation files from different modules into a single file. + Combine English translation files from different modules into a single master file in the I18N directory. """ try: modules_dir = get_modules_dir(override=modules_dir) translation = get_translations(modules_dir) - write_translations_to_modules(modules_dir, 'en.lproj', translation) + # We explicitly write to en.lproj for the combined file + write_translations_to_modules(modules_dir, 'en.lproj', translation, is_combine=True) except Exception as e: print(f"Error combining translation files: {e}", file=sys.stderr) raise -def get_languages_dirs(modules_dir: Path): +def get_languages_dirs(modules_dir: Path, include_english=False): """ - Retrieve directories containing language files for translation. + Retrieve directories containing language files from the I18N module. Args: modules_dir (Path): The directory containing all the modules. + include_english (bool): Whether to include the 'en.lproj' directory. Defaults to False. Returns: - list: A list of directories containing language files for translation. Each directory represents - a specific language and ends with the '.lproj' extension. + list: A list of directories ending with '.lproj'. """ try: lang_parent_dir = modules_dir / I18N_MODULE_NAME / I18N_MODULE_NAME + if not lang_parent_dir.exists(): + return [] + languages_dirs = [ directory for directory in os.listdir(lang_parent_dir) - if directory.endswith('.lproj') and directory != "en.lproj" + if directory.endswith('.lproj') ] + + if not include_english and "en.lproj" in languages_dirs: + languages_dirs.remove("en.lproj") + return languages_dirs - except FileNotFoundError as e: - print(f"Directory not found: {e}", file=sys.stderr) - raise - except PermissionError as e: - print(f"Permission denied: {e}", file=sys.stderr) + except Exception as e: + print(f"Error retrieving language directories: {e}", file=sys.stderr) raise def ensure_utf8(file_path: Path): + """ + Ensures that a file is encoded in UTF-8. If UTF-16 is detected via BOM, it converts it. + """ raw = file_path.read_bytes() if raw[:2] in (b'\xff\xfe', b'\xfe\xff'): text = raw.decode('utf-16') @@ -217,69 +221,109 @@ def ensure_utf8(file_path: Path): def get_translations_from_file(modules_dir, lang_dir): """ - Get translations from the translation file in the 'I18N' directory and distribute them into the appropriate - modules' directories. + Get translations from the master translation file in the 'I18N' directory and group them by module. Args: modules_dir (str): The directory containing all the modules. lang_dir (str): The directory containing the translation file being split. Returns: - dict: A dictionary containing translations split by module. The keys are module names, - and the values are lists of dictionaries, each containing the 'key', 'value', and 'comment' - for each translation entry within the module. + dict: A dictionary where keys are module names and values are lists of translation entries. """ translations = defaultdict(list) try: translations_file_path = get_translation_file_path(modules_dir, I18N_MODULE_NAME, lang_dir) + if not translations_file_path.exists(): + return translations + ensure_utf8(translations_file_path) - lang_list = localizable.parse_strings(filename=translations_file_path) + lang_list = localizable.parse_strings(filename=str(translations_file_path)) for translation_entry in lang_list: - module_name, key_remainder = translation_entry['key'].split('.', maxsplit=1) - split_entry = { - 'key': key_remainder, - 'value': translation_entry['value'], - 'comment': translation_entry['comment'] - } - translations[module_name].append(split_entry) + if '.' in translation_entry['key']: + module_name, key_remainder = translation_entry['key'].split('.', maxsplit=1) + split_entry = { + 'key': key_remainder, + 'value': translation_entry['value'], + 'comment': translation_entry['comment'] + } + translations[module_name].append(split_entry) except Exception as e: - print(f"Error extracting translations from file: {e}", file=sys.stderr) + print(f"Error extracting translations from master file: {e}", file=sys.stderr) raise return translations -def write_translations_to_modules(modules_dir: Path, lang_dir, modules_translations): +def write_translations_to_modules(modules_dir: Path, lang_dir, modules_translations, is_combine=False): """ Write translations to language files for each module. + + If is_combine is True: writes the master combined file to the I18N module. + If is_combine is False: splits strings into modules and merges with CustomLocalizable.strings if present. Args: - modules_dir (str): The directory containing all the modules. - lang_dir (str): The directory of the translation file being written. + modules_dir (Path): The directory containing all the modules. + lang_dir (str): The directory of the translation file being written (e.g., 'es.lproj'). modules_translations (dict): A dictionary containing translations for each module. - - Returns: - None + is_combine (bool): Flag indicating if we are combining into I18N or splitting into modules. """ - for module, translation_list in modules_translations.items(): + if is_combine: + targets = [I18N_MODULE_NAME] + else: + # During split, ensure we check all known modules plus the Main module + targets = set(get_modules_to_translate(modules_dir)) + targets.add(MAIN_MODULE_NAME) + targets.update(modules_translations.keys()) + + for module in targets: + # Skip I18N if we are in split mode + if not is_combine and module == I18N_MODULE_NAME: + continue + + community_list = modules_translations.get(module, []) + custom_list = [] + try: - translation_file_path = get_translation_file_path(modules_dir, module, lang_dir, create_dirs=True) - with open(translation_file_path, 'w') as f: - for translation_entry in translation_list: - write_line_and_comment(f, translation_entry) + target_file_path = get_translation_file_path(modules_dir, module, lang_dir, create_dirs=True) + + # Logic for merging Custom Overrides during the Split phase + if not is_combine: + custom_file_path = target_file_path.parent / 'CustomLocalizable.strings' + if custom_file_path.exists(): + print(f" - [{lang_dir}] Merging custom overrides for: {module}") + custom_list = localizable.parse_strings(filename=str(custom_file_path)) + + custom_keys = {item['key'] for item in custom_list} + + with open(target_file_path, 'w') as f: + if is_combine: + f.write(f"/* Combined English Source for Open edX iOS */\n") + else: + f.write(f"/* Community Translations for {module} ({lang_dir}) */\n") + + written_count = 0 + for entry in community_list: + # Overwrite community strings if a custom key exists + if entry['key'] not in custom_keys: + write_line_and_comment(f, entry) + written_count += 1 + + if written_count == 0 and not custom_list: + f.write(f"/* Empty {lang_dir}/Localizable.strings: Created by i18n_scripts/translation.py */\n") + + if custom_list: + f.write(f"\n/* --- Custom Overrides for {module} --- */\n") + for entry in custom_list: + write_line_and_comment(f, entry) + except Exception as e: - print(f"Error writing translations to file.\n Module: {module}\n Error: {e}", file=sys.stderr) + print(f"Error writing translations to module {module}: {e}", file=sys.stderr) raise - # The main project structure is located into `OpenEdX` rather than `OpenEdX/OpenEdX` - # Empty files are added, so iOS knows which languages are supported in this app - main_translation_file_path = get_translation_file_path(modules_dir, MAIN_MODULE_NAME, lang_dir, create_dirs=True) - with open(main_translation_file_path, 'w') as f: - f.write(f'/* Empty {lang_dir}/Localizable.strings: Created by i18n_scripts/translation.py */') - def _escape(s): """ - Reverse the replacements performed by _unescape() in the localizable library + Reverse the replacements performed by _unescape() in the localizable library. + Escapes newlines and quotes for .strings format. """ s = s.replace('\n', r'\n').replace('\r', r'\r').replace('"', r'\"') return s @@ -290,13 +334,10 @@ def write_line_and_comment(f, entry): Write a translation line with an optional comment to a file. Args: - file (file object): The file object to write to. - entry (dict): A dictionary containing the translation entry with 'key', 'value', and optional 'comment'. - - Returns: - None + f (file object): The file object to write to. + entry (dict): A dictionary containing 'key', 'value', and optional 'comment'. """ - comment = entry.get('comment') # Retrieve the comment, if present + comment = entry.get('comment') if comment: f.write(f"/* {comment} */\n") f.write(f'"{entry["key"]}" = "{_escape(entry["value"])}";\n') @@ -304,21 +345,15 @@ def write_line_and_comment(f, entry): def split_translation_files(modules_dir=None): """ - Split translation files into separate files for each module and language. - - Args: - modules_dir (str, optional): The directory containing all the modules. If not provided, - it defaults to the parent directory of the directory containing this script. - - Returns: - None + Split translation files from the I18N directory into separate files for each module and language. """ try: modules_dir = get_modules_dir(override=modules_dir) languages_dirs = get_languages_dirs(modules_dir) for lang_dir in languages_dirs: + print(f"Processing Language: {lang_dir}") translations = get_translations_from_file(modules_dir, lang_dir) - write_translations_to_modules(modules_dir, lang_dir, translations) + write_translations_to_modules(modules_dir, lang_dir, translations, is_combine=False) except Exception as e: print(f"Error splitting translation files: {e}", file=sys.stderr) raise @@ -327,45 +362,38 @@ def split_translation_files(modules_dir=None): def get_project_path(modules_dir: Path, module_name: str) -> Path: """ Using a module_name return the pbxproj path. - - :param modules_dir: - :param module_name: - :return: Path """ if module_name == MAIN_MODULE_NAME: - project_file_path = modules_dir / f'{module_name}.xcodeproj/project.pbxproj' - else: - project_file_path = modules_dir / module_name / f'{module_name}.xcodeproj/project.pbxproj' - - return project_file_path + return modules_dir / f'{module_name}.xcodeproj/project.pbxproj' + return modules_dir / module_name / f'{module_name}.xcodeproj/project.pbxproj' def get_xcode_project(modules_dir: Path, module_name: str) -> XcodeProject: """ Initialize an XCode project instance for a given module. """ - xcode_project = XcodeProject.load(get_project_path(modules_dir, module_name)) - return xcode_project + path = get_project_path(modules_dir, module_name) + return XcodeProject.load(path) if path.exists() else None -def list_translation_files(module_path: Path) -> [Path]: +def list_translation_files(module_path: Path): """ - List translaiton files in a given path. - - This method doesn't return the `en.lproj` translation source strings. + List translation files in a given path. + Does not return the `en.lproj` source strings or `CustomLocalizable.strings`. """ - for localizable_abs_path in module_path.rglob('**/Localizable.strings'): - if localizable_abs_path.parent.name != 'en.lproj': - yield localizable_abs_path + for path in module_path.rglob('**/Localizable.strings'): + if path.parent.name != 'en.lproj' and "CustomLocalizable" not in path.name: + yield path -def get_xcode_projects(modules_dir: Path) -> [{Path, XcodeProject}]: +def get_xcode_projects(modules_dir: Path): """ - Return a list of module_name, xcode_project pairs. + Return a list of module_name, xcode_project pairs for all modules to translate. """ for module_name in get_modules_to_translate(modules_dir): - xcode_project = get_xcode_project(modules_dir, module_name) - yield module_name, xcode_project + project = get_xcode_project(modules_dir, module_name) + if project: + yield module_name, project def add_localizable(xcode_project: XcodeProject, localizable_relative_path: Path): @@ -377,44 +405,35 @@ def add_localizable(xcode_project: XcodeProject, localizable_relative_path: Path TODO: Refactor to use the `master` version once either of the following issues is closed: - Issue by st3fan: https://github.com/kronenthaler/mod-pbxproj/issues/113 - Proposal by OmarIthawi for Axim: https://github.com/kronenthaler/mod-pbxproj/pull/356 - - :param xcode_project: XcodeProject - :param localizable_relative_path: Path - :return: """ - language, _rest = str(localizable_relative_path).split('.lproj') # e.g. `ar` or `fr-ca` + language = str(localizable_relative_path).split('.lproj')[0] print(f' - Adding "{localizable_relative_path}" for the "{language}" language.') + localizable_groups = xcode_project.get_groups_by_name(name='Localizable.strings', - section='PBXVariantGroup') - if len(localizable_groups) != 1: - # We need a single group. If many are found then, it's a problem. - raise Exception(f'Error: Cannot find the Localizable.strings group, please add the English ' - f'source translation strings with the name Localizable.strings. ' - f'Results: "{localizable_groups}"') - localizable_group = localizable_groups[0] + section='PBXVariantGroup') + if not localizable_groups: + return xcode_project.add_file( str(localizable_relative_path), name=language, - parent=localizable_group, + parent=localizable_groups[0], force=False, tree=LOCALIZABLE_FILES_TREE, - file_options=FileOptions( - create_build_files=False, - ), + file_options=FileOptions(create_build_files=False), ) def add_translation_files_to_xcode(modules_dir: Path = None): """ - Add Localizable.strings files pulled from Transifex to XCode. + Add Localizable.strings files pulled from Transifex (or split from I18N) to XCode projects. """ try: modules_dir = get_modules_dir(override=modules_dir) for module_name, xcode_project in get_xcode_projects(modules_dir): print(f'## Entering project: {module_name}') module_path = modules_dir / module_name - project_files_path = module_path / module_name # e.g. openedx-app-ios/Authorization/Authorization + project_files_path = module_path / module_name with change_directory(project_files_path): for localizable_abs_path in list_translation_files(module_path): @@ -424,30 +443,26 @@ def add_translation_files_to_xcode(modules_dir: Path = None): ) xcode_project.save() - # This project is used to specify which languages are supported by the app for iOS + # Handle Main Project print(f'## Entering project: {MAIN_MODULE_NAME}') - main_xcode_project = get_xcode_project(modules_dir, module_name=MAIN_MODULE_NAME) - main_xcode_project_module_path = modules_dir / MAIN_MODULE_NAME - with change_directory(main_xcode_project_module_path): - # The main project structure is located into `OpenEdX` rather than `OpenEdX/OpenEdX` - for localizable_abs_path in list_translation_files(main_xcode_project_module_path): - add_localizable( - xcode_project=main_xcode_project, - localizable_relative_path=localizable_abs_path.relative_to(main_xcode_project_module_path), - ) - main_xcode_project.save() - + main_project = get_xcode_project(modules_dir, MAIN_MODULE_NAME) + if main_project: + main_path = modules_dir / MAIN_MODULE_NAME + with change_directory(main_path): + for localizable_abs_path in list_translation_files(main_path): + add_localizable( + xcode_project=main_project, + localizable_relative_path=localizable_abs_path.relative_to(main_path), + ) + main_project.save() except Exception as e: - print(f"Error: An unexpected error occurred in add_translation_files_to_xcode: {e}", file=sys.stderr) + print(f"Error updating Xcode projects: {e}", file=sys.stderr) raise def remove_xcode_localizable_variants(xcode_project: XcodeProject) -> None: """ - Remove all non-English localizable files from the XCode project. - - :param xcode_project: XcodeProject - :return: + Remove all non-English localizable files from the XCode project metadata. """ for file_ref in xcode_project.objects.get_objects_in_section('PBXFileReference'): if ( @@ -457,85 +472,66 @@ def remove_xcode_localizable_variants(xcode_project: XcodeProject) -> None: and getattr(file_ref, 'lastKnownFileType', None) == 'text.plist.strings' ): path = file_ref.path - language, _rest = str(path).split('.lproj') # e.g. `ar` or `fr-ca` + language = str(path).split('.lproj')[0] print(f' - Removing "{path}" from project resources for the "{language}" language.') xcode_project.remove_files_by_path(file_ref.path, tree=LOCALIZABLE_FILES_TREE, target_name=language) -def delete_translation_files(module_path: Path, xcode_project_path_base: Path): - """ - Delete the files from the file system. - - :param module_path: Path - :param xcode_project_path_base: Path - :return: - """ - for localizable_abs_path in list_translation_files(module_path): - localizable_relative_path = localizable_abs_path.relative_to(xcode_project_path_base) - print(f' - Removing "{localizable_relative_path}" file from file system') - localizable_abs_path.unlink() - - def clean_translation_files(modules_dir: Path = None): """ - Remove translation files from both file system and XCode project files. + Remove translation files from both the file system and XCode project files. """ try: modules_dir = get_modules_dir(override=modules_dir) - for module_name, xcode_project in get_xcode_projects(modules_dir): - print(f'## Entering project: {module_name}') + all_modules = get_modules_to_translate(modules_dir) + [MAIN_MODULE_NAME] + + for module_name in all_modules: + print(f'## Cleaning project: {module_name}') + xcode_project = get_xcode_project(modules_dir, module_name) module_path = modules_dir / module_name - delete_translation_files(module_path, xcode_project_path_base=module_path/module_name) - remove_xcode_localizable_variants(xcode_project) - xcode_project.save() - - # This project is used to specify which languages are supported by the app for iOS - print(f'## Entering project: {MAIN_MODULE_NAME}') - main_xcode_project = get_xcode_project(modules_dir, module_name=MAIN_MODULE_NAME) - main_xcode_project_module_path = modules_dir / MAIN_MODULE_NAME - # The main project structure is located into `OpenEdX` rather than `OpenEdX/OpenEdX` - delete_translation_files(main_xcode_project_module_path, main_xcode_project_module_path) - remove_xcode_localizable_variants(main_xcode_project) - main_xcode_project.save() + + # Delete physical files + for path in list_translation_files(module_path): + print(f' - Deleting: {path.relative_to(modules_dir)}') + path.unlink() + + # Remove from Xcode + if xcode_project: + remove_xcode_localizable_variants(xcode_project) + xcode_project.save() + except Exception as e: - print(f"Error: An unexpected error occurred in clean_translation_files: {e}", file=sys.stderr) + print(f"Error cleaning files: {e}", file=sys.stderr) raise def replace_underscores(modules_dir=None): + """ + Rename Transifex style locale folders (e.g., ar_IQ.lproj) to iOS compatible + naming (ar-IQ.lproj) within the I18N directory. + """ try: modules_dir = get_modules_dir(override=modules_dir) - languages_dirs = get_languages_dirs(modules_dir) - - for lang_dir in languages_dirs: - lang_old_path = os.path.dirname(get_translation_file_path(modules_dir, I18N_MODULE_NAME, lang_dir)) - try: - pattern = r'_(\w\w.lproj$)' - if re.search(pattern, lang_dir): - replacement = r'-\1' - new_name = re.sub(pattern, replacement, lang_dir) - lang_new_path = os.path.dirname(get_translation_file_path(modules_dir, I18N_MODULE_NAME, new_name)) - - os.rename(lang_old_path, lang_new_path) - print(f"Renamed {lang_old_path} to {lang_new_path}") - - except FileNotFoundError as e: - print(f"Error: The file or directory {lang_old_path} does not exist: {e}", file=sys.stderr) - raise - except PermissionError as e: - print(f"Error: Permission denied while renaming {lang_old_path}: {e}", file=sys.stderr) - raise - except Exception as e: - print(f"Error: An unexpected error occurred while renaming {lang_old_path}: {e}", - file=sys.stderr) - raise - + parent = modules_dir / I18N_MODULE_NAME / I18N_MODULE_NAME + if not parent.exists(): + return + + for lang_dir in os.listdir(parent): + if '_' in lang_dir and lang_dir.endswith('.lproj'): + old_path = parent / lang_dir + new_name = lang_dir.replace('_', '-') + new_path = parent / new_name + os.rename(old_path, new_path) + print(f"Renamed locale folder: {lang_dir} -> {new_name}") except Exception as e: - print(f"Error: An unexpected error occurred in rename_translations_files: {e}", file=sys.stderr) + print(f"Error replacing underscores: {e}", file=sys.stderr) raise def main(): + """ + Main entry point for the script. + """ args = parse_arguments() if args.split: if args.replace_underscore: