mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 12:07:55 +00:00
Localizations Setup (#6)
* Move assets into ElementX folder * Add first version of localizer script * Add generated strings & tests & fallback mechanism * Rename strings file to Localizable * Rename Assets to Resources * Calculate preferred languages only when needed, remove share extension check * Add comments in the localizer script * Add GH workflow to push issues to the [ElementX board](https://github.com/orgs/vector-im/projects/43) * Closes #16 - Add license file * New version of localizer script, handle pluralization * Move assets into ElementX folder * Add first version of localizer script * Add generated strings & tests & fallback mechanism * Rename strings file to Localizable * Rename Assets to Resources * Calculate preferred languages only when needed, remove share extension check * Add comments in the localizer script * New version of localizer script, handle pluralization * Revert login button text * Add multiple dialect pluralization, fix string formatting Co-authored-by: manuroe <manu@matrix.org> Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
+10
-2
@@ -5,5 +5,13 @@ Generates all app icon variants from one single .pdf file.
|
||||
|
||||
Usage:
|
||||
```
|
||||
sh iconizer.sh ../ElementX/Supporting\ Files/AppIcon.pdf ../ElementX/Supporting\ Files
|
||||
```
|
||||
sh iconizer.sh ../ElementX/Supporting\ Files/AppIcon.pdf ../ElementX/Supporting\ Files
|
||||
```
|
||||
|
||||
## Localizer
|
||||
Generates all app localization files and imports them to the project, by downloading strings from [element-android](https://github.com/vector-im/element-android/tree/develop/vector/src/main/res) and converting them to `strings` and `stringsdict` files.
|
||||
|
||||
Usage:
|
||||
```
|
||||
./localizer.py
|
||||
```
|
||||
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
rm -rf element-android
|
||||
|
||||
git clone \
|
||||
--depth 1 \
|
||||
--filter=blob:none \
|
||||
--no-checkout \
|
||||
https://github.com/vector-im/element-android \
|
||||
;
|
||||
cd element-android
|
||||
git checkout develop
|
||||
git sparse-checkout set vector/src/main/res/values*
|
||||
cd ..
|
||||
Executable
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/python2.7
|
||||
|
||||
import sys
|
||||
import os
|
||||
import errno
|
||||
from imp import reload
|
||||
from xml.etree import cElementTree as ET
|
||||
from xml.etree.ElementTree import XMLParser
|
||||
|
||||
encoding = 'utf-8'
|
||||
if sys.version_info.major < 3:
|
||||
reload(sys)
|
||||
sys.setdefaultencoding(encoding)
|
||||
|
||||
# normalize given language code to iOS language code format:
|
||||
# https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPInternational/LanguageandLocaleIDs/LanguageandLocaleIDs.html
|
||||
def normalize_language_code(code):
|
||||
mappings = {
|
||||
'b+sr+Latn': 'sr-Latn'
|
||||
}
|
||||
# codes cannot be converted: 'vls', 'ang', 'szl', 'tzm'
|
||||
# return from mappings if exists
|
||||
if code in mappings:
|
||||
return mappings[code]
|
||||
# try to strip additional 'r' from the code
|
||||
r_tag = code.find('-r')
|
||||
if len(code) == 6 and r_tag != -1:
|
||||
result = code.replace('-r', '-')
|
||||
return result
|
||||
# return the original
|
||||
return code
|
||||
|
||||
# normalize given string by handling escapes and Android -> iOS format specifiers
|
||||
def normalize_str(e):
|
||||
unique_tmp_val = '___'
|
||||
quote = '"'
|
||||
escaping_quote = '\\"'
|
||||
result = str(e).encode(encoding).strip() # convert input to a encoded string object and strip
|
||||
result = result.replace(escaping_quote, unique_tmp_val) # replace escaping quotes with a temp value
|
||||
result = result.replace(quote, '') # remove all quotes
|
||||
result = result.replace(unique_tmp_val, escaping_quote) # revert temp values to escaping quotes back
|
||||
result = result.replace('%s', '%@') # replace C-style specifiers
|
||||
for i in range(1, 6):
|
||||
result = result.replace('%' + str(i) + '$s', '%' + str(i) + '$@') # replace C-style indexed specifiers
|
||||
result = result.replace('${app_name}', '%@') # replace JSON-style specifiers
|
||||
result = result.replace('$ {app_name}', '%@') # replace JSON-style specifiers
|
||||
result = result.replace('&', '&') # replace ampersand
|
||||
result = result.replace('<', '<') # replace less than
|
||||
result = result.replace('>', '>') # replace greater than
|
||||
result = result.replace('\n', '')
|
||||
return result
|
||||
|
||||
def create_directories_for(output):
|
||||
if not os.path.exists(os.path.dirname(output)):
|
||||
try:
|
||||
os.makedirs(os.path.dirname(output))
|
||||
except OSError as exc:
|
||||
if exc.errno != errno.EEXIST:
|
||||
raise
|
||||
|
||||
def convert_file(input, output):
|
||||
create_directories_for(output)
|
||||
f_strings = open(output, 'w')
|
||||
|
||||
# parse input XML into root
|
||||
root = ET.parse(input, parser=XMLParser(encoding=encoding)).getroot()
|
||||
|
||||
for node in root.findall('string'):
|
||||
key = node.get('name')
|
||||
value = ET.tostring(node, encoding=encoding)
|
||||
value = value.replace('</string>', '')
|
||||
end_of_string_tag = value.find('>')
|
||||
if end_of_string_tag != -1:
|
||||
value = value[end_of_string_tag+1:]
|
||||
f_strings.write('"' + normalize_str(key) + '" = "' + normalize_str(value) + '";' + '\n')
|
||||
|
||||
f_strings.close()
|
||||
|
||||
# parse plurals and build stringsdict plist
|
||||
strings_plist = ET.Element('plist')
|
||||
strings_plist.set('version', '1.0')
|
||||
stringsDict = ET.SubElement(strings_plist, 'dict')
|
||||
for node in root.findall('plurals'):
|
||||
top_key = ET.SubElement(stringsDict, 'key')
|
||||
top_key.text = node.get('name')
|
||||
top_dict = ET.SubElement(stringsDict, 'dict')
|
||||
format_key = ET.Element('key')
|
||||
format_key.text = 'NSStringLocalizedFormatKey'
|
||||
top_dict.append(format_key)
|
||||
inner_string = ET.Element('string')
|
||||
inner_string.text = '%#@VARIABLE@'
|
||||
top_dict.append(inner_string)
|
||||
inner_key2 = ET.Element('key')
|
||||
inner_key2.text = 'VARIABLE'
|
||||
top_dict.append(inner_key2)
|
||||
inner_dict = ET.Element('dict')
|
||||
spec_type_key = ET.SubElement(inner_dict, 'key')
|
||||
spec_type_key.text = 'NSStringFormatSpecTypeKey'
|
||||
rule_type_key = ET.SubElement(inner_dict, 'string')
|
||||
rule_type_key.text = 'NSStringPluralRuleType'
|
||||
value_type_key = ET.SubElement(inner_dict, 'key')
|
||||
value_type_key.text = 'NSStringFormatValueTypeKey'
|
||||
value_type_value_string = ET.SubElement(inner_dict, 'string')
|
||||
value_type_value_string.text = 'd'
|
||||
for item in node.findall('item'):
|
||||
if item.text is not None and item.text != '':
|
||||
value = item.text
|
||||
quantity = item.get('quantity')
|
||||
key = ET.SubElement(inner_dict, 'key')
|
||||
key.text = normalize_str(quantity)
|
||||
string = ET.SubElement(inner_dict, 'string')
|
||||
string.text = normalize_str(value)
|
||||
top_dict.append(inner_dict)
|
||||
|
||||
# write to stringsdict file
|
||||
if stringsDict.find('key') is not None:
|
||||
output_dict = str(output) + 'dict'
|
||||
tree = ET.ElementTree(element=strings_plist)
|
||||
tree.write(file_or_filename=output_dict, encoding=encoding, xml_declaration=False)
|
||||
os.system('plutil -convert xml1 ' + output_dict)
|
||||
|
||||
# os.system('./fetch_android_strings.sh')
|
||||
|
||||
print('\nAndroid strings fetched.\n')
|
||||
|
||||
res_folder = 'element-android/vector/src/main/res'
|
||||
|
||||
for subdir, dirs, files in os.walk(res_folder):
|
||||
for dir in dirs:
|
||||
lang_code = dir
|
||||
lang_code = lang_code.replace('values-', '')
|
||||
if lang_code == 'values':
|
||||
lang_code = 'en'
|
||||
input = os.path.join(os.path.join(res_folder, dir), 'strings.xml')
|
||||
output = '../../ElementX/Resources/Localizations/' + normalize_language_code(lang_code) + '.lproj/Localizable.strings'
|
||||
input = os.path.realpath(input)
|
||||
output = os.path.realpath(output)
|
||||
print('--- Processing ' + lang_code + ' as: ' + normalize_language_code(lang_code))
|
||||
convert_file(input, output)
|
||||
|
||||
print('')
|
||||
os.chdir('../..')
|
||||
os.system('xcodegen')
|
||||
@@ -0,0 +1,101 @@
|
||||
// swiftlint:disable all
|
||||
// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen
|
||||
|
||||
{% if tables.count > 0 %}
|
||||
{% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %}
|
||||
import Foundation
|
||||
|
||||
// swiftlint:disable superfluous_disable_command file_length implicit_return
|
||||
|
||||
// MARK: - Strings
|
||||
|
||||
{% macro parametersBlock types %}{% filter removeNewlines:"leading" %}
|
||||
{% for type in types %}
|
||||
{% if type == "String" %}
|
||||
_ p{{forloop.counter}}: Any
|
||||
{% else %}
|
||||
_ p{{forloop.counter}}: {{type}}
|
||||
{% endif %}
|
||||
{{ ", " if not forloop.last }}
|
||||
{% endfor %}
|
||||
{% endfilter %}{% endmacro %}
|
||||
{% macro argumentsBlock types %}{% filter removeNewlines:"leading" %}
|
||||
{% for type in types %}
|
||||
{% if type == "String" %}
|
||||
String(describing: p{{forloop.counter}})
|
||||
{% elif type == "UnsafeRawPointer" %}
|
||||
Int(bitPattern: p{{forloop.counter}})
|
||||
{% else %}
|
||||
p{{forloop.counter}}
|
||||
{% endif %}
|
||||
{{ ", " if not forloop.last }}
|
||||
{% endfor %}
|
||||
{% endfilter %}{% endmacro %}
|
||||
{% macro recursiveBlock table item %}
|
||||
{% for string in item.strings %}
|
||||
{% if not param.noComments %}
|
||||
{% for line in string.translation|split:"\n" %}
|
||||
/// {{line}}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if string.types %}
|
||||
{{accessModifier}} static func {{string.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}({% call parametersBlock string.types %}) -> String {
|
||||
return {{enumName}}.tr("{{table}}", "{{string.key}}", {% call argumentsBlock string.types %})
|
||||
}
|
||||
{% elif param.lookupFunction %}
|
||||
{# custom localization function is mostly used for in-app lang selection, so we want the loc to be recomputed at each call for those (hence the computed var) #}
|
||||
{{accessModifier}} static var {{string.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}: String { return {{enumName}}.tr("{{table}}", "{{string.key}}") }
|
||||
{% else %}
|
||||
{{accessModifier}} static var {{string.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = {{enumName}}.tr("{{table}}", "{{string.key}}")
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for child in item.children %}
|
||||
|
||||
{{accessModifier}} enum {{child.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} {
|
||||
{% filter indent:2 %}{% call recursiveBlock table child %}{% endfilter %}
|
||||
}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
{% set enumName %}{{param.enumName|default:"L10n"}}{% endset %}
|
||||
{{accessModifier}} enum {{enumName}} {
|
||||
{% if tables.count > 1 or param.forceFileNameEnum %}
|
||||
{% for table in tables %}
|
||||
{% filter indent:2 %}{% call recursiveBlock table.name table.levels %}{% endfilter %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% call recursiveBlock tables.first.name tables.first.levels %}
|
||||
{% endif %}
|
||||
}
|
||||
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
|
||||
// MARK: - Implementation Details
|
||||
|
||||
extension {{enumName}} {
|
||||
static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String {
|
||||
let languages = Bundle.preferredLanguages
|
||||
|
||||
for language in languages {
|
||||
let translation = trIn(language, table, key, args)
|
||||
if translation != key {
|
||||
return translation
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
private static func trIn(_ language: String, _ table: String, _ key: String, _ args: CVarArg...) -> String {
|
||||
guard let bundle = Bundle.lprojBundle(for: language) else {
|
||||
// no translations for the desired language
|
||||
return key
|
||||
}
|
||||
let format = NSLocalizedString(key, tableName: table, bundle: bundle, comment: "")
|
||||
return String(format: format, locale: Locale(identifier: language), arguments: args)
|
||||
}
|
||||
}
|
||||
|
||||
{% else %}
|
||||
// No string found
|
||||
{% endif %}
|
||||
@@ -0,0 +1,75 @@
|
||||
// swiftlint:disable all
|
||||
// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen
|
||||
|
||||
{% if tables.count > 0 %}
|
||||
{% set accessModifier %}{% if param.publicAccess %}public{% else %}internal{% endif %}{% endset %}
|
||||
import Foundation
|
||||
|
||||
// swiftlint:disable superfluous_disable_command file_length implicit_return
|
||||
|
||||
// MARK: - Strings
|
||||
|
||||
{% macro parametersBlock types %}{% filter removeNewlines:"leading" %}
|
||||
{% for type in types %}
|
||||
{% if type == "String" %}
|
||||
_ p{{forloop.counter}}: Any
|
||||
{% else %}
|
||||
_ p{{forloop.counter}}: {{type}}
|
||||
{% endif %}
|
||||
{{ ", " if not forloop.last }}
|
||||
{% endfor %}
|
||||
{% endfilter %}{% endmacro %}
|
||||
{% macro argumentsBlock types %}{% filter removeNewlines:"leading" %}
|
||||
{% for type in types %}
|
||||
{% if type == "String" %}
|
||||
String(describing: p{{forloop.counter}})
|
||||
{% elif type == "UnsafeRawPointer" %}
|
||||
Int(bitPattern: p{{forloop.counter}})
|
||||
{% else %}
|
||||
p{{forloop.counter}}
|
||||
{% endif %}
|
||||
{{ ", " if not forloop.last }}
|
||||
{% endfor %}
|
||||
{% endfilter %}{% endmacro %}
|
||||
{% macro recursiveBlock table item %}
|
||||
{% for string in item.strings %}
|
||||
{% if not param.noComments %}
|
||||
{% for line in string.translation|split:"\n" %}
|
||||
/// {{line}}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if string.types %}
|
||||
{{accessModifier}} static func {{string.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}({% call parametersBlock string.types %}) -> String {
|
||||
return {{enumName}}.tr("{{table}}", "{{string.key}}", {% call argumentsBlock string.types %})
|
||||
}
|
||||
{% elif param.lookupFunction %}
|
||||
{# custom localization function is mostly used for in-app lang selection, so we want the loc to be recomputed at each call for those (hence the computed var) #}
|
||||
{{accessModifier}} static var {{string.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}}: String { return {{enumName}}.tr("{{table}}", "{{string.key}}") }
|
||||
{% else %}
|
||||
{{accessModifier}} static let {{string.name|swiftIdentifier:"pretty"|lowerFirstWord|escapeReservedKeywords}} = {{enumName}}.tr("{{table}}", "{{string.key}}")
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% for child in item.children %}
|
||||
|
||||
{{accessModifier}} enum {{child.name|swiftIdentifier:"pretty"|escapeReservedKeywords}} {
|
||||
{% filter indent:2 %}{% call recursiveBlock table child %}{% endfilter %}
|
||||
}
|
||||
{% endfor %}
|
||||
{% endmacro %}
|
||||
// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
{% set enumName %}{{param.enumName|default:"L10n"}}{% endset %}
|
||||
extension {{enumName}} {
|
||||
{% if tables.count > 1 or param.forceFileNameEnum %}
|
||||
{% for table in tables %}
|
||||
{% filter indent:2 %}{% call recursiveBlock table.name table.levels %}{% endfilter %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% call recursiveBlock tables.first.name tables.first.levels %}
|
||||
{% endif %}
|
||||
}
|
||||
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
|
||||
// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces
|
||||
{% else %}
|
||||
// No string found
|
||||
{% endif %}
|
||||
@@ -1,8 +1,26 @@
|
||||
input_dir: ../../ElementX/
|
||||
output_dir: ../../ElementX/Sources/Generated/
|
||||
xcassets:
|
||||
xcassets:
|
||||
inputs:
|
||||
- SupportingFiles/Assets.xcassets
|
||||
- Resources/Assets.xcassets
|
||||
outputs:
|
||||
templateName: swift5
|
||||
output: Assets.swift
|
||||
strings:
|
||||
- inputs: Resources/Localizations/en.lproj
|
||||
filter: Localizable*
|
||||
outputs:
|
||||
templatePath: Templates/Strings/structured-swift5-element.stencil
|
||||
output: Strings.swift
|
||||
params:
|
||||
enumName: ElementL10n
|
||||
lookupFunction: tr # used for the loc to be computed variables
|
||||
publicAccess: true
|
||||
- inputs: Resources/Localizations/en.lproj
|
||||
filter: Untranslated*
|
||||
outputs:
|
||||
templatePath: Templates/Strings/structured-swift5-untranslated.stencil
|
||||
output: Strings+Untranslated.swift
|
||||
params:
|
||||
enumName: ElementL10n
|
||||
publicAccess: true
|
||||
|
||||
Reference in New Issue
Block a user