From 361869b816ed2a57214e4452667eebca02aa8bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sta=C5=9B=20Ma=C5=82olepszy?= Date: Mon, 8 May 2017 17:43:44 +0200 Subject: [PATCH 1/4] FluentParser and FluentSerializer --- fluent/syntax/__init__.py | 12 ++++++++ fluent/syntax/parser.py | 36 +++++++++++------------ fluent/syntax/serializer.py | 51 +++++++++++++++++---------------- tests/syntax/test_ast_json.py | 11 ++++--- tests/syntax/test_entry.py | 14 ++++++--- tests/syntax/test_serializer.py | 3 +- tools/fluentfmt.py | 7 ++--- tools/parse.py | 4 +-- tools/serialize.py | 7 ++--- 9 files changed, 83 insertions(+), 62 deletions(-) diff --git a/fluent/syntax/__init__.py b/fluent/syntax/__init__.py index e69de29b..a33b858e 100644 --- a/fluent/syntax/__init__.py +++ b/fluent/syntax/__init__.py @@ -0,0 +1,12 @@ +from .parser import FluentParser +from .serializer import FluentSerializer + + +def parse(source, **kwargs): + parser = FluentParser(**kwargs) + return parser.parse(source) + + +def serialize(resource, **kwargs): + serializer = FluentSerializer(**kwargs) + return serializer.serialize(resource) diff --git a/fluent/syntax/parser.py b/fluent/syntax/parser.py index 5a293eea..1d52e815 100644 --- a/fluent/syntax/parser.py +++ b/fluent/syntax/parser.py @@ -4,31 +4,31 @@ from .errors import ParseError -def parse(source): - comment = None - - ps = FTLParserStream(source) - ps.skip_ws_lines() +class FluentParser(object): + def parse(self, source): + comment = None - entries = [] + ps = FTLParserStream(source) + ps.skip_ws_lines() - while ps.current(): - entry = get_entry_or_junk(ps) + entries = [] - if isinstance(entry, ast.Comment) and len(entries) == 0: - comment = entry - else: - entries.append(entry) + while ps.current(): + entry = get_entry_or_junk(ps) - ps.skip_ws_lines() + if isinstance(entry, ast.Comment) and len(entries) == 0: + comment = entry + else: + entries.append(entry) - return ast.Resource(entries, comment) + ps.skip_ws_lines() + return ast.Resource(entries, comment) -def parse_entry(source): - ps = FTLParserStream(source) - ps.skip_ws_lines() - return get_entry_or_junk(ps) + def parse_entry(self, source): + ps = FTLParserStream(source) + ps.skip_ws_lines() + return get_entry_or_junk(ps) def get_entry_or_junk(ps): diff --git a/fluent/syntax/serializer.py b/fluent/syntax/serializer.py index 9f2df912..c3992787 100644 --- a/fluent/syntax/serializer.py +++ b/fluent/syntax/serializer.py @@ -15,31 +15,34 @@ def contain_new_line(elems): ]) -def serialize(resource, with_junk=False): - parts = [] - if resource.comment: - parts.append( - "{}\n\n".format( - serialize_comment(resource.comment) +class FluentSerializer(object): + def __init__(self, with_junk=False): + self.with_junk = with_junk + + def serialize(self, resource): + parts = [] + if resource.comment: + parts.append( + "{}\n\n".format( + serialize_comment(resource.comment) + ) ) - ) - for entry in resource.body: - if not isinstance(entry, ast.Junk) or with_junk: - parts.append(serialize_entry(entry)) - - return "".join(parts) - - -def serialize_entry(entry): - if isinstance(entry, ast.Message): - return serialize_message(entry) - if isinstance(entry, ast.Section): - return serialize_section(entry) - if isinstance(entry, ast.Comment): - return serialize_comment(entry) - if isinstance(entry, ast.Junk): - return serialize_junk(entry) - raise Exception('Unknown entry type: {}'.format(entry.type)) + for entry in resource.body: + if not isinstance(entry, ast.Junk) or self.with_junk: + parts.append(self.serialize_entry(entry)) + + return "".join(parts) + + def serialize_entry(self, entry): + if isinstance(entry, ast.Message): + return serialize_message(entry) + if isinstance(entry, ast.Section): + return serialize_section(entry) + if isinstance(entry, ast.Comment): + return serialize_comment(entry) + if isinstance(entry, ast.Junk): + return serialize_junk(entry) + raise Exception('Unknown entry type: {}'.format(entry.type)) def serialize_comment(comment): diff --git a/tests/syntax/test_ast_json.py b/tests/syntax/test_ast_json.py index 57f7c9ce..970f8bcd 100644 --- a/tests/syntax/test_ast_json.py +++ b/tests/syntax/test_ast_json.py @@ -6,16 +6,19 @@ from tests.syntax import dedent_ftl from fluent.syntax.ast import from_json -from fluent.syntax.parser import parse +from fluent.syntax.parser import FluentParser class TestASTJSON(unittest.TestCase): + def setUp(self): + self.parser = FluentParser() + def test_simple_resource(self): input = """\ foo = Foo """ - ast1 = parse(dedent_ftl(input)) + ast1 = self.parser.parse(dedent_ftl(input)) json1 = ast1.to_json() ast2 = from_json(json1) json2 = ast2.to_json() @@ -47,7 +50,7 @@ def test_complex_resource(self): } post. """ - ast1 = parse(dedent_ftl(input)) + ast1 = self.parser.parse(dedent_ftl(input)) json1 = ast1.to_json() ast2 = from_json(json1) json2 = ast2.to_json() @@ -59,7 +62,7 @@ def test_syntax_error(self): foo = Foo { """ - ast1 = parse(dedent_ftl(input)) + ast1 = self.parser.parse(dedent_ftl(input)) json1 = ast1.to_json() ast2 = from_json(json1) json2 = ast2.to_json() diff --git a/tests/syntax/test_entry.py b/tests/syntax/test_entry.py index 8f3ae8eb..8df6bdb7 100644 --- a/tests/syntax/test_entry.py +++ b/tests/syntax/test_entry.py @@ -6,11 +6,14 @@ from tests.syntax import dedent_ftl from fluent.syntax.ast import from_json -from fluent.syntax.parser import parse_entry -from fluent.syntax.serializer import serialize_entry +from fluent.syntax.parser import FluentParser +from fluent.syntax.serializer import FluentSerializer class TestParseEntry(unittest.TestCase): + def setUp(self): + self.parser = FluentParser() + def test_simple_message(self): input = """\ foo = Foo @@ -41,11 +44,14 @@ def test_simple_message(self): } } - message = parse_entry(dedent_ftl(input)) + message = self.parser.parse_entry(dedent_ftl(input)) self.assertEqual(message.to_json(), output) class TestSerializeEntry(unittest.TestCase): + def setUp(self): + self.serializer = FluentSerializer() + def test_simple_message(self): input = { "comment": None, @@ -76,5 +82,5 @@ def test_simple_message(self): foo = Foo """ - message = serialize_entry(from_json(input)) + message = self.serializer.serialize_entry(from_json(input)) self.assertEqual(message, dedent_ftl(output)) diff --git a/tests/syntax/test_serializer.py b/tests/syntax/test_serializer.py index 51874133..32d82206 100644 --- a/tests/syntax/test_serializer.py +++ b/tests/syntax/test_serializer.py @@ -5,8 +5,7 @@ sys.path.append('.') from tests.syntax import dedent_ftl -from fluent.syntax.parser import parse -from fluent.syntax.serializer import serialize +from fluent.syntax import parse, serialize def pretty_ftl(text): diff --git a/tools/fluentfmt.py b/tools/fluentfmt.py index 88f33d4f..d22d7896 100755 --- a/tools/fluentfmt.py +++ b/tools/fluentfmt.py @@ -4,8 +4,7 @@ sys.path.append('./') import codecs -import fluent.syntax.parser -import fluent.syntax.serializer +from fluent.syntax import parse, serialize def read_file(path): @@ -15,8 +14,8 @@ def read_file(path): def pretty_print(fileType, data): - ast = fluent.syntax.parser.parse(data) - print(fluent.syntax.serializer.serialize(ast)) + ast = parse(data) + print(serialize(ast)) if __name__ == "__main__": file_type = 'ftl' diff --git a/tools/parse.py b/tools/parse.py index 4af0350f..8e026c2b 100755 --- a/tools/parse.py +++ b/tools/parse.py @@ -4,7 +4,7 @@ sys.path.append('./') import codecs -import fluent.syntax.parser +from fluent.syntax import parse import json @@ -15,7 +15,7 @@ def read_file(path): def print_ast(fileType, data): - ast = fluent.syntax.parser.parse(data) + ast = parse(data) print(json.dumps(ast.to_json(), indent=2, ensure_ascii=False)) diff --git a/tools/serialize.py b/tools/serialize.py index 2abcd53c..69b75575 100755 --- a/tools/serialize.py +++ b/tools/serialize.py @@ -5,8 +5,7 @@ sys.path.append('./') import codecs -import fluent.syntax.ast -import fluent.syntax.serializer +from fluent.syntax import ast, serialize def read_json(path): @@ -15,8 +14,8 @@ def read_json(path): def pretty_print(fileType, data): - ast = fluent.syntax.ast.from_json(data) - print(fluent.syntax.serializer.serialize(ast)) + resource = ast.from_json(data) + print(serialize(resource)) if __name__ == "__main__": file_type = 'ftl' From d9bd22a966784ca4dd5ecc39839f37fd1b3b0fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sta=C5=9B=20Ma=C5=82olepszy?= Date: Tue, 9 May 2017 17:22:23 +0200 Subject: [PATCH 2/4] Add with_spans and with_annotations Parser options --- fluent/syntax/parser.py | 25 ++++++++++++++++--------- tests/syntax/test_behavior.py | 2 +- tests/syntax/test_structure.py | 2 +- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/fluent/syntax/parser.py b/fluent/syntax/parser.py index 1d52e815..d9928849 100644 --- a/fluent/syntax/parser.py +++ b/fluent/syntax/parser.py @@ -5,6 +5,10 @@ class FluentParser(object): + def __init__(self, with_spans=True, with_annotations=True): + self.with_spans = with_spans + self.with_annotations = with_annotations + def parse(self, source): comment = None @@ -14,7 +18,7 @@ def parse(self, source): entries = [] while ps.current(): - entry = get_entry_or_junk(ps) + entry = get_entry_or_junk(self, ps) if isinstance(entry, ast.Comment) and len(entries) == 0: comment = entry @@ -28,28 +32,31 @@ def parse(self, source): def parse_entry(self, source): ps = FTLParserStream(source) ps.skip_ws_lines() - return get_entry_or_junk(ps) + return get_entry_or_junk(self, ps) -def get_entry_or_junk(ps): +def get_entry_or_junk(self, ps): entry_start_pos = ps.get_index() try: entry = get_entry(ps) - entry.add_span(entry_start_pos, ps.get_index()) + if self.with_spans: + entry.add_span(entry_start_pos, ps.get_index()) return entry except ParseError as err: - annot = ast.Annotation(err.code, err.args, err.message) - annot.add_span(ps.get_index(), ps.get_index()) - + error_index = ps.get_index() ps.skip_to_next_entry_start() next_entry_start = ps.get_index() # Create a Junk instance slice = ps.get_slice(entry_start_pos, next_entry_start) junk = ast.Junk(slice) - junk.add_span(entry_start_pos, next_entry_start) - junk.add_annotation(annot) + if self.with_spans: + junk.add_span(entry_start_pos, next_entry_start) + if self.with_annotations: + annot = ast.Annotation(err.code, err.args, err.message) + annot.add_span(error_index, error_index) + junk.add_annotation(annot) return junk diff --git a/tests/syntax/test_behavior.py b/tests/syntax/test_behavior.py index 49fa8705..58b5198c 100644 --- a/tests/syntax/test_behavior.py +++ b/tests/syntax/test_behavior.py @@ -7,7 +7,7 @@ sys.path.append('.') -from fluent.syntax.parser import parse +from fluent.syntax import parse sigil = r'^\/\/~ ' diff --git a/tests/syntax/test_structure.py b/tests/syntax/test_structure.py index af2f2f65..222da3d4 100644 --- a/tests/syntax/test_structure.py +++ b/tests/syntax/test_structure.py @@ -7,7 +7,7 @@ sys.path.append('.') -from fluent.syntax.parser import parse +from fluent.syntax import parse def read_file(path): From bf713da609c3942467b5ab7ea1fcc349fbafa757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sta=C5=9B=20Ma=C5=82olepszy?= Date: Tue, 9 May 2017 20:09:29 +0200 Subject: [PATCH 3/4] Initial rebase of the migration scripts from python-l20n --- fluent/migrate/__init__.py | 8 + fluent/migrate/changesets.py | 58 ++ fluent/migrate/cldr.py | 55 ++ fluent/migrate/cldr_data/plurals.json | 857 ++++++++++++++++++ fluent/migrate/cldr_data/unicode-license.txt | 53 ++ fluent/migrate/context.py | 266 ++++++ fluent/migrate/merge.py | 58 ++ fluent/migrate/transforms.py | 281 ++++++ fluent/migrate/util.py | 59 ++ fluent/util.py | 42 + tests/migrate/__init__.py | 0 tests/migrate/fixtures/en-US/aboutDialog.ftl | 10 + .../migrate/fixtures/en-US/aboutDownloads.ftl | 39 + tests/migrate/fixtures/en-US/toolbar.ftl | 24 + tests/migrate/fixtures/pl/aboutDialog.dtd | 16 + tests/migrate/fixtures/pl/aboutDownloads.dtd | 15 + .../fixtures/pl/aboutDownloads.properties | 12 + tests/migrate/test_changesets.py | 50 + tests/migrate/test_cldr.py | 37 + tests/migrate/test_concat.py | 230 +++++ tests/migrate/test_context.py | 309 +++++++ tests/migrate/test_context_real_examples.py | 391 ++++++++ tests/migrate/test_literal.py | 137 +++ tests/migrate/test_merge.py | 470 ++++++++++ tests/migrate/test_plural.py | 121 +++ tests/migrate/test_replace.py | 168 ++++ tests/migrate/test_util.py | 83 ++ tools/migrate/about_dialog.py | 86 ++ tools/migrate/about_downloads.py | 190 ++++ tools/migrate/blame.py | 52 ++ tools/migrate/brand.ftl | 13 + tools/migrate/menubar.ftl | 336 +++++++ tools/migrate/migrate-l10n.py | 112 +++ tools/migrate/toolbar.ftl | 24 + 34 files changed, 4662 insertions(+) create mode 100644 fluent/migrate/__init__.py create mode 100644 fluent/migrate/changesets.py create mode 100644 fluent/migrate/cldr.py create mode 100644 fluent/migrate/cldr_data/plurals.json create mode 100644 fluent/migrate/cldr_data/unicode-license.txt create mode 100644 fluent/migrate/context.py create mode 100644 fluent/migrate/merge.py create mode 100644 fluent/migrate/transforms.py create mode 100644 fluent/migrate/util.py create mode 100644 fluent/util.py create mode 100644 tests/migrate/__init__.py create mode 100644 tests/migrate/fixtures/en-US/aboutDialog.ftl create mode 100644 tests/migrate/fixtures/en-US/aboutDownloads.ftl create mode 100644 tests/migrate/fixtures/en-US/toolbar.ftl create mode 100644 tests/migrate/fixtures/pl/aboutDialog.dtd create mode 100644 tests/migrate/fixtures/pl/aboutDownloads.dtd create mode 100644 tests/migrate/fixtures/pl/aboutDownloads.properties create mode 100644 tests/migrate/test_changesets.py create mode 100644 tests/migrate/test_cldr.py create mode 100644 tests/migrate/test_concat.py create mode 100644 tests/migrate/test_context.py create mode 100644 tests/migrate/test_context_real_examples.py create mode 100644 tests/migrate/test_literal.py create mode 100644 tests/migrate/test_merge.py create mode 100644 tests/migrate/test_plural.py create mode 100644 tests/migrate/test_replace.py create mode 100644 tests/migrate/test_util.py create mode 100644 tools/migrate/about_dialog.py create mode 100644 tools/migrate/about_downloads.py create mode 100644 tools/migrate/blame.py create mode 100644 tools/migrate/brand.ftl create mode 100644 tools/migrate/menubar.ftl create mode 100755 tools/migrate/migrate-l10n.py create mode 100644 tools/migrate/toolbar.ftl diff --git a/fluent/migrate/__init__.py b/fluent/migrate/__init__.py new file mode 100644 index 00000000..d2951eff --- /dev/null +++ b/fluent/migrate/__init__.py @@ -0,0 +1,8 @@ +# coding=utf8 + +from .context import MergeContext # noqa: F401 +from .transforms import ( # noqa: F401 + CONCAT, EXTERNAL, LITERAL, LITERAL_FROM, PLURALS, PLURALS_FROM, REPLACE, + REPLACE_FROM, SOURCE +) +from .changesets import convert_blame_to_changesets # noqa: F401 diff --git a/fluent/migrate/changesets.py b/fluent/migrate/changesets.py new file mode 100644 index 00000000..d0fd396f --- /dev/null +++ b/fluent/migrate/changesets.py @@ -0,0 +1,58 @@ +# coding=utf8 + +import time + + +def by_first_commit(item): + """Order two changesets by their first commit date.""" + return item['first_commit'] + + +def convert_blame_to_changesets(blame_json): + """Convert a blame dict into a list of changesets. + + The blame information in `blame_json` should be a dict of the following + structure: + + { + 'authors': [ + 'A.N. Author ', + ], + 'blame': { + 'path/one': { + 'key1': [0, 1346095921.0], + }, + } + } + + It will be transformed into a list of changesets which can be fed into + `MergeContext.serialize_changeset`: + + [ + { + 'author': 'A.N. Author ', + 'first_commit': 1346095921.0, + 'changes': { + ('path/one', 'key1'), + } + }, + ] + + """ + now = time.time() + changesets = [ + { + 'author': author, + 'first_commit': now, + 'changes': set() + } for author in blame_json['authors'] + ] + + for path, keys_info in blame_json['blame'].items(): + for key, (author_index, timestamp) in keys_info.items(): + changeset = changesets[author_index] + changeset['changes'].add((path, key)) + if timestamp < changeset['first_commit']: + changeset['first_commit'] = timestamp + + return sorted(changesets, key=by_first_commit) diff --git a/fluent/migrate/cldr.py b/fluent/migrate/cldr.py new file mode 100644 index 00000000..86822d88 --- /dev/null +++ b/fluent/migrate/cldr.py @@ -0,0 +1,55 @@ +# coding=utf8 + +import pkgutil +import json + + +def in_canonical_order(item): + return canonical_order.index(item) + + +cldr_plurals = json.loads( + pkgutil.get_data('fluent.migrate', 'cldr_data/plurals.json').decode('utf-8') +) + +rules = cldr_plurals['supplemental']['plurals-type-cardinal'] +canonical_order = ('zero', 'one', 'two', 'few', 'many', 'other') + +categories = {} +for lang, rules in rules.items(): + categories[lang] = tuple(sorted(map( + lambda key: key.replace('pluralRule-count-', ''), + rules.keys() + ), key=in_canonical_order)) + + +def get_plural_categories(lang): + """Return a tuple of CLDR plural categories for `lang`. + + If an exact match for `lang` is not available, recursively fall back to + a language code with the last subtag stripped. That is, if `ja-JP-mac` is + not defined in CLDR, the code will try `ja-JP` and then `ja`. + + If no matches are found, a `RuntimeError` is raised. + + >>> get_plural_categories('sl') + ('one', 'two', 'few', 'other') + >>> get_plural_categories('ga-IE') + ('one', 'few', 'two', 'few', 'other') + >>> get_plural_categories('ja-JP-mac') + ('other') + + """ + + langs_categories = categories.get(lang, None) + + if langs_categories is None: + # Remove the trailing subtag. + fallback_lang, _, _ = lang.rpartition('-') + + if fallback_lang == '': + raise RuntimeError('Unknown language: {}'.format(lang)) + + return get_plural_categories(fallback_lang) + + return langs_categories diff --git a/fluent/migrate/cldr_data/plurals.json b/fluent/migrate/cldr_data/plurals.json new file mode 100644 index 00000000..da05933e --- /dev/null +++ b/fluent/migrate/cldr_data/plurals.json @@ -0,0 +1,857 @@ +{ + "supplemental": { + "version": { + "_number": "$Revision: 12805 $", + "_unicodeVersion": "9.0.0", + "_cldrVersion": "30" + }, + "plurals-type-cardinal": { + "af": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ak": { + "pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "am": { + "pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ar": { + "pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000", + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-few": "n % 100 = 3..10 @integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, …", + "pluralRule-count-many": "n % 100 = 11..99 @integer 11~26, 111, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, …", + "pluralRule-count-other": " @integer 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ars": { + "pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000", + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-few": "n % 100 = 3..10 @integer 3~10, 103~110, 1003, … @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 103.0, 1003.0, …", + "pluralRule-count-many": "n % 100 = 11..99 @integer 11~26, 111, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, …", + "pluralRule-count-other": " @integer 100~102, 200~202, 300~302, 400~402, 500~502, 600, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "as": { + "pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "asa": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ast": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "az": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "be": { + "pluralRule-count-one": "n % 10 = 1 and n % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, …", + "pluralRule-count-few": "n % 10 = 2..4 and n % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 2.0, 3.0, 4.0, 22.0, 23.0, 24.0, 32.0, 33.0, 102.0, 1002.0, …", + "pluralRule-count-many": "n % 10 = 0 or n % 10 = 5..9 or n % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …", + "pluralRule-count-other": " @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, …" + }, + "bem": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "bez": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "bg": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "bh": { + "pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "bm": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "bn": { + "pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "bo": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "br": { + "pluralRule-count-one": "n % 10 = 1 and n % 100 != 11,71,91 @integer 1, 21, 31, 41, 51, 61, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 81.0, 101.0, 1001.0, …", + "pluralRule-count-two": "n % 10 = 2 and n % 100 != 12,72,92 @integer 2, 22, 32, 42, 52, 62, 82, 102, 1002, … @decimal 2.0, 22.0, 32.0, 42.0, 52.0, 62.0, 82.0, 102.0, 1002.0, …", + "pluralRule-count-few": "n % 10 = 3..4,9 and n % 100 != 10..19,70..79,90..99 @integer 3, 4, 9, 23, 24, 29, 33, 34, 39, 43, 44, 49, 103, 1003, … @decimal 3.0, 4.0, 9.0, 23.0, 24.0, 29.0, 33.0, 34.0, 103.0, 1003.0, …", + "pluralRule-count-many": "n != 0 and n % 1000000 = 0 @integer 1000000, … @decimal 1000000.0, 1000000.00, 1000000.000, …", + "pluralRule-count-other": " @integer 0, 5~8, 10~20, 100, 1000, 10000, 100000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, …" + }, + "brx": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "bs": { + "pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …", + "pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …", + "pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ca": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ce": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "cgg": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "chr": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ckb": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "cs": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-few": "i = 2..4 and v = 0 @integer 2~4", + "pluralRule-count-many": "v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …", + "pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …" + }, + "cy": { + "pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000", + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-few": "n = 3 @integer 3 @decimal 3.0, 3.00, 3.000, 3.0000", + "pluralRule-count-many": "n = 6 @integer 6 @decimal 6.0, 6.00, 6.000, 6.0000", + "pluralRule-count-other": " @integer 4, 5, 7~20, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "da": { + "pluralRule-count-one": "n = 1 or t != 0 and i = 0,1 @integer 1 @decimal 0.1~1.6", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 2.0~3.4, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "de": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "dsb": { + "pluralRule-count-one": "v = 0 and i % 100 = 1 or f % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …", + "pluralRule-count-two": "v = 0 and i % 100 = 2 or f % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … @decimal 0.2, 1.2, 2.2, 3.2, 4.2, 5.2, 6.2, 7.2, 10.2, 100.2, 1000.2, …", + "pluralRule-count-few": "v = 0 and i % 100 = 3..4 or f % 100 = 3..4 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.3, 0.4, 1.3, 1.4, 2.3, 2.4, 3.3, 3.4, 4.3, 4.4, 5.3, 5.4, 6.3, 6.4, 7.3, 7.4, 10.3, 100.3, 1000.3, …", + "pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "dv": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "dz": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ee": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "el": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "en": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "eo": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "es": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "et": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "eu": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "fa": { + "pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ff": { + "pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "fi": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "fil": { + "pluralRule-count-one": "v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 @integer 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.3, 0.5, 0.7, 0.8, 1.0~1.3, 1.5, 1.7, 1.8, 2.0, 2.1, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …", + "pluralRule-count-other": " @integer 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, … @decimal 0.4, 0.6, 0.9, 1.4, 1.6, 1.9, 2.4, 2.6, 10.4, 100.4, 1000.4, …" + }, + "fo": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "fr": { + "pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "fur": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "fy": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ga": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-few": "n = 3..6 @integer 3~6 @decimal 3.0, 4.0, 5.0, 6.0, 3.00, 4.00, 5.00, 6.00, 3.000, 4.000, 5.000, 6.000, 3.0000, 4.0000, 5.0000, 6.0000", + "pluralRule-count-many": "n = 7..10 @integer 7~10 @decimal 7.0, 8.0, 9.0, 10.0, 7.00, 8.00, 9.00, 10.00, 7.000, 8.000, 9.000, 10.000, 7.0000, 8.0000, 9.0000, 10.0000", + "pluralRule-count-other": " @integer 0, 11~25, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "gd": { + "pluralRule-count-one": "n = 1,11 @integer 1, 11 @decimal 1.0, 11.0, 1.00, 11.00, 1.000, 11.000, 1.0000", + "pluralRule-count-two": "n = 2,12 @integer 2, 12 @decimal 2.0, 12.0, 2.00, 12.00, 2.000, 12.000, 2.0000", + "pluralRule-count-few": "n = 3..10,13..19 @integer 3~10, 13~19 @decimal 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 3.00", + "pluralRule-count-other": " @integer 0, 20~34, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "gl": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "gsw": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "gu": { + "pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "guw": { + "pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "gv": { + "pluralRule-count-one": "v = 0 and i % 10 = 1 @integer 1, 11, 21, 31, 41, 51, 61, 71, 101, 1001, …", + "pluralRule-count-two": "v = 0 and i % 10 = 2 @integer 2, 12, 22, 32, 42, 52, 62, 72, 102, 1002, …", + "pluralRule-count-few": "v = 0 and i % 100 = 0,20,40,60,80 @integer 0, 20, 40, 60, 80, 100, 120, 140, 1000, 10000, 100000, 1000000, …", + "pluralRule-count-many": "v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …", + "pluralRule-count-other": " @integer 3~10, 13~19, 23, 103, 1003, …" + }, + "ha": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "haw": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "he": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-two": "i = 2 and v = 0 @integer 2", + "pluralRule-count-many": "v = 0 and n != 0..10 and n % 10 = 0 @integer 20, 30, 40, 50, 60, 70, 80, 90, 100, 1000, 10000, 100000, 1000000, …", + "pluralRule-count-other": " @integer 0, 3~17, 101, 1001, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "hi": { + "pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "hr": { + "pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …", + "pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …", + "pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "hsb": { + "pluralRule-count-one": "v = 0 and i % 100 = 1 or f % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …", + "pluralRule-count-two": "v = 0 and i % 100 = 2 or f % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, … @decimal 0.2, 1.2, 2.2, 3.2, 4.2, 5.2, 6.2, 7.2, 10.2, 100.2, 1000.2, …", + "pluralRule-count-few": "v = 0 and i % 100 = 3..4 or f % 100 = 3..4 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.3, 0.4, 1.3, 1.4, 2.3, 2.4, 3.3, 3.4, 4.3, 4.4, 5.3, 5.4, 6.3, 6.4, 7.3, 7.4, 10.3, 100.3, 1000.3, …", + "pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "hu": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "hy": { + "pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "id": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ig": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ii": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "in": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "is": { + "pluralRule-count-one": "t = 0 and i % 10 = 1 and i % 100 != 11 or t != 0 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1~1.6, 10.1, 100.1, 1000.1, …", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "it": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "iu": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "iw": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-two": "i = 2 and v = 0 @integer 2", + "pluralRule-count-many": "v = 0 and n != 0..10 and n % 10 = 0 @integer 20, 30, 40, 50, 60, 70, 80, 90, 100, 1000, 10000, 100000, 1000000, …", + "pluralRule-count-other": " @integer 0, 3~17, 101, 1001, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ja": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "jbo": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "jgo": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ji": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "jmc": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "jv": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "jw": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ka": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "kab": { + "pluralRule-count-one": "i = 0,1 @integer 0, 1 @decimal 0.0~1.5", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "kaj": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "kcg": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "kde": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "kea": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "kk": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "kkj": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "kl": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "km": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "kn": { + "pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ko": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ks": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ksb": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ksh": { + "pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000", + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ku": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "kw": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ky": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "lag": { + "pluralRule-count-zero": "n = 0 @integer 0 @decimal 0.0, 0.00, 0.000, 0.0000", + "pluralRule-count-one": "i = 0,1 and n != 0 @integer 1 @decimal 0.1~1.6", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 2.0~3.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "lb": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "lg": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "lkt": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ln": { + "pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "lo": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "lt": { + "pluralRule-count-one": "n % 10 = 1 and n % 100 != 11..19 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 1.0, 21.0, 31.0, 41.0, 51.0, 61.0, 71.0, 81.0, 101.0, 1001.0, …", + "pluralRule-count-few": "n % 10 = 2..9 and n % 100 != 11..19 @integer 2~9, 22~29, 102, 1002, … @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 22.0, 102.0, 1002.0, …", + "pluralRule-count-many": "f != 0 @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.1, 1000.1, …", + "pluralRule-count-other": " @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "lv": { + "pluralRule-count-zero": "n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19 @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …", + "pluralRule-count-one": "n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …", + "pluralRule-count-other": " @integer 2~9, 22~29, 102, 1002, … @decimal 0.2~0.9, 1.2~1.9, 10.2, 100.2, 1000.2, …" + }, + "mas": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "mg": { + "pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "mgo": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "mk": { + "pluralRule-count-one": "v = 0 and i % 10 = 1 or f % 10 = 1 @integer 1, 11, 21, 31, 41, 51, 61, 71, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …", + "pluralRule-count-other": " @integer 0, 2~10, 12~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.2~1.0, 1.2~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ml": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "mn": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "mo": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-few": "v != 0 or n = 0 or n != 1 and n % 100 = 1..19 @integer 0, 2~16, 101, 1001, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …", + "pluralRule-count-other": " @integer 20~35, 100, 1000, 10000, 100000, 1000000, …" + }, + "mr": { + "pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ms": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "mt": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-few": "n = 0 or n % 100 = 2..10 @integer 0, 2~10, 102~107, 1002, … @decimal 0.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 10.0, 102.0, 1002.0, …", + "pluralRule-count-many": "n % 100 = 11..19 @integer 11~19, 111~117, 1011, … @decimal 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 111.0, 1011.0, …", + "pluralRule-count-other": " @integer 20~35, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "my": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "nah": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "naq": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "nb": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "nd": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ne": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "nl": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "nn": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "nnh": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "no": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "nqo": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "nr": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "nso": { + "pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ny": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "nyn": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "om": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "or": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "os": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "pa": { + "pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "pap": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "pl": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …", + "pluralRule-count-many": "v = 0 and i != 1 and i % 10 = 0..1 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 12..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …", + "pluralRule-count-other": " @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "prg": { + "pluralRule-count-zero": "n % 10 = 0 or n % 100 = 11..19 or v = 2 and f % 100 = 11..19 @integer 0, 10~20, 30, 40, 50, 60, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …", + "pluralRule-count-one": "n % 10 = 1 and n % 100 != 11 or v = 2 and f % 10 = 1 and f % 100 != 11 or v != 2 and f % 10 = 1 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.0, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …", + "pluralRule-count-other": " @integer 2~9, 22~29, 102, 1002, … @decimal 0.2~0.9, 1.2~1.9, 10.2, 100.2, 1000.2, …" + }, + "ps": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "pt": { + "pluralRule-count-one": "n = 0..2 and n != 2 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "pt-PT": { + "pluralRule-count-one": "n = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "rm": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ro": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-few": "v != 0 or n = 0 or n != 1 and n % 100 = 1..19 @integer 0, 2~16, 101, 1001, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …", + "pluralRule-count-other": " @integer 20~35, 100, 1000, 10000, 100000, 1000000, …" + }, + "rof": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "root": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ru": { + "pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + "pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …", + "pluralRule-count-many": "v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …", + "pluralRule-count-other": " @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "rwk": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "sah": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "saq": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "sdh": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "se": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "seh": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ses": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "sg": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "sh": { + "pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …", + "pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …", + "pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "shi": { + "pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04", + "pluralRule-count-few": "n = 2..10 @integer 2~10 @decimal 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 2.00, 3.00, 4.00, 5.00, 6.00, 7.00, 8.00", + "pluralRule-count-other": " @integer 11~26, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~1.9, 2.1~2.7, 10.1, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "si": { + "pluralRule-count-one": "n = 0,1 or i = 0 and f = 1 @integer 0, 1 @decimal 0.0, 0.1, 1.0, 0.00, 0.01, 1.00, 0.000, 0.001, 1.000, 0.0000, 0.0001, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.2~0.9, 1.1~1.8, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "sk": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-few": "i = 2..4 and v = 0 @integer 2~4", + "pluralRule-count-many": "v != 0 @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …", + "pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …" + }, + "sl": { + "pluralRule-count-one": "v = 0 and i % 100 = 1 @integer 1, 101, 201, 301, 401, 501, 601, 701, 1001, …", + "pluralRule-count-two": "v = 0 and i % 100 = 2 @integer 2, 102, 202, 302, 402, 502, 602, 702, 1002, …", + "pluralRule-count-few": "v = 0 and i % 100 = 3..4 or v != 0 @integer 3, 4, 103, 104, 203, 204, 303, 304, 403, 404, 503, 504, 603, 604, 703, 704, 1003, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …", + "pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …" + }, + "sma": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "smi": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "smj": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "smn": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "sms": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-two": "n = 2 @integer 2 @decimal 2.0, 2.00, 2.000, 2.0000", + "pluralRule-count-other": " @integer 0, 3~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "sn": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "so": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "sq": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "sr": { + "pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 or f % 10 = 1 and f % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, … @decimal 0.1, 1.1, 2.1, 3.1, 4.1, 5.1, 6.1, 7.1, 10.1, 100.1, 1000.1, …", + "pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 or f % 10 = 2..4 and f % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, … @decimal 0.2~0.4, 1.2~1.4, 2.2~2.4, 3.2~3.4, 4.2~4.4, 5.2, 10.2, 100.2, 1000.2, …", + "pluralRule-count-other": " @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0, 0.5~1.0, 1.5~2.0, 2.5~2.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ss": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ssy": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "st": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "sv": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "sw": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "syr": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ta": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "te": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "teo": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "th": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ti": { + "pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "tig": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "tk": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "tl": { + "pluralRule-count-one": "v = 0 and i = 1,2,3 or v = 0 and i % 10 != 4,6,9 or v != 0 and f % 10 != 4,6,9 @integer 0~3, 5, 7, 8, 10~13, 15, 17, 18, 20, 21, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.3, 0.5, 0.7, 0.8, 1.0~1.3, 1.5, 1.7, 1.8, 2.0, 2.1, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …", + "pluralRule-count-other": " @integer 4, 6, 9, 14, 16, 19, 24, 26, 104, 1004, … @decimal 0.4, 0.6, 0.9, 1.4, 1.6, 1.9, 2.4, 2.6, 10.4, 100.4, 1000.4, …" + }, + "tn": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "to": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "tr": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ts": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "tzm": { + "pluralRule-count-one": "n = 0..1 or n = 11..99 @integer 0, 1, 11~24 @decimal 0.0, 1.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0, 24.0", + "pluralRule-count-other": " @integer 2~10, 100~106, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ug": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "uk": { + "pluralRule-count-one": "v = 0 and i % 10 = 1 and i % 100 != 11 @integer 1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …", + "pluralRule-count-few": "v = 0 and i % 10 = 2..4 and i % 100 != 12..14 @integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002, …", + "pluralRule-count-many": "v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9 or v = 0 and i % 100 = 11..14 @integer 0, 5~19, 100, 1000, 10000, 100000, 1000000, …", + "pluralRule-count-other": " @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ur": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "uz": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "ve": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "vi": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "vo": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "vun": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "wa": { + "pluralRule-count-one": "n = 0..1 @integer 0, 1 @decimal 0.0, 1.0, 0.00, 1.00, 0.000, 1.000, 0.0000, 1.0000", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 0.1~0.9, 1.1~1.7, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "wae": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "wo": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "xh": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "xog": { + "pluralRule-count-one": "n = 1 @integer 1 @decimal 1.0, 1.00, 1.000, 1.0000", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~0.9, 1.1~1.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "yi": { + "pluralRule-count-one": "i = 1 and v = 0 @integer 1", + "pluralRule-count-other": " @integer 0, 2~16, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "yo": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "yue": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "zh": { + "pluralRule-count-other": " @integer 0~15, 100, 1000, 10000, 100000, 1000000, … @decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + }, + "zu": { + "pluralRule-count-one": "i = 0 or n = 1 @integer 0, 1 @decimal 0.0~1.0, 0.00~0.04", + "pluralRule-count-other": " @integer 2~17, 100, 1000, 10000, 100000, 1000000, … @decimal 1.1~2.6, 10.0, 100.0, 1000.0, 10000.0, 100000.0, 1000000.0, …" + } + } + } +} diff --git a/fluent/migrate/cldr_data/unicode-license.txt b/fluent/migrate/cldr_data/unicode-license.txt new file mode 100644 index 00000000..7e6dc2e8 --- /dev/null +++ b/fluent/migrate/cldr_data/unicode-license.txt @@ -0,0 +1,53 @@ +UNICODE, INC. LICENSE AGREEMENT - DATA FILES AND SOFTWARE + +Unicode Data Files include all data files under the directories +http://www.unicode.org/Public/, http://www.unicode.org/reports/, +http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, +and http://www.unicode.org/utility/trac/browser/. + +Unicode Data Files do not include PDF online code charts under the +directory http://www.unicode.org/Public/. + +Software includes any source code published in the Unicode Standard +or under the directories +http://www.unicode.org/Public/, http://www.unicode.org/reports/, +http://www.unicode.org/cldr/data/, http://source.icu-project.org/repos/icu/, +and http://www.unicode.org/utility/trac/browser/. + +NOTICE TO USER: Carefully read the following legal agreement. +BY DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING UNICODE INC.'S DATA +FILES ("DATA FILES"), AND/OR SOFTWARE ("SOFTWARE"), YOU UNEQUIVOCALLY ACCEPT, +AND AGREE TO BE BOUND BY, ALL OF THE TERMS AND CONDITIONS OF THIS AGREEMENT. +IF YOU DO NOT AGREE, DO NOT DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE +DATA FILES OR SOFTWARE. + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2016 Unicode, Inc. All rights reserved. Distributed under +the Terms of Use in http://www.unicode.org/copyright.html. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of the Unicode data files and any associated documentation (the "Data Files") +or Unicode software and any associated documentation (the "Software") to deal +in the Data Files or Software without restriction, including without +limitation the rights to use, copy, modify, merge, publish, distribute, +and/or sell copies of the Data Files or Software, and to permit persons to +whom the Data Files or Software are furnished to do so, provided that either +(a) this copyright and permission notice appear with all copies of the Data +Files or Software, or +(b) this copyright and permission notice appear in associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS +INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR +CONSEQUENTIAL DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +OF THE DATA FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall not +be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written authorization +of the copyright holder. diff --git a/fluent/migrate/context.py b/fluent/migrate/context.py new file mode 100644 index 00000000..1af4d26a --- /dev/null +++ b/fluent/migrate/context.py @@ -0,0 +1,266 @@ +# coding=utf8 +from __future__ import unicode_literals + +import os +import codecs +import logging + +import fluent.syntax.ast as FTL +from fluent.syntax.parser import FluentParser +from fluent.syntax.serializer import FluentSerializer +from fluent.util import fold +try: + from compare_locales.parser import getParser +except ImportError: + def getParser(path): + raise RuntimeError('compare-locales required') + +from .cldr import get_plural_categories +from .transforms import SOURCE +from .merge import merge_resource +from .util import get_message + + +class MergeContext(object): + """Stateful context for merging translation resources. + + `MergeContext` must be configured with the target language and the + directory locations of the input data. + + The transformation takes four types of input data: + + - The en-US FTL reference files which will be used as templates for + message order, comments and sections. + + - The current FTL files for the given language. + + - The legacy (DTD, properties) translation files for the given + language. The translations from these files will be transformed + into FTL and merged into the existing FTL files for this language. + + - A list of `FTL.Message` objects some of whose nodes are special + operation nodes: CONCAT, EXTERNAL, LITERAL, LITERAL_FROM, PLURALS, + PLURALS_FROM, REPLACE, REPLACE_FROM, SOURCE. + """ + + def __init__(self, lang, reference_dir, localization_dir): + self.fluent_parser = FluentParser( + with_spans=False, with_annotations=False + ) + self.fluent_serializer = FluentSerializer() + + # An iterable of plural category names relevant to the context's + # language. E.g. ('one', 'other') for English. + self.plural_categories = get_plural_categories(lang) + + # Paths to directories with input data, relative to CWD. + self.reference_dir = reference_dir + self.localization_dir = localization_dir + + # Parsed input resources stored by resource path. + self.reference_resources = {} + self.localization_resources = {} + + # An iterable of `FTL.Entity` objects some of whose nodes can be the + # transform operations. + self.transforms = {} + + # A dict whose keys are `(path, key)` tuples corresponding to target + # FTL translations, and values are sets of `(path, key)` tuples + # corresponding to localized entities which will be migrated. + self.dependencies = {} + + def read_ftl_resource(self, path): + """Read an FTL resource and parse it into an AST.""" + f = codecs.open(path, 'r', 'utf8') + try: + contents = f.read() + finally: + f.close() + + ast = self.fluent_parser.parse(contents) + + annots = [ + annot + for entry in ast.body + for annot in entry.annotations + ] + + if len(annots): + logger = logging.getLogger('migrate') + for annot in annots: + msg = annot.message + logger.warn(u'Syntax error in {}: {}'.format(path, msg)) + + return ast + + def read_legacy_resource(self, path): + """Read a legacy resource and parse it into a dict.""" + parser = getParser(path) + parser.readFile(path) + # Transform the parsed result which is an iterator into a dict. + return {entity.key: entity.val for entity in parser} + + def add_reference(self, path, realpath=None): + """Add an FTL AST to this context's reference resources.""" + fullpath = os.path.join(self.reference_dir, realpath or path) + try: + ast = self.read_ftl_resource(fullpath) + except IOError as err: + logger = logging.getLogger('migrate') + logger.error(u'Missing reference file: {}'.format(path)) + raise err + except UnicodeDecodeError as err: + logger = logging.getLogger('migrate') + logger.error(u'Error reading file {}: {}'.format(path, err)) + raise err + else: + self.reference_resources[path] = ast + + def add_localization(self, path): + """Add an existing localization resource. + + If it's an FTL resource, add an FTL AST. Otherwise, it's a legacy + resource. Use a compare-locales parser to create a dict of (key, + string value) tuples. + """ + fullpath = os.path.join(self.localization_dir, path) + if fullpath.endswith('.ftl'): + try: + ast = self.read_ftl_resource(fullpath) + except IOError: + logger = logging.getLogger('migrate') + logger.warn(u'Missing localization file: {}'.format(path)) + except UnicodeDecodeError as err: + logger = logging.getLogger('migrate') + logger.warn(u'Error reading file {}: {}'.format(path, err)) + else: + self.localization_resources[path] = ast + else: + try: + collection = self.read_legacy_resource(fullpath) + except IOError: + logger = logging.getLogger('migrate') + logger.warn(u'Missing localization file: {}'.format(path)) + else: + self.localization_resources[path] = collection + + def add_transforms(self, path, transforms): + """Define transforms for path. + + Each transform is an extended FTL node with `Transform` nodes as some + values. Transforms are stored in their lazy AST form until + `merge_changeset` is called, at which point they are evaluated to real + FTL nodes with migrated translations. + + Each transform is scanned for `SOURCE` nodes which will be used to + build the list of dependencies for the transformed message. + """ + def get_sources(acc, cur): + if isinstance(cur, SOURCE): + acc.add((cur.path, cur.key)) + return acc + + for node in transforms: + # Scan `node` for `SOURCE` nodes and collect the information they + # store into a set of dependencies. + dependencies = fold(get_sources, node, set()) + # Set these sources as dependencies for the current transform. + self.dependencies[(path, node.id.name)] = dependencies + + path_transforms = self.transforms.setdefault(path, []) + path_transforms += transforms + + def get_source(self, path, key): + """Get an entity value from the localized source. + + Used by the `SOURCE` transform. + """ + if path.endswith('.ftl'): + resource = self.localization_resources[path] + return get_message(resource.body, key) + else: + resource = self.localization_resources[path] + return resource.get(key, None) + + def merge_changeset(self, changeset=None): + """Return a generator of FTL ASTs for the changeset. + + The input data must be configured earlier using the `add_*` methods. + if given, `changeset` must be a set of (path, key) tuples describing + which legacy translations are to be merged. + + Given `changeset`, return a dict whose keys are resource paths and + values are `FTL.Resource` instances. The values will also be used to + update this context's existing localization resources. + """ + + if changeset is None: + # Merge all known legacy translations. + changeset = { + (path, key) + for path, strings in self.localization_resources.iteritems() + for key in strings.iterkeys() + } + + for path, reference in self.reference_resources.iteritems(): + current = self.localization_resources.get(path, FTL.Resource()) + transforms = self.transforms.get(path, []) + + def in_changeset(ident): + """Check if entity should be merged. + + If at least one dependency of the entity is in the current + set of changeset, merge it. + """ + message_deps = self.dependencies.get((path, ident), None) + + # Don't merge if we don't have a transform for this message. + if message_deps is None: + return False + + # As a special case, if a transform exists but has no + # dependecies, it's a hardcoded `FTL.Node` which doesn't + # migrate any existing translation but rather creates a new + # one. Merge it. + if len(message_deps) == 0: + return True + + # If the intersection of the dependencies and the current + # changeset is non-empty, merge this message. + return message_deps & changeset + + # Merge legacy translations with the existing ones using the + # reference as a template. + snapshot = merge_resource( + self, reference, current, transforms, in_changeset + ) + + # If none of the transforms is in the given changeset, the merged + # snapshot is identical to the current translation. We compare + # JSON trees rather then use filtering by `in_changeset` to account + # for translations removed from `reference`. + if snapshot.to_json() == current.to_json(): + continue + + # Store the merged snapshot on the context so that the next merge + # already takes it into account as the existing localization. + self.localization_resources[path] = snapshot + + # The result for this path is a complete `FTL.Resource`. + yield path, snapshot + + def serialize_changeset(self, changeset): + """Return a dict of serialized FTLs for the changeset. + + Given `changeset`, return a dict whose keys are resource paths and + values are serialized FTL snapshots. + """ + + return { + path: self.fluent_serializer.serialize(snapshot) + for path, snapshot in self.merge_changeset(changeset) + } + + +logging.basicConfig() diff --git a/fluent/migrate/merge.py b/fluent/migrate/merge.py new file mode 100644 index 00000000..3112cb54 --- /dev/null +++ b/fluent/migrate/merge.py @@ -0,0 +1,58 @@ +# coding=utf8 +from __future__ import unicode_literals + +import fluent.syntax.ast as FTL + +from .transforms import evaluate +from .util import get_message, get_transform + + +def merge_resource(ctx, reference, current, transforms, in_changeset): + """Transform legacy translations into FTL. + + Use the `reference` FTL AST as a template. For each en-US string in the + reference, first check if it's in the currently processed changeset with + `in_changeset`; then check for an existing translation in the current FTL + `localization` or for a migration specification in `transforms`. + """ + + def merge_body(body): + return [ + entry + for entry in map(merge_entry, body) + if entry is not None + ] + + def merge_entry(entry): + # All standalone comments will be merged. + if isinstance(entry, FTL.Comment): + return entry + + # All section headers will be merged. + if isinstance(entry, FTL.Section): + return entry + + # Ignore Junk + if isinstance(entry, FTL.Junk): + return None + + ident = entry.id.name + + # If the message is present in the existing localization, we add it to + # the resulting resource. This ensures consecutive merges don't remove + # translations but rather create supersets of them. + existing = get_message(current.body, ident) + if existing is not None: + return existing + + transform = get_transform(transforms, ident) + + # Make sure this message is supposed to be migrated as part of the + # current changeset. + if transform is not None and in_changeset(ident): + if transform.comment is None: + transform.comment = entry.comment + return evaluate(ctx, transform) + + body = merge_body(reference.body) + return FTL.Resource(body, reference.comment) diff --git a/fluent/migrate/transforms.py b/fluent/migrate/transforms.py new file mode 100644 index 00000000..b5f10665 --- /dev/null +++ b/fluent/migrate/transforms.py @@ -0,0 +1,281 @@ +# coding=utf8 +from __future__ import unicode_literals + +import fluent.syntax.ast as FTL + + +def evaluate(ctx, node): + def eval_node(subnode): + if isinstance(subnode, Transform): + return subnode(ctx) + else: + return subnode + + return node.traverse(eval_node) + + +class Transform(FTL.Node): + def __call__(self, ctx): + raise NotImplementedError + + +class SOURCE(Transform): + """Declare the source translation to be migrated with other transforms. + + When evaluated `SOURCE` returns a simple string value. All \\uXXXX from + the original translations are converted beforehand to the literal + characters they encode. + + HTML entities are left unchanged for now because we can't know if they + should be converted to the characters they represent or not. Consider the + following example in which `&` could be replaced with the literal `&`: + + Privacy & History + + vs. these two examples where the HTML encoding should be preserved: + + Erreur ! + Use /help <command> for more information. + + """ + + # XXX Perhaps there's a strict subset of HTML entities which must or must + # not be replaced? + + def __init__(self, path, key): + self.path = path + self.key = key + + def __call__(self, ctx): + return ctx.get_source(self.path, self.key) + + +class LITERAL(Transform): + """Create a Pattern with the literal text `value`. + + This transform is used by `LITERAL_FROM` and can be used on its own with + `CONCAT`. + """ + + def __init__(self, value): + self.value = value + + def __call__(self, ctx): + elements = [FTL.TextElement(self.value)] + return FTL.Pattern(elements) + + +class LITERAL_FROM(SOURCE): + """Create a Pattern with the translation value from the given source.""" + + def __call__(self, ctx): + source = super(self.__class__, self).__call__(ctx) + return LITERAL(source)(ctx) + + +class EXTERNAL(Transform): + """Create a Pattern with the external argument `name` + + This is a common use-case when joining translations with CONCAT. + """ + + def __init__(self, name): + self.name = name + + def __call__(self, ctx): + external = FTL.ExternalArgument( + id=FTL.Identifier(self.name) + ) + return FTL.Pattern([external]) + + +class REPLACE(Transform): + """Replace various placeables in the translation with FTL placeables. + + The original placeables are defined as keys on the `replacements` dict. + For each key the value is defined as a list of FTL Expressions to be + interpolated. + """ + + def __init__(self, value, replacements): + self.value = value + self.replacements = replacements + + def __call__(self, ctx): + + # Only replace placeable which are present in the translation. + replacements = { + k: v for k, v in self.replacements.iteritems() if k in self.value + } + + # Order the original placeables by their position in the translation. + keys_in_order = sorted( + replacements.keys(), + lambda x, y: self.value.find(x) - self.value.find(y) + ) + + # Used to reduce the `keys_in_order` list. + def replace(acc, cur): + """Convert original placeables and text into FTL Nodes. + + For each original placeable the translation will be partitioned + around it and the text before it will be converted into an + `FTL.TextElement` and the placeable will be replaced with its + replacement. The text following the placebale will be fed again to + the `replace` function. + """ + + parts, rest = acc + before, key, after = rest.value.partition(cur) + + # Return the elements found and converted so far, and the remaining + # text which hasn't been scanned for placeables yet. + return ( + parts + [FTL.TextElement(before), replacements[key]], + FTL.TextElement(after) + ) + + def is_non_empty(elem): + """Used for filtering empty `FTL.TextElement` nodes out.""" + return not isinstance(elem, FTL.TextElement) or len(elem.value) + + # Start with an empty list of elements and the original translation. + init = ([], FTL.TextElement(self.value)) + parts, tail = reduce(replace, keys_in_order, init) + + # Explicitly concat the trailing part to get the full list of elements + # and filter out the empty ones. + elements = filter(is_non_empty, parts + [tail]) + + return FTL.Pattern(elements) + + +class REPLACE_FROM(SOURCE): + """Create a Pattern with interpolations from given source. + + Interpolations in the translation value from the given source will be + replaced with FTL placeables using the `REPLACE` transform. + """ + + def __init__(self, path, key, replacements): + super(self.__class__, self).__init__(path, key) + self.replacements = replacements + + def __call__(self, ctx): + value = super(self.__class__, self).__call__(ctx) + return REPLACE(value, self.replacements)(ctx) + + +class PLURALS(Transform): + """Convert semicolon-separated variants into a select expression. + + Build an `FTL.SelectExpression` with the supplied `selector` and variants + extracted from the source. Each variant will be run through the + `foreach` function, which should return an `FTL.Node`. + """ + + def __init__(self, value, selector, foreach): + self.value = value + self.selector = selector + self.foreach = foreach + + def __call__(self, ctx): + variants = self.value.split(';') + keys = ctx.plural_categories + last_index = min(len(variants), len(keys)) - 1 + + def createVariant(zipped_enum): + index, (key, variant) = zipped_enum + # Run the legacy variant through `foreach` which returns an + # `FTL.Node` describing the transformation required for each + # variant. Then evaluate it to a migrated FTL node. + value = evaluate(ctx, self.foreach(variant)) + return FTL.Variant( + key=FTL.Symbol(key), + value=value, + default=index == last_index + ) + + select = FTL.SelectExpression( + expression=self.selector, + variants=map(createVariant, enumerate(zip(keys, variants))) + ) + + return FTL.Pattern([select]) + + +class PLURALS_FROM(SOURCE): + """Create a Pattern with plurals from given source. + + Semi-colon separated variants in the translation value from the given + source will be replaced with an FTL select expression using the `PLURALS` + transform. + """ + + def __init__(self, path, key, selector, foreach): + super(self.__class__, self).__init__(path, key) + self.selector = selector + self.foreach = foreach + + def __call__(self, ctx): + value = super(self.__class__, self).__call__(ctx) + return PLURALS(value, self.selector, self.foreach)(ctx) + + +class CONCAT(Transform): + """Concatenate elements of many patterns.""" + + def __init__(self, *patterns): + self.patterns = list(patterns) + + def __call__(self, ctx): + # Flatten the list of patterns of which each has a list of elements. + elements = [ + elems for pattern in self.patterns for elems in pattern.elements + ] + + # Merge adjecent `FTL.TextElement` nodes. + def merge_adjecent_text(acc, cur): + if type(cur) == FTL.TextElement and len(acc): + last = acc[-1] + if type(last) == FTL.TextElement: + last.value += cur.value + else: + acc.append(cur) + else: + acc.append(cur) + return acc + + elements = reduce(merge_adjecent_text, elements, []) + return FTL.Pattern(elements) + + def traverse(self, fun): + def visit(value): + if isinstance(value, FTL.Node): + return value.traverse(fun) + if isinstance(value, list): + return fun(map(visit, value)) + else: + return fun(value) + + node = self.__class__( + *[ + visit(value) for value in self.patterns + ] + ) + + return fun(node) + + def to_json(self): + def to_json(value): + if isinstance(value, FTL.Node): + return value.to_json() + else: + return value + + return { + 'type': self.__class__.__name__, + 'patterns': [ + to_json(value) for value in self.patterns + ] + } diff --git a/fluent/migrate/util.py b/fluent/migrate/util.py new file mode 100644 index 00000000..f59892a5 --- /dev/null +++ b/fluent/migrate/util.py @@ -0,0 +1,59 @@ +# coding=utf8 +from __future__ import unicode_literals + +import fluent.syntax.ast as FTL +from fluent.syntax.parser import FluentParser +from fluent.util import ftl + + +fluent_parser = FluentParser( + with_spans=False, + with_annotations=False +) + + +def parse(Parser, string): + if Parser is FluentParser: + return fluent_parser.parse(string) + + # Parsing a legacy resource. + + # Parse the string into the internal Context. + parser = Parser() + # compare-locales expects ASCII strings. + parser.readContents(string.encode('utf8')) + # Transform the parsed result which is an iterator into a dict. + return {ent.key: ent for ent in parser} + + +def ftl_resource_to_ast(code): + return fluent_parser.parse(ftl(code)) + + +def ftl_resource_to_json(code): + return fluent_parser.parse(ftl(code)).to_json() + + +def ftl_message_to_json(code): + return fluent_parser.parse_entry(ftl(code)).to_json() + + +def to_json(merged_iter): + return { + path: resource.to_json() + for path, resource in merged_iter + } + + +def get_message(body, ident): + """Get message called `ident` from the `body` iterable.""" + for entity in body: + if isinstance(entity, FTL.Message) and entity.id.name == ident: + return entity + + +def get_transform(body, ident): + """Get entity called `ident` from the `body` iterable.""" + for transform in body: + if transform.id.name == ident: + return transform diff --git a/fluent/util.py b/fluent/util.py new file mode 100644 index 00000000..5044be48 --- /dev/null +++ b/fluent/util.py @@ -0,0 +1,42 @@ +# coding=utf8 +import textwrap + +import fluent.syntax.ast as FTL + + +def ftl(code): + """Nicer indentation for FTL code. + + The code returned by this function is meant to be compared against the + output of the FTL Serializer. The input code will end with a newline to + match the output of the serializer. + """ + + # The code might be triple-quoted. + code = code.lstrip('\n') + + return textwrap.dedent(code) + + +def fold(fun, node, init): + """Reduce `node` to a single value using `fun`. + + Apply `fun` against an accumulator and each subnode of `node` (in postorder + traversal) to reduce it to a single value. + """ + + def fold_(vals, acc): + if not vals: + return acc + + head = list(vals)[0] + tail = list(vals)[1:] + + if isinstance(head, FTL.Node): + acc = fold(fun, head, acc) + if isinstance(head, list): + acc = fold_(head, acc) + + return fold_(tail, fun(acc, head)) + + return fold_(vars(node).values(), init) diff --git a/tests/migrate/__init__.py b/tests/migrate/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/migrate/fixtures/en-US/aboutDialog.ftl b/tests/migrate/fixtures/en-US/aboutDialog.ftl new file mode 100644 index 00000000..55392d8b --- /dev/null +++ b/tests/migrate/fixtures/en-US/aboutDialog.ftl @@ -0,0 +1,10 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +update-failed = Update failed. Download manually! +channel-desc = You are on the { $channelname } channel. +community = + { brand-short-name } is designed by + { vendor-short-name }, a global community + working together to… diff --git a/tests/migrate/fixtures/en-US/aboutDownloads.ftl b/tests/migrate/fixtures/en-US/aboutDownloads.ftl new file mode 100644 index 00000000..a33d1dfa --- /dev/null +++ b/tests/migrate/fixtures/en-US/aboutDownloads.ftl @@ -0,0 +1,39 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +title = Downloads +header = Your Downloads +empty = No Downloads +about = About Downloads + +open-menuitem + .label = Open +retry-menuitem + .label = Retry +remove-menuitem + .label = Delete +pause-menuitem + .label = Pause +resume-menuitem + .label = Resume +cancel-menuitem + .label = Cancel +remove-all-menuitem + .label = Delete All + +delete-all-title = Delete All +delete-all-message = + { $num -> + [1] Delete this download? + *[other] Delete { $num } downloads? + } + +download-state-downloading = Downloading… +download-state-canceled = Canceled +download-state-failed = Failed +download-state-paused = Paused +download-state-starting = Starting… +download-state-unknown = Unknown + +download-size-unknown = Unknown size diff --git a/tests/migrate/fixtures/en-US/toolbar.ftl b/tests/migrate/fixtures/en-US/toolbar.ftl new file mode 100644 index 00000000..1d056553 --- /dev/null +++ b/tests/migrate/fixtures/en-US/toolbar.ftl @@ -0,0 +1,24 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +urlbar-textbox + .placeholder = Search or enter address + .accesskey = d + + +[[ Toolbar items ]] + +view-bookmarks-broadcaster + .label = Bookmarks +view-bookmarks-command + .key = b +view-bookmarks-command-win + .key = i + +view-history-broadcaster + .label = History +view-history-command + .key = h +view-tabs-broadcaster + .label = Synced Tabs diff --git a/tests/migrate/fixtures/pl/aboutDialog.dtd b/tests/migrate/fixtures/pl/aboutDialog.dtd new file mode 100644 index 00000000..37629a32 --- /dev/null +++ b/tests/migrate/fixtures/pl/aboutDialog.dtd @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/tests/migrate/fixtures/pl/aboutDownloads.dtd b/tests/migrate/fixtures/pl/aboutDownloads.dtd new file mode 100644 index 00000000..2bc4fe77 --- /dev/null +++ b/tests/migrate/fixtures/pl/aboutDownloads.dtd @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/tests/migrate/fixtures/pl/aboutDownloads.properties b/tests/migrate/fixtures/pl/aboutDownloads.properties new file mode 100644 index 00000000..8a4c5cec --- /dev/null +++ b/tests/migrate/fixtures/pl/aboutDownloads.properties @@ -0,0 +1,12 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +downloadMessage.deleteAll=Usunąć pobrany plik?;Usunąć #1 pobrane pliki?;Usunąć #1 pobranych plików? +downloadState.downloading=Pobieranie… +downloadState.canceled=Anulowane +downloadState.failed=Nieudane +downloadState.paused=Wstrzymane +downloadState.starting=Rozpoczynanie… +downloadState.unknownSize=Nieznany rozmiar +downloadAction.deleteAll=Usuń wszystko diff --git a/tests/migrate/test_changesets.py b/tests/migrate/test_changesets.py new file mode 100644 index 00000000..52f423fd --- /dev/null +++ b/tests/migrate/test_changesets.py @@ -0,0 +1,50 @@ +# coding=utf8 +from __future__ import unicode_literals + +import unittest + +from fluent.migrate.changesets import convert_blame_to_changesets + + +class TestBlameToChangesets(unittest.TestCase): + def test_convert(self): + blame = { + 'authors': [ + 'A', + 'B' + ], + 'blame': { + 'path/one': { + 'key1': [0, 1346095921.0], + 'key2': [1, 1218121409.0] + }, + 'path/two': { + 'key1': [1, 1440596526.0], + 'key3': [0, 1346095921.0] + } + } + } + + expected = [ + { + 'author': 'B', + 'first_commit': 1218121409.0, + 'changes': { + ('path/one', 'key2'), + ('path/two', 'key1'), + } + }, + { + 'author': 'A', + 'first_commit': 1346095921.0, + 'changes': { + ('path/one', 'key1'), + ('path/two', 'key3'), + } + }, + ] + + self.assertEqual( + convert_blame_to_changesets(blame), + expected + ) diff --git a/tests/migrate/test_cldr.py b/tests/migrate/test_cldr.py new file mode 100644 index 00000000..6d739ed4 --- /dev/null +++ b/tests/migrate/test_cldr.py @@ -0,0 +1,37 @@ +# coding=utf8 +from __future__ import unicode_literals + +import unittest +import sys + +from fluent.migrate.cldr import get_plural_categories + + +class TestPluralCategories(unittest.TestCase): + def __init__(self, *args): + super(TestPluralCategories, self).__init__(*args) + if sys.version_info < (3,0): + self.assertRaisesRegex = self.assertRaisesRegexp + + + def test_known_language(self): + self.assertEqual( + get_plural_categories('pl'), + ('one', 'few', 'many', 'other') + ) + + def test_fallback_one(self): + self.assertEqual( + get_plural_categories('ga-IE'), + ('one', 'two', 'few', 'many', 'other') + ) + + def test_fallback_two(self): + self.assertEqual( + get_plural_categories('ja-JP-mac'), + ('other',) + ) + + def test_unknown_language(self): + with self.assertRaisesRegex(RuntimeError, 'Unknown language'): + get_plural_categories('i-default') diff --git a/tests/migrate/test_concat.py b/tests/migrate/test_concat.py new file mode 100644 index 00000000..7ee7a43a --- /dev/null +++ b/tests/migrate/test_concat.py @@ -0,0 +1,230 @@ +# coding=utf8 +from __future__ import unicode_literals + +import unittest + +import fluent.syntax.ast as FTL +try: + from compare_locales.parser import PropertiesParser, DTDParser +except ImportError: + DTDParser = PropertiesParser = None + +from fluent.migrate.util import parse, ftl_message_to_json +from fluent.migrate.transforms import ( + evaluate, CONCAT, LITERAL_FROM, EXTERNAL, REPLACE_FROM, LITERAL +) + + +class MockContext(unittest.TestCase): + def get_source(self, path, key): + return self.strings.get(key, None).get_val() + + +@unittest.skipUnless(PropertiesParser, 'compare-locales required') +class TestConcatCopy(MockContext): + def setUp(self): + self.strings = parse(PropertiesParser, ''' + hello = Hello, world! + hello.start = Hello,\\u0020 + hello.end = world! + whitespace.begin.start = \\u0020Hello,\\u0020 + whitespace.begin.end = world! + whitespace.end.start = Hello,\\u0020 + whitespace.end.end = world!\\u0020 + ''') + + def test_concat_one(self): + msg = FTL.Message( + FTL.Identifier('hello'), + value=CONCAT( + LITERAL_FROM(self.strings, 'hello'), + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + hello = Hello, world! + ''') + ) + + def test_concat_two(self): + msg = FTL.Message( + FTL.Identifier('hello'), + value=CONCAT( + LITERAL_FROM(self.strings, 'hello.start'), + LITERAL_FROM(self.strings, 'hello.end'), + ) + ) + + result = evaluate(self, msg) + + self.assertEqual( + len(result.value.elements), + 1, + 'The constructed value should have only one element' + ) + self.assertIsInstance( + result.value.elements[0], + FTL.TextElement, + 'The constructed element should be a TextElement.' + ) + self.assertEqual( + result.value.elements[0].value, + 'Hello, world!', + 'The TextElement should be a concatenation of the sources.' + ) + + self.assertEqual( + result.to_json(), + ftl_message_to_json(''' + hello = Hello, world! + ''') + ) + + @unittest.skip('Parser/Serializer trim whitespace') + def test_concat_whitespace_begin(self): + msg = FTL.Message( + FTL.Identifier('hello'), + value=CONCAT( + LITERAL_FROM(self.strings, 'whitespace.begin.start'), + LITERAL_FROM(self.strings, 'whitespace.begin.end'), + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + hello = {" "}Hello, world! + ''') + ) + + @unittest.skip('Parser/Serializer trim whitespace') + def test_concat_whitespace_end(self): + msg = FTL.Message( + FTL.Identifier('hello'), + value=CONCAT( + LITERAL_FROM(self.strings, 'whitespace.end.start'), + LITERAL_FROM(self.strings, 'whitespace.end.end'), + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + hello = Hello, world! + ''') + ) + + +@unittest.skipUnless(DTDParser, 'compare-locales required') +class TestConcatLiteral(MockContext): + def setUp(self): + self.strings = parse(DTDParser, ''' + + + + ''') + + def test_concat_literal(self): + msg = FTL.Message( + FTL.Identifier('update-failed'), + value=CONCAT( + LITERAL_FROM(self.strings, 'update.failed.start'), + LITERAL(''), + LITERAL_FROM(self.strings, 'update.failed.linkText'), + LITERAL(''), + LITERAL_FROM(self.strings, 'update.failed.end'), + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + update-failed = Update failed. Download manually! + ''') + ) + + +@unittest.skipUnless(DTDParser, 'compare-locales required') +class TestConcatInterpolate(MockContext): + def setUp(self): + self.strings = parse(DTDParser, ''' + + + ''') + + @unittest.skip('Parser/Serializer trim whitespace') + def test_concat_replace(self): + msg = FTL.Message( + FTL.Identifier('channel-desc'), + value=CONCAT( + LITERAL_FROM(self.strings, 'channel.description.start'), + EXTERNAL('channelname'), + LITERAL_FROM(self.strings, 'channel.description.end'), + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + channel-desc = You are on the { $channelname } channel. + ''') + ) + + +@unittest.skipUnless(DTDParser, 'compare-locales required') +class TestConcatReplace(MockContext): + def setUp(self): + self.strings = parse(DTDParser, ''' + + + + + + ''') + + def test_concat_replace(self): + msg = FTL.Message( + FTL.Identifier('community'), + value=CONCAT( + REPLACE_FROM( + self.strings, + 'community.start', + { + '&brandShortName;': FTL.ExternalArgument( + id=FTL.Identifier('brand-short-name') + ) + } + ), + LITERAL(''), + REPLACE_FROM( + self.strings, + 'community.mozillaLink', + { + '&vendorShortName;': FTL.ExternalArgument( + id=FTL.Identifier('vendor-short-name') + ) + } + ), + LITERAL(''), + LITERAL_FROM(self.strings, 'community.middle'), + LITERAL(''), + LITERAL_FROM(self.strings, 'community.creditsLink'), + LITERAL(''), + LITERAL_FROM(self.strings, 'community.end') + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json( + 'community = { $brand-short-name } is designed by ' + '{ $vendor-short-name }, a global community ' + 'working together to…' + ) + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/migrate/test_context.py b/tests/migrate/test_context.py new file mode 100644 index 00000000..175c03c4 --- /dev/null +++ b/tests/migrate/test_context.py @@ -0,0 +1,309 @@ +# coding=utf8 +from __future__ import unicode_literals + +import os +import logging +import unittest + +import fluent.syntax.ast as FTL + +from fluent.migrate.util import ftl, ftl_resource_to_json, to_json +from fluent.migrate.context import MergeContext +from fluent.migrate.transforms import LITERAL, LITERAL_FROM + + +def here(*parts): + dirname = os.path.dirname(os.path.realpath(__file__)) + return os.path.join(dirname, *parts) + + +class TestMergeContext(unittest.TestCase): + def setUp(self): + self.ctx = MergeContext( + lang='pl', + reference_dir=here('fixtures/en-US'), + localization_dir=here('fixtures/pl') + ) + + self.ctx.add_reference('aboutDownloads.ftl') + try: + self.ctx.add_localization('aboutDownloads.dtd') + self.ctx.add_localization('aboutDownloads.properties') + except RuntimeError: + self.skipTest('compare-locales required') + + def test_hardcoded_node(self): + self.ctx.add_transforms('aboutDownloads.ftl', [ + FTL.Message( + id=FTL.Identifier('about'), + value=LITERAL('Hardcoded Value') + ), + ]) + + expected = { + 'aboutDownloads.ftl': ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + about = Hardcoded Value + ''') + } + + self.assertDictEqual( + to_json(self.ctx.merge_changeset()), + expected + ) + + def test_merge_single_message(self): + self.ctx.add_transforms('aboutDownloads.ftl', [ + FTL.Message( + id=FTL.Identifier('title'), + value=LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.title' + ) + ), + ]) + + expected = { + 'aboutDownloads.ftl': ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + title = Pobrane pliki + ''') + } + + self.assertDictEqual( + to_json(self.ctx.merge_changeset()), + expected + ) + + def test_merge_one_changeset(self): + self.ctx.add_transforms('aboutDownloads.ftl', [ + FTL.Message( + id=FTL.Identifier('title'), + value=LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.title' + ) + ), + FTL.Message( + id=FTL.Identifier('header'), + value=LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.header' + ) + ), + ]) + + changeset = { + ('aboutDownloads.dtd', 'aboutDownloads.title'), + ('aboutDownloads.dtd', 'aboutDownloads.header') + } + + expected = { + 'aboutDownloads.ftl': ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + title = Pobrane pliki + header = Twoje pobrane pliki + ''') + } + + self.assertDictEqual( + to_json(self.ctx.merge_changeset(changeset)), + expected + ) + + def test_merge_two_changesets(self): + self.ctx.add_transforms('aboutDownloads.ftl', [ + FTL.Message( + id=FTL.Identifier('title'), + value=LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.title' + ) + ), + FTL.Message( + id=FTL.Identifier('header'), + value=LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.header' + ) + ), + ]) + + changeset_a = { + ('aboutDownloads.dtd', 'aboutDownloads.title'), + } + + changeset_b = { + ('aboutDownloads.dtd', 'aboutDownloads.header') + } + + expected_a = { + 'aboutDownloads.ftl': ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + title = Pobrane pliki + ''') + } + + expected_b = { + 'aboutDownloads.ftl': ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + title = Pobrane pliki + header = Twoje pobrane pliki + ''') + } + + merged_a = to_json(self.ctx.merge_changeset(changeset_a)) + self.assertDictEqual(merged_a, expected_a) + + merged_b = to_json(self.ctx.merge_changeset(changeset_b)) + self.assertDictEqual(merged_b, expected_b) + + def test_serialize_changeset(self): + self.ctx.add_transforms('aboutDownloads.ftl', [ + FTL.Message( + id=FTL.Identifier('title'), + value=LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.title' + ) + ), + FTL.Message( + id=FTL.Identifier('header'), + value=LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.header' + ) + ), + ]) + + changesets = [ + { + ('aboutDownloads.dtd', 'aboutDownloads.title'), + }, + { + ('aboutDownloads.dtd', 'aboutDownloads.header') + } + ] + + expected = iter([ + { + 'aboutDownloads.ftl': ftl(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + title = Pobrane pliki + ''') + }, + { + 'aboutDownloads.ftl': ftl(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + title = Pobrane pliki + header = Twoje pobrane pliki + ''') + } + ]) + + for changeset in changesets: + serialized = self.ctx.serialize_changeset(changeset) + self.assertEqual(serialized, next(expected)) + + +class TestIncompleteReference(unittest.TestCase): + def setUp(self): + # Silence all logging. + logging.disable(logging.CRITICAL) + + self.ctx = MergeContext( + lang='pl', + reference_dir=here('fixtures/en-US'), + localization_dir=here('fixtures/pl') + ) + + def tearDown(self): + # Resume logging. + logging.disable(logging.NOTSET) + + def test_missing_reference_file(self): + with self.assertRaises(IOError): + self.ctx.add_reference('missing.ftl') + + +class TestIncompleteLocalization(unittest.TestCase): + def setUp(self): + # Silence all logging. + logging.disable(logging.CRITICAL) + + self.ctx = MergeContext( + lang='pl', + reference_dir=here('fixtures/en-US'), + localization_dir=here('fixtures/pl') + ) + + self.ctx.add_reference('toolbar.ftl') + try: + self.ctx.add_localization('browser.dtd') + except RuntimeError: + self.skipTest('compare-locales required') + + self.ctx.add_transforms('toolbar.ftl', [ + FTL.Message( + id=FTL.Identifier('urlbar-textbox'), + attributes=[ + FTL.Attribute( + id=FTL.Identifier('placeholder'), + value=LITERAL_FROM( + 'browser.dtd', + 'urlbar.placeholder2' + ) + ), + FTL.Attribute( + id=FTL.Identifier('accesskey'), + value=LITERAL_FROM( + 'browser.dtd', + 'urlbar.accesskey' + ) + ), + ] + ), + ]) + + def tearDown(self): + # Resume logging. + logging.disable(logging.NOTSET) + + def test_missing_localization_file(self): + expected = { + 'toolbar.ftl': ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + + [[ Toolbar items ]] + ''') + } + + self.maxDiff = None + self.assertDictEqual( + to_json(self.ctx.merge_changeset()), + expected + ) diff --git a/tests/migrate/test_context_real_examples.py b/tests/migrate/test_context_real_examples.py new file mode 100644 index 00000000..cdd9991b --- /dev/null +++ b/tests/migrate/test_context_real_examples.py @@ -0,0 +1,391 @@ +# coding=utf8 +from __future__ import unicode_literals + +import os +import unittest + +import fluent.syntax.ast as FTL + +from fluent.migrate.util import ftl_resource_to_json, to_json +from fluent.migrate.context import MergeContext +from fluent.migrate.transforms import ( + CONCAT, EXTERNAL, LITERAL, LITERAL_FROM, PLURALS_FROM, REPLACE, + REPLACE_FROM +) + + +def here(*parts): + dirname = os.path.dirname(os.path.realpath(__file__)) + return os.path.join(dirname, *parts) + + +class TestMergeAboutDownloads(unittest.TestCase): + def setUp(self): + self.ctx = MergeContext( + lang='pl', + reference_dir=here('fixtures/en-US'), + localization_dir=here('fixtures/pl') + ) + + self.ctx.add_reference('aboutDownloads.ftl') + try: + self.ctx.add_localization('aboutDownloads.dtd') + self.ctx.add_localization('aboutDownloads.properties') + except RuntimeError: + self.skipTest('compare-locales required') + + self.ctx.add_transforms('aboutDownloads.ftl', [ + FTL.Message( + id=FTL.Identifier('title'), + value=LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.title' + ) + ), + FTL.Message( + id=FTL.Identifier('header'), + value=LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.header' + ) + ), + FTL.Message( + id=FTL.Identifier('empty'), + value=LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.empty' + ) + ), + FTL.Message( + id=FTL.Identifier('open-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.open' + ) + ) + ] + ), + FTL.Message( + id=FTL.Identifier('retry-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.retry' + ) + ) + ] + ), + FTL.Message( + id=FTL.Identifier('remove-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.remove' + ) + ) + ] + ), + FTL.Message( + id=FTL.Identifier('pause-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.pause' + ) + ) + ] + ), + FTL.Message( + id=FTL.Identifier('resume-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.resume' + ) + ) + ] + ), + FTL.Message( + id=FTL.Identifier('cancel-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.cancel' + ) + ) + ] + ), + FTL.Message( + id=FTL.Identifier('remove-all-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'aboutDownloads.dtd', + 'aboutDownloads.removeAll' + ) + ) + ] + ), + FTL.Message( + id=FTL.Identifier('delete-all-title'), + value=LITERAL_FROM( + 'aboutDownloads.properties', + 'downloadAction.deleteAll' + ) + ), + FTL.Message( + id=FTL.Identifier('delete-all-message'), + value=PLURALS_FROM( + 'aboutDownloads.properties', + 'downloadMessage.deleteAll', + FTL.ExternalArgument( + id=FTL.Identifier('num') + ), + lambda var: REPLACE( + var, + { + '#1': FTL.ExternalArgument( + id=FTL.Identifier('num') + ) + } + ) + ) + ), + FTL.Message( + id=FTL.Identifier('download-state-downloading'), + value=LITERAL_FROM( + 'aboutDownloads.properties', + 'downloadState.downloading' + ) + ), + FTL.Message( + id=FTL.Identifier('download-state-canceled'), + value=LITERAL_FROM( + 'aboutDownloads.properties', + 'downloadState.canceled' + ) + ), + FTL.Message( + id=FTL.Identifier('download-state-failed'), + value=LITERAL_FROM( + 'aboutDownloads.properties', + 'downloadState.failed' + ) + ), + FTL.Message( + id=FTL.Identifier('download-state-paused'), + value=LITERAL_FROM( + 'aboutDownloads.properties', + 'downloadState.paused' + ) + ), + FTL.Message( + id=FTL.Identifier('download-state-starting'), + value=LITERAL_FROM( + 'aboutDownloads.properties', + 'downloadState.starting' + ) + ), + FTL.Message( + id=FTL.Identifier('download-size-unknown'), + value=LITERAL_FROM( + 'aboutDownloads.properties', + 'downloadState.unknownSize' + ) + ), + ]) + + def test_merge_context_all_messages(self): + expected = { + 'aboutDownloads.ftl': ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + title = Pobrane pliki + header = Twoje pobrane pliki + empty = Brak pobranych plików + + open-menuitem + .label = Otwórz + retry-menuitem + .label = Spróbuj ponownie + remove-menuitem + .label = Usuń + pause-menuitem + .label = Wstrzymaj + resume-menuitem + .label = Wznów + cancel-menuitem + .label = Anuluj + remove-all-menuitem + .label = Usuń wszystko + + delete-all-title = Usuń wszystko + delete-all-message = + { $num -> + [one] Usunąć pobrany plik? + [few] Usunąć { $num } pobrane pliki? + *[many] Usunąć { $num } pobranych plików? + } + + download-state-downloading = Pobieranie… + download-state-canceled = Anulowane + download-state-failed = Nieudane + download-state-paused = Wstrzymane + download-state-starting = Rozpoczynanie… + download-size-unknown = Nieznany rozmiar + ''') + } + + self.assertDictEqual( + to_json(self.ctx.merge_changeset()), + expected + ) + + def test_merge_context_some_messages(self): + changeset = { + ('aboutDownloads.dtd', 'aboutDownloads.title'), + ('aboutDownloads.dtd', 'aboutDownloads.header'), + ('aboutDownloads.properties', 'downloadState.downloading'), + ('aboutDownloads.properties', 'downloadState.canceled'), + } + + expected = { + 'aboutDownloads.ftl': ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + title = Pobrane pliki + header = Twoje pobrane pliki + download-state-downloading = Pobieranie… + download-state-canceled = Anulowane + ''') + } + + self.assertDictEqual( + to_json(self.ctx.merge_changeset(changeset)), + expected + ) + + +class TestMergeAboutDialog(unittest.TestCase): + def setUp(self): + self.ctx = MergeContext( + lang='pl', + reference_dir=here('fixtures/en-US'), + localization_dir=here('fixtures/pl') + ) + + try: + self.ctx.add_reference('aboutDialog.ftl') + self.ctx.add_localization('aboutDialog.dtd') + except RuntimeError: + self.skipTest('compare-locales required') + + self.ctx.add_transforms('aboutDialog.ftl', [ + FTL.Message( + id=FTL.Identifier('update-failed'), + value=CONCAT( + LITERAL_FROM('aboutDialog.dtd', 'update.failed.start'), + LITERAL(''), + LITERAL_FROM('aboutDialog.dtd', 'update.failed.linkText'), + LITERAL(''), + LITERAL_FROM('aboutDialog.dtd', 'update.failed.end'), + ) + ), + FTL.Message( + id=FTL.Identifier('channel-desc'), + value=CONCAT( + LITERAL_FROM( + 'aboutDialog.dtd', 'channel.description.start' + ), + EXTERNAL('channelname'), + LITERAL_FROM('aboutDialog.dtd', 'channel.description.end'), + ) + ), + FTL.Message( + id=FTL.Identifier('community'), + value=CONCAT( + REPLACE_FROM( + 'aboutDialog.dtd', + 'community.start', + { + '&brandShortName;': FTL.ExternalArgument( + id=FTL.Identifier('brand-short-name') + ) + } + ), + LITERAL(''), + REPLACE_FROM( + 'aboutDialog.dtd', + 'community.mozillaLink', + { + '&vendorBrandShortName;': FTL.ExternalArgument( + id=FTL.Identifier('vendor-short-name') + ) + } + ), + LITERAL(''), + LITERAL_FROM('aboutDialog.dtd', 'community.middle'), + LITERAL(''), + LITERAL_FROM('aboutDialog.dtd', 'community.creditsLink'), + LITERAL(''), + LITERAL_FROM('aboutDialog.dtd', 'community.end') + ) + ), + ]) + + @unittest.skip('Parser/Serializer trim whitespace') + def test_merge_context_all_messages(self): + expected = { + 'aboutDialog.ftl': ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + update-failed = Aktualizacja się nie powiodła. Pobierz. + channel-desc = Obecnie korzystasz z kanału { $channelname }. + community = Program { $brand-short-name } został opracowany przez organizację { $vendor-short-name }, która jest globalną społecznością, starającą się zapewnić, by… + ''') + } + + self.assertDictEqual( + to_json(self.ctx.merge_changeset()), + expected + ) + + def test_merge_context_some_messages(self): + changeset = { + ('aboutDialog.dtd', 'update.failed.start'), + } + + expected = { + 'aboutDialog.ftl': ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of the Mozilla Public + // License, v. 2.0. If a copy of the MPL was not distributed with this + // file, You can obtain one at http://mozilla.org/MPL/2.0/. + + update-failed = Aktualizacja się nie powiodła. Pobierz. + ''') + } + + self.assertDictEqual( + to_json(self.ctx.merge_changeset(changeset)), + expected + ) diff --git a/tests/migrate/test_literal.py b/tests/migrate/test_literal.py new file mode 100644 index 00000000..da065563 --- /dev/null +++ b/tests/migrate/test_literal.py @@ -0,0 +1,137 @@ +# coding=utf8 +from __future__ import unicode_literals + +import unittest + +import fluent.syntax.ast as FTL +try: + from compare_locales.parser import PropertiesParser, DTDParser +except ImportError: + PropertiesParser = DTDParser = None + +from fluent.migrate.util import parse, ftl_message_to_json +from fluent.migrate.transforms import evaluate, LITERAL_FROM + + +class MockContext(unittest.TestCase): + def get_source(self, path, key): + return self.strings.get(key, None).get_val() + + +@unittest.skipUnless(PropertiesParser, 'compare-locales required') +class TestCopy(MockContext): + def setUp(self): + self.strings = parse(PropertiesParser, ''' + foo = Foo + foo.unicode.middle = Foo\\u0020Bar + foo.unicode.begin = \\u0020Foo + foo.unicode.end = Foo\\u0020 + + foo.html.entity = <⇧⌘K> + ''') + + def test_copy(self): + msg = FTL.Message( + FTL.Identifier('foo'), + value=LITERAL_FROM(self.strings, 'foo') + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + foo = Foo + ''') + ) + + def test_copy_escape_unicode_middle(self): + msg = FTL.Message( + FTL.Identifier('foo-unicode-middle'), + value=LITERAL_FROM(self.strings, 'foo.unicode.middle') + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + foo-unicode-middle = Foo Bar + ''') + ) + + @unittest.skip('Parser/Serializer trim whitespace') + def test_copy_escape_unicode_begin(self): + msg = FTL.Message( + FTL.Identifier('foo-unicode-begin'), + value=LITERAL_FROM(self.strings, 'foo.unicode.begin') + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + foo-unicode-begin = Foo + ''') + ) + + @unittest.skip('Parser/Serializer trim whitespace') + def test_copy_escape_unicode_end(self): + msg = FTL.Message( + FTL.Identifier('foo-unicode-end'), + value=LITERAL_FROM(self.strings, 'foo.unicode.end') + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + foo-unicode-end = Foo + ''') + ) + + def test_copy_html_entity(self): + msg = FTL.Message( + FTL.Identifier('foo-html-entity'), + value=LITERAL_FROM(self.strings, 'foo.html.entity') + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + foo-html-entity = <⇧⌘K> + ''') + ) + + +@unittest.skipUnless(DTDParser, 'compare-locales required') +class TestCopyTraits(MockContext): + def setUp(self): + self.strings = parse(DTDParser, ''' + + + ''') + + def test_copy_accesskey(self): + msg = FTL.Message( + FTL.Identifier('check-for-updates'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM(self.strings, 'checkForUpdatesButton.label') + ), + FTL.Attribute( + FTL.Identifier('accesskey'), + LITERAL_FROM( + self.strings, 'checkForUpdatesButton.accesskey' + ) + ), + ] + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + check-for-updates + .label = Check for updates + .accesskey = C + ''') + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/migrate/test_merge.py b/tests/migrate/test_merge.py new file mode 100644 index 00000000..cdb61d08 --- /dev/null +++ b/tests/migrate/test_merge.py @@ -0,0 +1,470 @@ +# coding=utf8 +from __future__ import unicode_literals + +import unittest + +import fluent.syntax.ast as FTL +from fluent.syntax.parser import FluentParser +try: + from compare_locales.parser import PropertiesParser, DTDParser +except ImportError: + PropertiesParser = DTDParser = None + +from fluent.migrate.util import parse, ftl, ftl_resource_to_json +from fluent.migrate.merge import merge_resource +from fluent.migrate.transforms import LITERAL, LITERAL_FROM + + +class MockContext(unittest.TestCase): + def get_source(self, path, key): + return self.ab_cd_legacy.get(key, None).get_val() + + +@unittest.skipUnless(PropertiesParser and DTDParser, + 'compare-locales required') +class TestMergeMessages(MockContext): + def setUp(self): + self.en_us_ftl = parse(FluentParser, ftl(''' + title = Downloads + header = Your Downloads + empty = No Downloads + about = About Downloads + + open-menuitem + .label = Open + + download-state-downloading = Downloading… + ''')) + + self.ab_cd_ftl = parse(FluentParser, ftl(''' + empty = Brak pobranych plików + about = Previously Hardcoded Value + ''')) + + ab_cd_dtd = parse(DTDParser, ''' + + + ''') + + ab_cd_prop = parse(PropertiesParser, ''' + downloadState.downloading=Pobieranie… + ''') + + self.ab_cd_legacy = { + key: val + for strings in (ab_cd_dtd, ab_cd_prop) + for key, val in strings.items() + } + + self.transforms = [ + FTL.Message( + FTL.Identifier('title'), + value=LITERAL_FROM(None, 'aboutDownloads.title') + ), + FTL.Message( + FTL.Identifier('about'), + value=LITERAL('Hardcoded Value') + ), + FTL.Message( + FTL.Identifier('open-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM(None, 'aboutDownloads.open') + ), + ] + ), + FTL.Message( + FTL.Identifier('download-state-downloading'), + value=LITERAL_FROM(None, 'downloadState.downloading') + ) + ] + + def test_merge_two_way(self): + resource = merge_resource( + self, self.en_us_ftl, FTL.Resource(), self.transforms, + in_changeset=lambda x: True + ) + + self.assertEqual( + resource.to_json(), + ftl_resource_to_json(''' + title = Pobrane pliki + about = Hardcoded Value + + open-menuitem + .label = Otwórz + + download-state-downloading = Pobieranie… + ''') + ) + + def test_merge_three_way(self): + resource = merge_resource( + self, self.en_us_ftl, self.ab_cd_ftl, self.transforms, + in_changeset=lambda x: True + ) + + self.assertEqual( + resource.to_json(), + ftl_resource_to_json(''' + title = Pobrane pliki + empty = Brak pobranych plików + about = Previously Hardcoded Value + + open-menuitem + .label = Otwórz + + download-state-downloading = Pobieranie… + ''') + ) + + +@unittest.skipUnless(PropertiesParser and DTDParser, + 'compare-locales required') +class TestMergeAllEntries(MockContext): + def setUp(self): + self.en_us_ftl = parse(FluentParser, ftl(''' + // This Source Code Form is subject to the terms of … + + // A generic comment. + + title = Downloads + header = Your Downloads + empty = No Downloads + + // A section comment. + [[ Menu items ]] + + // A message comment. + open-menuitem + .label = Open + + download-state-downloading = Downloading… + ''')) + + self.ab_cd_ftl = parse(FluentParser, ftl(''' + // This Source Code Form is subject to the terms of … + + empty = Brak pobranych plików + ''')) + + ab_cd_dtd = parse(DTDParser, ''' + + + ''') + + ab_cd_prop = parse(PropertiesParser, ''' + downloadState.downloading=Pobieranie… + ''') + + self.ab_cd_legacy = { + key: val + for strings in (ab_cd_dtd, ab_cd_prop) + for key, val in strings.items() + } + + self.transforms = [ + FTL.Message( + FTL.Identifier('title'), + value=LITERAL_FROM(None, 'aboutDownloads.title') + ), + FTL.Message( + FTL.Identifier('open-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM(None, 'aboutDownloads.open') + ), + ] + ), + FTL.Message( + FTL.Identifier('download-state-downloading'), + value=LITERAL_FROM(None, 'downloadState.downloading') + ) + ] + + def test_merge_two_way(self): + resource = merge_resource( + self, self.en_us_ftl, FTL.Resource(), self.transforms, + in_changeset=lambda x: True + ) + + self.assertEqual( + resource.to_json(), + ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of … + + // A generic comment. + + title = Pobrane pliki + + // A section comment. + [[ Menu items ]] + + // A message comment. + open-menuitem + .label = Otwórz + download-state-downloading = Pobieranie… + + ''') + ) + + def test_merge_three_way(self): + resource = merge_resource( + self, self.en_us_ftl, self.ab_cd_ftl, self.transforms, + in_changeset=lambda x: True + ) + + self.assertEqual( + resource.to_json(), + ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of … + + // A generic comment. + + title = Pobrane pliki + empty = Brak pobranych plików + + // A section comment. + [[ Menu items ]] + + // A message comment. + open-menuitem + .label = Otwórz + + download-state-downloading = Pobieranie… + + ''') + ) + + +@unittest.skipUnless(PropertiesParser and DTDParser, + 'compare-locales required') +class TestMergeSubset(MockContext): + def setUp(self): + self.en_us_ftl = parse(FluentParser, ftl(''' + // This Source Code Form is subject to the terms of … + + // A generic comment. + + title = Downloads + header = Your Downloads + empty = No Downloads + + // A section comment. + [[ Menu items ]] + + // A message comment. + open-menuitem + .label = Open + + download-state-downloading = Downloading… + ''')) + + ab_cd_dtd = parse(DTDParser, ''' + + + ''') + + ab_cd_prop = parse(PropertiesParser, ''' + downloadState.downloading=Pobieranie… + ''') + + self.ab_cd_legacy = { + key: val + for strings in (ab_cd_dtd, ab_cd_prop) + for key, val in strings.items() + } + + self.transforms = [ + FTL.Message( + FTL.Identifier('title'), + value=LITERAL_FROM(None, 'aboutDownloads.title') + ), + FTL.Message( + FTL.Identifier('download-state-downloading'), + value=LITERAL_FROM(None, 'downloadState.downloading') + ) + ] + + def test_two_way_one_entity(self): + subset = ('title',) + resource = merge_resource( + self, self.en_us_ftl, FTL.Resource(), self.transforms, + in_changeset=lambda x: x in subset + ) + + self.assertEqual( + resource.to_json(), + ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of … + + // A generic comment. + + title = Pobrane pliki + + // A section comment. + [[ Menu items ]] + ''') + ) + + def test_two_way_two_entities(self): + subset = ('title', 'download-state-downloading') + resource = merge_resource( + self, self.en_us_ftl, FTL.Resource(), self.transforms, + in_changeset=lambda x: x in subset + ) + + self.assertEqual( + resource.to_json(), + ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of … + + // A generic comment. + + title = Pobrane pliki + + // A section comment. + [[ Menu items ]] + + download-state-downloading = Pobieranie… + ''') + ) + + def test_three_way_one_entity(self): + ab_cd_ftl = parse(FluentParser, ftl(''' + // This Source Code Form is subject to the terms of … + + empty = Brak pobranych plików + ''')) + + subset = ('title',) + resource = merge_resource( + self, self.en_us_ftl, ab_cd_ftl, self.transforms, + in_changeset=lambda x: x in subset + ) + + self.assertEqual( + resource.to_json(), + ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of … + + // A generic comment. + + title = Pobrane pliki + empty = Brak pobranych plików + + // A section comment. + [[ Menu items ]] + ''') + ) + + def test_three_way_two_entities(self): + ab_cd_ftl = parse(FluentParser, ftl(''' + // This Source Code Form is subject to the terms of … + + empty = Brak pobranych plików + ''')) + + subset = ('title', 'download-state-downloading') + resource = merge_resource( + self, self.en_us_ftl, ab_cd_ftl, self.transforms, + in_changeset=lambda x: x in subset + ) + + self.assertEqual( + resource.to_json(), + ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of … + + // A generic comment. + + title = Pobrane pliki + empty = Brak pobranych plików + + // A section comment. + [[ Menu items ]] + + download-state-downloading = Pobieranie… + ''') + ) + + def test_three_way_one_entity_existing_section(self): + ab_cd_ftl = parse(FluentParser, ftl(''' + // This Source Code Form is subject to the terms of … + + empty = Brak pobranych plików + + // A section comment. + [[ Menu items ]] + + // A message comment. + open-menuitem + .label = Otwórz + ''')) + + subset = ('title',) + resource = merge_resource( + self, self.en_us_ftl, ab_cd_ftl, self.transforms, + in_changeset=lambda x: x in subset + ) + + self.assertEqual( + resource.to_json(), + ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of … + + // A generic comment. + + title = Pobrane pliki + empty = Brak pobranych plików + + // A section comment. + [[ Menu items ]] + + // A message comment. + open-menuitem + .label = Otwórz + ''') + ) + + def test_three_way_two_entities_existing_section(self): + ab_cd_ftl = parse(FluentParser, ftl(''' + // This Source Code Form is subject to the terms of … + + empty = Brak pobranych plików + + // A section comment. + [[ Menu items ]] + + // A message comment. + open-menuitem + .label = Otwórz + ''')) + + subset = ('title', 'download-state-downloading') + resource = merge_resource( + self, self.en_us_ftl, ab_cd_ftl, self.transforms, + in_changeset=lambda x: x in subset + ) + + self.assertEqual( + resource.to_json(), + ftl_resource_to_json(''' + // This Source Code Form is subject to the terms of … + + // A generic comment. + + title = Pobrane pliki + empty = Brak pobranych plików + + // A section comment. + [[ Menu items ]] + + // A message comment. + open-menuitem + .label = Otwórz + download-state-downloading = Pobieranie… + ''') + ) diff --git a/tests/migrate/test_plural.py b/tests/migrate/test_plural.py new file mode 100644 index 00000000..11ddd0c9 --- /dev/null +++ b/tests/migrate/test_plural.py @@ -0,0 +1,121 @@ +# coding=utf8 +from __future__ import unicode_literals + +import unittest + +import fluent.syntax.ast as FTL +try: + from compare_locales.parser import PropertiesParser +except ImportError: + PropertiesParser = None + +from fluent.migrate.util import parse, ftl_message_to_json +from fluent.migrate.transforms import evaluate, LITERAL, PLURALS_FROM, REPLACE + + +class MockContext(unittest.TestCase): + # Static categories corresponding to en-US. + plural_categories = ('one', 'other') + + def get_source(self, path, key): + return self.strings.get(key, None).get_val() + + +@unittest.skipUnless(PropertiesParser, 'compare-locales required') +class TestPlural(MockContext): + def setUp(self): + self.strings = parse(PropertiesParser, ''' + deleteAll=Delete this download?;Delete all downloads? + ''') + + self.message = FTL.Message( + FTL.Identifier('delete-all'), + value=PLURALS_FROM( + self.strings, + 'deleteAll', + FTL.ExternalArgument( + id=FTL.Identifier('num') + ), + lambda var: LITERAL(var) + ) + ) + + def test_plural(self): + self.assertEqual( + evaluate(self, self.message).to_json(), + ftl_message_to_json(''' + delete-all = + { $num -> + [one] Delete this download? + *[other] Delete all downloads? + } + ''') + ) + + def test_plural_too_few_variants(self): + self.plural_categories = ('one', 'few', 'many', 'other') + self.assertEqual( + evaluate(self, self.message).to_json(), + ftl_message_to_json(''' + delete-all = + { $num -> + [one] Delete this download? + *[few] Delete all downloads? + } + ''') + ) + + def test_plural_too_many_variants(self): + self.plural_categories = ('one',) + self.assertEqual( + evaluate(self, self.message).to_json(), + ftl_message_to_json(''' + delete-all = + { $num -> + *[one] Delete this download? + } + ''') + ) + + +@unittest.skipUnless(PropertiesParser, 'compare-locales required') +class TestPluralReplace(MockContext): + def setUp(self): + self.strings = parse(PropertiesParser, ''' + deleteAll=Delete this download?;Delete #1 downloads? + ''') + + def test_plural_replace(self): + msg = FTL.Message( + FTL.Identifier('delete-all'), + value=PLURALS_FROM( + self.strings, + 'deleteAll', + FTL.ExternalArgument( + id=FTL.Identifier('num') + ), + lambda var: REPLACE( + var, + { + '#1': FTL.ExternalArgument( + id=FTL.Identifier('num') + ) + } + ) + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + delete-all = + { $num -> + [one] Delete this download? + *[other] Delete { $num } downloads? + } + ''') + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/migrate/test_replace.py b/tests/migrate/test_replace.py new file mode 100644 index 00000000..69e77eb1 --- /dev/null +++ b/tests/migrate/test_replace.py @@ -0,0 +1,168 @@ +# coding=utf8 +from __future__ import unicode_literals + +import unittest + +import fluent.syntax.ast as FTL +try: + from compare_locales.parser import PropertiesParser +except ImportError: + PropertiesParser = None + +from fluent.migrate.util import parse, ftl_message_to_json +from fluent.migrate.transforms import evaluate, REPLACE_FROM + + +class MockContext(unittest.TestCase): + def get_source(self, path, key): + return self.strings.get(key, None).get_val() + + +@unittest.skipUnless(PropertiesParser, 'compare-locales required') +class TestReplace(MockContext): + def setUp(self): + self.strings = parse(PropertiesParser, ''' + hello = Hello, #1! + welcome = Welcome, #1, to #2! + first = #1 Bar + last = Foo #1 + ''') + + def test_replace_one(self): + msg = FTL.Message( + FTL.Identifier(u'hello'), + value=REPLACE_FROM( + self.strings, + 'hello', + { + '#1': FTL.ExternalArgument( + id=FTL.Identifier('username') + ) + } + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + hello = Hello, { $username }! + ''') + ) + + def test_replace_two(self): + msg = FTL.Message( + FTL.Identifier(u'welcome'), + value=REPLACE_FROM( + self.strings, + 'welcome', + { + '#1': FTL.ExternalArgument( + id=FTL.Identifier('username') + ), + '#2': FTL.ExternalArgument( + id=FTL.Identifier('appname') + ) + } + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + welcome = Welcome, { $username }, to { $appname }! + ''') + ) + + def test_replace_too_many(self): + msg = FTL.Message( + FTL.Identifier(u'welcome'), + value=REPLACE_FROM( + self.strings, + 'welcome', + { + '#1': FTL.ExternalArgument( + id=FTL.Identifier('username') + ), + '#2': FTL.ExternalArgument( + id=FTL.Identifier('appname') + ), + '#3': FTL.ExternalArgument( + id=FTL.Identifier('extraname') + ) + } + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + welcome = Welcome, { $username }, to { $appname }! + ''') + ) + + def test_replace_too_few(self): + msg = FTL.Message( + FTL.Identifier(u'welcome'), + value=REPLACE_FROM( + self.strings, + 'welcome', + { + '#1': FTL.ExternalArgument( + id=FTL.Identifier('username') + ) + } + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + welcome = Welcome, { $username }, to #2! + ''') + ) + + def test_replace_first(self): + msg = FTL.Message( + FTL.Identifier(u'first'), + value=REPLACE_FROM( + self.strings, + 'first', + { + '#1': FTL.ExternalArgument( + id=FTL.Identifier('foo') + ) + } + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + first = { $foo } Bar + ''') + ) + + def test_replace_last(self): + msg = FTL.Message( + FTL.Identifier(u'last'), + value=REPLACE_FROM( + self.strings, + 'last', + { + '#1': FTL.ExternalArgument( + id=FTL.Identifier('bar') + ) + } + ) + ) + + self.assertEqual( + evaluate(self, msg).to_json(), + ftl_message_to_json(''' + last = Foo { $bar } + ''') + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/migrate/test_util.py b/tests/migrate/test_util.py new file mode 100644 index 00000000..036f88e3 --- /dev/null +++ b/tests/migrate/test_util.py @@ -0,0 +1,83 @@ +# coding=utf8 +from __future__ import unicode_literals + +import unittest + +import fluent.syntax.ast as FTL +from fluent.util import fold +from fluent.migrate.transforms import CONCAT, LITERAL_FROM, SOURCE + + +def get_source(acc, cur): + if isinstance(cur, SOURCE): + return acc + ((cur.path, cur.key),) + return acc + + +class TestTraverse(unittest.TestCase): + def test_copy_concat(self): + node = FTL.Message( + FTL.Identifier('hello'), + value=CONCAT( + LITERAL_FROM('path1', 'key1'), + LITERAL_FROM('path2', 'key2') + ) + ) + + result = node.traverse(lambda x: x) + + self.assertEqual( + result.value.patterns[0].key, + 'key1' + ) + self.assertEqual( + result.value.patterns[1].key, + 'key2' + ) + + +class TestReduce(unittest.TestCase): + def test_copy_value(self): + node = FTL.Message( + id=FTL.Identifier('key'), + value=LITERAL_FROM('path', 'key') + ) + + self.assertEqual( + fold(get_source, node, ()), + (('path', 'key'),) + ) + + def test_copy_traits(self): + node = FTL.Message( + id=FTL.Identifier('key'), + attributes=[ + FTL.Attribute( + FTL.Identifier('trait1'), + value=LITERAL_FROM('path1', 'key1') + ), + FTL.Attribute( + FTL.Identifier('trait2'), + value=LITERAL_FROM('path2', 'key2') + ) + ] + ) + + self.assertEqual( + fold(get_source, node, ()), + (('path1', 'key1'), ('path2', 'key2')) + ) + + def test_copy_concat(self): + node = FTL.Message( + FTL.Identifier('hello'), + value=CONCAT( + LITERAL_FROM('path1', 'key1'), + LITERAL_FROM('path2', 'key2') + ) + ) + + self.assertEqual( + fold(get_source, node, ()), + (('path1', 'key1'), ('path2', 'key2')) + ) diff --git a/tools/migrate/about_dialog.py b/tools/migrate/about_dialog.py new file mode 100644 index 00000000..11a1252a --- /dev/null +++ b/tools/migrate/about_dialog.py @@ -0,0 +1,86 @@ +# coding=utf8 + +import fluent.syntax.ast as FTL +from fluent.migrate import CONCAT, EXTERNAL, LITERAL, LITERAL_FROM, REPLACE_FROM + + +def migrate(ctx): + """Migrate about:dialog, part {index}""" + + ctx.add_reference('browser/aboutDialog.ftl', realpath='aboutDialog.ftl') + ctx.add_localization('browser/chrome/browser/aboutDialog.dtd') + + ctx.add_transforms('browser/aboutDialog.ftl', [ + FTL.Entity( + id=FTL.Identifier('update-failed'), + value=CONCAT( + LITERAL_FROM( + 'browser/chrome/browser/aboutDialog.dtd', + 'update.failed.start' + ), + LITERAL(''), + LITERAL_FROM( + 'browser/chrome/browser/aboutDialog.dtd', + 'update.failed.linkText' + ), + LITERAL(''), + LITERAL_FROM( + 'browser/chrome/browser/aboutDialog.dtd', + 'update.failed.end' + ) + ) + ), + FTL.Entity( + id=FTL.Identifier('channel-desc'), + value=CONCAT( + LITERAL_FROM( + 'browser/chrome/browser/aboutDialog.dtd', + 'channel.description.start' + ), + EXTERNAL('channelname'), + LITERAL_FROM( + 'browser/chrome/browser/aboutDialog.dtd', + 'channel.description.end' + ) + ) + ), + FTL.Entity( + id=FTL.Identifier('community'), + value=CONCAT( + REPLACE_FROM( + 'browser/chrome/browser/aboutDialog.dtd', + 'community.start2', + { + '&brandShortName;': FTL.ExternalArgument( + id=FTL.Identifier('brand-short-name') + ) + } + ), + LITERAL(''), + REPLACE_FROM( + 'browser/chrome/browser/aboutDialog.dtd', + 'community.mozillaLink', + { + '&vendorShortName;': FTL.ExternalArgument( + id=FTL.Identifier('vendor-short-name') + ) + } + ), + LITERAL(''), + LITERAL_FROM( + 'browser/chrome/browser/aboutDialog.dtd', + 'community.middle2' + ), + LITERAL(''), + LITERAL_FROM( + 'browser/chrome/browser/aboutDialog.dtd', + 'community.creditsLink' + ), + LITERAL(''), + LITERAL_FROM( + 'browser/chrome/browser/aboutDialog.dtd', + 'community.end3' + ) + ) + ), + ]) diff --git a/tools/migrate/about_downloads.py b/tools/migrate/about_downloads.py new file mode 100644 index 00000000..62675ef3 --- /dev/null +++ b/tools/migrate/about_downloads.py @@ -0,0 +1,190 @@ +# coding=utf8 + +import fluent.syntax.ast as FTL +from fluent.migrate import LITERAL_FROM, PLURALS_FROM, REPLACE + + +def migrate(ctx): + """Migrate about:download in Firefox for Android, part {index}""" + + ctx.add_reference( + 'mobile/aboutDownloads.ftl', + realpath='aboutDownloads.ftl' + ) + ctx.add_localization('mobile/android/chrome/aboutDownloads.dtd') + ctx.add_localization('mobile/android/chrome/aboutDownloads.properties') + + ctx.add_transforms('mobile/aboutDownloads.ftl', [ + FTL.Entity( + id=FTL.Identifier('title'), + value=LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.dtd', + 'aboutDownloads.title' + ) + ), + FTL.Entity( + id=FTL.Identifier('header'), + value=LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.dtd', + 'aboutDownloads.header' + ) + ), + FTL.Entity( + id=FTL.Identifier('empty'), + value=LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.dtd', + 'aboutDownloads.empty' + ) + ), + FTL.Entity( + id=FTL.Identifier('open-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.dtd', + 'aboutDownloads.open' + ) + ) + ] + ), + FTL.Entity( + id=FTL.Identifier('retry-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.dtd', + 'aboutDownloads.retry' + ) + ) + ] + ), + FTL.Entity( + id=FTL.Identifier('remove-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.dtd', + 'aboutDownloads.remove' + ) + ) + ] + ), + FTL.Entity( + id=FTL.Identifier('pause-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.dtd', + 'aboutDownloads.pause' + ) + ) + ] + ), + FTL.Entity( + id=FTL.Identifier('resume-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.dtd', + 'aboutDownloads.resume' + ) + ) + ] + ), + FTL.Entity( + id=FTL.Identifier('cancel-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.dtd', + 'aboutDownloads.cancel' + ) + ) + ] + ), + FTL.Entity( + id=FTL.Identifier('remove-all-menuitem'), + attributes=[ + FTL.Attribute( + FTL.Identifier('label'), + LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.dtd', + 'aboutDownloads.removeAll' + ) + ) + ] + ), + FTL.Entity( + id=FTL.Identifier('delete-all-title'), + value=LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.properties', + 'downloadAction.deleteAll' + ) + ), + FTL.Entity( + id=FTL.Identifier('delete-all-message'), + value=PLURALS_FROM( + 'mobile/android/chrome/aboutDownloads.properties', + 'downloadMessage.deleteAll', + FTL.ExternalArgument( + id=FTL.Identifier('num') + ), + lambda var: REPLACE( + var, + { + '#1': FTL.ExternalArgument( + id=FTL.Identifier('num') + ) + } + ) + ) + ), + FTL.Entity( + id=FTL.Identifier('download-state-downloading'), + value=LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.properties', + 'downloadState.downloading' + ) + ), + FTL.Entity( + id=FTL.Identifier('download-state-canceled'), + value=LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.properties', + 'downloadState.canceled' + ) + ), + FTL.Entity( + id=FTL.Identifier('download-state-failed'), + value=LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.properties', + 'downloadState.failed' + ) + ), + FTL.Entity( + id=FTL.Identifier('download-state-paused'), + value=LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.properties', + 'downloadState.paused' + ) + ), + FTL.Entity( + id=FTL.Identifier('download-state-starting'), + value=LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.properties', + 'downloadState.starting' + ) + ), + FTL.Entity( + id=FTL.Identifier('download-size-unknown'), + value=LITERAL_FROM( + 'mobile/android/chrome/aboutDownloads.properties', + 'downloadState.unknownSize' + ) + ), + ]) diff --git a/tools/migrate/blame.py b/tools/migrate/blame.py new file mode 100644 index 00000000..69726b09 --- /dev/null +++ b/tools/migrate/blame.py @@ -0,0 +1,52 @@ +import argparse +import os +import json +import hglib +from hglib.util import b, cmdbuilder +from compare_locales.parser import getParser + + +class Blame(object): + def __init__(self, repopath): + self.client = hglib.open(repopath) + self.users = [] + self.blame = {} + + def main(self): + for manifestline in self.client.manifest(): + leaf = manifestline[-1] + self.handleFile(leaf) + return {'authors': self.users, + 'blame': self.blame} + + def handleFile(self, leaf): + try: + parser = getParser(leaf) + except UserWarning: + return + args = cmdbuilder(b('annotate'), d=True, u=True, T='json', + *['path:' + leaf]) + blame_json = ''.join(self.client.rawcommand(args)) + blames = json.loads(blame_json) + fname = os.path.join(self.client.root(), leaf) + parser.readFile(fname) + entities, emap = parser.parse() + self.blame[leaf] = {} + for e in entities: + blines = blames[(e.value_position()[0] - 1):e.value_position(-1)[0]] + blines.sort(key=lambda blame: -blame['date'][0]) # ignore timezone + blame = blines[0] + user = blame['user'] + timestamp = blame['date'][0] # ignore timezone + if user not in self.users: + self.users.append(user) + userid = self.users.index(user) + self.blame[leaf][e.key] = [userid, timestamp] + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument("repopath") + args = parser.parse_args() + blame = Blame(args.repopath) + blimey = blame.main() + print(json.dumps(blimey, indent=4, separators=(',', ': '))) diff --git a/tools/migrate/brand.ftl b/tools/migrate/brand.ftl new file mode 100644 index 00000000..db54421f --- /dev/null +++ b/tools/migrate/brand.ftl @@ -0,0 +1,13 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +brand-shorter-name = Firefox +brand-short-name = Firefox +brand-full-name = Mozilla Firefox +vendor-short-name = Mozilla + +trademark-info = + Firefox and the Firefox logos are trademarks of the Mozilla Foundation. + +sync-brand-short-name = Sync diff --git a/tools/migrate/menubar.ftl b/tools/migrate/menubar.ftl new file mode 100644 index 00000000..c4a2ab59 --- /dev/null +++ b/tools/migrate/menubar.ftl @@ -0,0 +1,336 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +[[ File menu ]] + +file-menu + .label = File + .accesskey = F +tab-menuitem + .label = New Tab + .accesskey = T +tab-command + .key = t +new-user-context-menu + .label = New Container Tab + .accesskey = C +new-navigator-menuitem + .label = New Window + .accesskey = N +new-navigator-command + .key = N +new-private-window-menuitem + .label = New Private Window + .accesskey = W +new-non-remote-window-menuitem + .label = New Non-e10s Window + +// Only displayed on OS X, and only on windows that aren't main browser windows, +// or when there are no windows but Firefox is still running. +open-location-menuitem + .label = Open Location… +open-file-menuitem + .label = Open File… + .accesskey = O +open-file-command + .key = o + +close-menuitem + .label = Close + .accesskey = C +close-command + .key = W +close-window-menuitem + .label = Close Window + .accesskey = d + +// .accesskey2 is for content area context menu +save-page-menuitem + .label = Save Page As… + .accesskey = A + .accesskey2 = P +save-page-command + .key = s + +email-page-menuitem + .label = Email Link… + .accesskey = E + +print-setup-menuitem + .label = Page Setup… + .accesskey = u +print-preview-menuitem + .label = Print Preview… + .accesskey = v +print-menuitem + .label = Print… + .accesskey = P +print-command + .key = p + +go-offline-menuitem + .label = Work Offline + .accesskey = k + +quit-application-menuitem + .label = Quit + .accesskey = Q +quit-application-menuitem-win + .label = Exit + .accesskey = x +quit-application-menuitem-mac + .label = Quit { brand-shorter-name } +// Used by both Linux and OSX builds +quit-application-command-unix + .key = Q + +[[ Edit menu ]] + +edit-menu + .label = Edit + .accesskey = E +undo-menuitem + .label = Undo + .accesskey = U +undo-command + .key = Z +redo-menuitem + .label = Redo + .accesskey = R +redo-command + .key = Y +cut-menuitem + .label = Cut + .accesskey = t +cut-command + .key = X +copy-menuitem + .label = Copy + .accesskey = C +copy-command + .key = C +paste-menuitem + .label = Paste + .accesskey = P +paste-command + .key = V +delete-menuitem + .label = Delete + .accesskey = D +select-all-menuitem + .label = Select All + .accesskey = A +select-all-command + .key = A + +find-on-menuitem + .label = Find in This Page… + .accesskey = F +find-on-command + .key = f +find-again-menuitem + .label = Find Again + .accesskey = g +find-again-command1 + .key = g +find-again-command2 + .keycode = VK_F3 +find-selection-command + .key = e + +bidi-switch-text-direction-menuitem + .label = Switch Text Direction + .accesskey = w +bidi-switch-text-direction-command + .key = X + +preferences-menuitem + .label = Options + .accesskey = O +preferences-menuitem-unix + .label = Preferences + .accesskey = n + + +[[ View menu ]] + +view-menu + .label = View + .accesskey = V +view-toolbars-menu + .label = Toolbars + .accesskey = T +view-sidebar-menu + .label = Sidebar + .accesskey = e +view-customize-toolbar-menuitem + .label = Customize… + .accesskey = C + +full-zoom-menu + .label = Zoom + .accesskey = Z +full-zoom-enlarge-menuitem + .label = Zoom In + .accesskey = I +full-zoom-enlarge-command1 + .key = + +full-zoom-enlarge-command2 + .key = +full-zoom-enlarge-command3 + .key = "" +full-zoom-reduce-menuitem + .label = Zoom Out + .accesskey = O +full-zoom-reduce-command1 + .key = - +full-zoom-reduce-command2 + .key = "" +full-zoom-reset-menuitem + .label = Reset + .accesskey = R +full-zoom-reset-command1 + .key = 0 +full-zoom-reset-command2 + .key = "" +full-zoom-toggle-menuitem + .label = Zoom Text Only + .accesskey = T + +page-style-menu + .label = Page Style + .accesskey = y +page-style-no-style-menuitem + .label = No Style + .accesskey = n +page-style-persistent-only-menuitem + .label = Basic Page Style + .accesskey = b + +show-all-tabs-menuitem + .label = Show All Tabs + .accesskey = A +bidi-switch-page-direction-menuitem + .label = Switch Page Direction + .accesskey = D + +// Match what Safari and other Apple applications use on OS X Lion. +[[ Full Screen controls ]] + +enter-full-screen-menuitem + .label = Enter Full Screen + .accesskey = F +exit-full-screen-menuitem + .label = Exit Full Screen + .accesskey = F +full-screen-menuitem + .label = Full Screen + .accesskey = F +full-screen-command + .key = f + + +[[ History menu ]] + +history-menu + .label = History + .accesskey = s +show-all-history-menuitem + .label = Show All History +show-all-history-command + .key = H +clear-recent-history-menuitem + .label = Clean Recent History… +history-synced-tabs-menuitem + .label = Synced Tabs +history-restore-last-session-menuitem + .label = Restore Previous Session +history-undo-menu + .label = Recently Closed Tabs +history-undo-window-menu + .label = Recently Closed Windows + + +[[ Bookmarks menu ]] + +bookmarks-menu + .label = Bookmarks + .accesskey = B +show-all-bookmarks-menuitem + .label = Show All Bookmarks +show-all-bookmarks-command + .key = b +// .key should not contain the letters A-F since the are reserved shortcut +// keys on Linux. +show-all-bookmarks-command-gtk + .key = o +bookmark-this-page-broadcaster + .label = Bookmark This Page +edit-this-page-broadcaster + .label = Edit This Page +bookmark-this-page-command + .key = d +subscribe-to-page-menuitem + .label = Subscribe to This Page… +subscribe-to-page-menupopup + .label = Subscribe to This Page… +add-cur-pages-menuitem + .label = Bookmark All Tabs… +recent-bookmarks-menuitem + .label = Recently Bookmarked + +other-bookmarks-menu + .label = Other Bookmarks +personalbar-menu + .label = Bookmarks Toolbar + .accesskey = B + + +[[ Tools menu ]] + +tools-menu + .label = Tools + .accesskey = T +downloads-menuitem + .label = Downloads + .accesskey = D +downloads-command + .key = j +downloads-command-unix + .key = y +addons-menuitem + .label = Add-ons + .accesskey = A +addons-command + .key = A + +sync-sign-in-menuitem + .label = Sign In To { sync-brand-short-name }… + .accesskey = Y +sync-sync-now-menuitem + .label = Sync Now + .accesskey = S +sync-re-auth-menuitem + .label = Reconnect to { sync-brand-short-name }… + .accesskey = R +sync-toolbar-button + .label = Sync + +web-developer-menu + .label = Web Developer + .accesskey = W + +page-source-broadcaster + .label = Page Source + .accesskey = o +page-source-command + .key = u +page-info-menuitem + .label = Page Info + .accesskey = I +page-info-command + .key = i +mirror-tab-menu + .label = Mirror Tab + .accesskey = m diff --git a/tools/migrate/migrate-l10n.py b/tools/migrate/migrate-l10n.py new file mode 100755 index 00000000..eda823c6 --- /dev/null +++ b/tools/migrate/migrate-l10n.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python +# coding=utf8 + +import os +import json +import argparse +import importlib + +import hglib +from hglib.util import b + +from fluent.migrate import MergeContext, convert_blame_to_changesets +from blame import Blame + + +def main(lang, reference_dir, localization_dir, blame, migrations, dry_run): + """Run migrations and commit files with the result.""" + changesets = convert_blame_to_changesets(blame) + client = hglib.open(localization_dir) + + for migration in migrations: + + print('Running migration {}'.format(migration.__name__)) + + # For each migration create a new context. + ctx = MergeContext(lang, reference_dir, localization_dir) + + # Add the migration spec. + migration.migrate(ctx) + + # Keep track of how many changesets we're committing. + index = 0 + + for changeset in changesets: + # Run the migration. + snapshot = ctx.serialize_changeset(changeset['changes']) + + # The current changeset didn't touch any of the translations + # affected by the migration. + if not snapshot: + continue + + # Write serialized FTL files to disk. + for path, content in snapshot.iteritems(): + fullpath = os.path.join(localization_dir, path) + print(' Writing to {}'.format(fullpath)) + if not dry_run: + with open(fullpath, 'w') as f: + f.write(content.encode('utf8')) + f.close() + + index += 1 + author = changeset['author'].encode('utf8') + message = migration.migrate.__doc__.format( + index=index, + author=author + ) + + print(' Committing changeset: {}'.format(message)) + if not dry_run: + client.commit( + b(message), user=b(author), addremove=True + ) + +if __name__ == '__main__': + parser = argparse.ArgumentParser( + description='Migrate translations to FTL.' + ) + parser.add_argument( + 'migrations', metavar='MIGRATION', type=str, nargs='+', + help='migrations to run (Python modules)' + ) + parser.add_argument( + '--lang', type=str, + help='target language code' + ) + parser.add_argument( + '--reference-dir', type=str, + help='directory with reference FTL files' + ) + parser.add_argument( + '--localization-dir', type=str, + help='directory for localization files' + ) + parser.add_argument( + '--blame', type=argparse.FileType(), default=None, + help='path to a JSON with blame information' + ) + parser.add_argument( + '--dry-run', action='store_true', + help='do not write to disk nor commit any changes' + ) + parser.set_defaults(dry_run=False) + + args = parser.parse_args() + + if args.blame: + # Load pre-computed blame from a JSON file. + blame = json.load(args.blame) + else: + # Compute blame right now. + print('Annotating {}'.format(args.localization_dir)) + blame = Blame(args.localization_dir).main() + + main( + lang=args.lang, + reference_dir=args.reference_dir, + localization_dir=args.localization_dir, + blame=blame, + migrations=map(importlib.import_module, args.migrations), + dry_run=args.dry_run + ) diff --git a/tools/migrate/toolbar.ftl b/tools/migrate/toolbar.ftl new file mode 100644 index 00000000..1d056553 --- /dev/null +++ b/tools/migrate/toolbar.ftl @@ -0,0 +1,24 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +urlbar-textbox + .placeholder = Search or enter address + .accesskey = d + + +[[ Toolbar items ]] + +view-bookmarks-broadcaster + .label = Bookmarks +view-bookmarks-command + .key = b +view-bookmarks-command-win + .key = i + +view-history-broadcaster + .label = History +view-history-command + .key = h +view-tabs-broadcaster + .label = Synced Tabs From 7a318366f516f7be3218518adf2d37fe0c70ac04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sta=C5=9B=20Ma=C5=82olepszy?= Date: Fri, 12 May 2017 14:59:43 +0200 Subject: [PATCH 4/4] Port changes proposed by Pike in bug 1291693 --- fluent/migrate/context.py | 6 +++++- tools/migrate/blame.py | 8 ++++++-- tools/migrate/migrate-l10n.py | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/fluent/migrate/context.py b/fluent/migrate/context.py index 1af4d26a..9953cc5e 100644 --- a/fluent/migrate/context.py +++ b/fluent/migrate/context.py @@ -51,7 +51,11 @@ def __init__(self, lang, reference_dir, localization_dir): # An iterable of plural category names relevant to the context's # language. E.g. ('one', 'other') for English. - self.plural_categories = get_plural_categories(lang) + try: + self.plural_categories = get_plural_categories(lang) + except RuntimeError as e: + print(e.message) + self.plural_categories = 'en' # Paths to directories with input data, relative to CWD. self.reference_dir = reference_dir diff --git a/tools/migrate/blame.py b/tools/migrate/blame.py index 69726b09..32142d9f 100644 --- a/tools/migrate/blame.py +++ b/tools/migrate/blame.py @@ -3,7 +3,7 @@ import json import hglib from hglib.util import b, cmdbuilder -from compare_locales.parser import getParser +from compare_locales.parser import getParser, Junk class Blame(object): @@ -33,7 +33,11 @@ def handleFile(self, leaf): entities, emap = parser.parse() self.blame[leaf] = {} for e in entities: - blines = blames[(e.value_position()[0] - 1):e.value_position(-1)[0]] + if isinstance(e, Junk): + continue + blines = blames[ + (e.value_position()[0] - 1):e.value_position(-1)[0] + ] blines.sort(key=lambda blame: -blame['date'][0]) # ignore timezone blame = blines[0] user = blame['user'] diff --git a/tools/migrate/migrate-l10n.py b/tools/migrate/migrate-l10n.py index eda823c6..6420991a 100755 --- a/tools/migrate/migrate-l10n.py +++ b/tools/migrate/migrate-l10n.py @@ -45,6 +45,9 @@ def main(lang, reference_dir, localization_dir, blame, migrations, dry_run): fullpath = os.path.join(localization_dir, path) print(' Writing to {}'.format(fullpath)) if not dry_run: + fulldir = os.path.dirname(fullpath) + if not os.path.isdir(fulldir): + os.makedirs(fulldir) with open(fullpath, 'w') as f: f.write(content.encode('utf8')) f.close()