From a46ac59b5aa947995558cac735a29d1fc150de9f Mon Sep 17 00:00:00 2001 From: Marcelo Glasberg <13332110+marcglasberg@users.noreply.github.com> Date: Thu, 16 Nov 2023 18:24:03 -0300 Subject: [PATCH] Importer library independently available as a standalone package. --- CHANGELOG.md | 9 ++ README.md | 38 ++++-- analysis_options.yaml | 3 +- pubspec.yaml | 10 +- test/getStrings/getstrings.dart | 37 ----- test/getStrings/i18n_getstrings.dart | 137 ------------------- test/getStrings/io/export.dart | 66 --------- test/getStrings/io/import.dart | 101 -------------- test/i18n_getstrings_test.dart | 193 --------------------------- test/import_test.dart | 44 ------ 10 files changed, 39 insertions(+), 599 deletions(-) delete mode 100644 test/getStrings/getstrings.dart delete mode 100644 test/getStrings/i18n_getstrings.dart delete mode 100644 test/getStrings/io/export.dart delete mode 100644 test/getStrings/io/import.dart delete mode 100644 test/i18n_getstrings_test.dart delete mode 100644 test/import_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index deccb06..3cddfff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## [10.0.0] - 2023/11/16 + +* Up to version 8.0.0, the i18n_extension package contained the importer library developed by Johann + Bauer. This importer library has been separated and is now independently available as a standalone + package. You can find it at: https://pub.dev/packages/i18n_extension_importer. This new package + offers capabilities for importing translations in both `.PO` and `.JSON` formats. + It also includes the `GetStrings` exporting utility, which is a useful script designed to + automate the export of all translatable strings from your project. + ## [9.0.2] - 2023/05/12 * Flutter 3.10 e Dart 3.0.0 diff --git a/README.md b/README.md index 4aa0fb5..ff014d7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![pub package](https://img.shields.io/pub/v/i18n_extension.svg)](https://pub.dartlang.org/packages/i18n_extension) # i18n_extension
No boilerplate Translation and Internationalization + > IntelliJ plugin that supports this package coming soon:
https://plugins.jetbrains.com/plugin/21898-marcelo-s-flutter-dart-essentials @@ -669,8 +670,8 @@ I18n.observeLocale = ### Importing and exporting -This package is optimized so that you can easily create and manage all of your translations -yourself, by hand. +The `i18n_extension` package is optimized so that you can easily create and manage all of your +translations yourself, by hand. However, for large projects with big teams you probably need to follow a more involved process: @@ -708,11 +709,18 @@ The following formats may be used with translations: #### Importing +Up to version `8.0.0`, the `i18n_extension` package contained the importer library. +It has now been separated and is now independently available as a standalone package. +You can find it at: https://pub.dev/packages/i18n_extension_importer. + +**Note:** Those importers were contributed by Johann Bauer, +and were separated into its own package by Xiang Li. + Currently, only `.PO` and `.JSON` importers are supported out-of-the-box. +If you want to help creating importers for any of the other formats above, please PR there. -**Note:** Those importers were contributed by Johann Bauer. -If you want to help creating importers for any of the other formats above, please PR -here: https://github.com/marcglasberg/i18n_extension. +It also includes the `GetStrings` exporting utility, which is a useful script designed to +automate the export of all translatable strings from your project. Add your translation files as assets to your app in a directory structure like this: @@ -728,7 +736,7 @@ app Then you can import them using `GettextImporter` or `JSONImporter`: ``` -import 'package:i18n_extension/io/import.dart'; +import 'package:i18n_extension_importer/io/import.dart'; import 'package:i18n_extension/i18n_extension.dart'; class MyI18n { @@ -757,17 +765,19 @@ codes, you'll get errors like this: `There are no translations in 'en_us' for "H **Note:** If you need to import any other custom format, remember importing is easy to do because the Translation constructors use maps as input. If you can generate a map from your file format, you -can then use the `Translation()` -or `Translation.byLocale()` constructors to create the translation objects. +can then use the `Translation()` or `Translation.byLocale()` constructors to create the translation +objects. #### The GetStrings exporting utility -An utility script to automatically export all translatable strings from your project was contributed -by Johann Bauer. Simply -run `flutter pub run i18n_extension:getstrings` in your project root directory and you will get a -list of strings to translate in `strings.json`. This file can then be sent to your translators or be -imported in translation services like _Crowdin_, _Transifex_ or _Lokalise_. You can use it as part -of your CI pipeline in order to always have your translation templates up to date. +A utility script to automatically export all translatable strings from your project was also +contributed by Johann Bauer. + +Simply run `flutter pub run i18n_extension_importer:getstrings` in your project root directory, and +you will get a list of strings to translate in `strings.json`. This file can then be sent to your +translators or be imported in translation services like _Crowdin_, _Transifex_ or _Lokalise_. You +can use it as part of your CI pipeline in order to always have your translation templates up to +date. Note the tool simply searches the source code for strings to which getters like `.i18n` are applied. Since it is not very smart, you should not make it too hard: diff --git a/analysis_options.yaml b/analysis_options.yaml index 33231ee..a433af5 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -35,10 +35,9 @@ linter: - empty_statements - hash_and_equals - implementation_imports - - iterable_contains_unrelated_type + - collection_methods_unrelated_type - library_names - library_prefixes - - list_remove_unrelated_type - no_duplicate_case_values - overridden_fields - package_api_docs diff --git a/pubspec.yaml b/pubspec.yaml index c1658f5..f514c1a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,23 +1,23 @@ name: i18n_extension description: Translation and Internationalization (i18n) for Flutter. Easy to use for both large and small projects. Uses Dart extensions to reduce boilerplate. -version: 9.0.2 +version: 10.0.0 homepage: https://github.com/marcglasberg/i18n_extension # author: Marcelo Glasberg environment: - sdk: '>=2.19.0 <4.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: sprintf: ^7.0.0 - args: ^2.3.1 + args: ^2.4.2 equatable: ^2.0.5 intl: ^0.18.0 flutter: sdk: flutter dev_dependencies: - analyzer: ^5.3.1 - gettext_parser: ^0.2.0 flutter_test: sdk: flutter + test: ^1.24.9 + diff --git a/test/getStrings/getstrings.dart b/test/getStrings/getstrings.dart deleted file mode 100644 index c698023..0000000 --- a/test/getStrings/getstrings.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:io'; - -import 'package:args/args.dart'; - -import 'i18n_getstrings.dart'; -import 'io/export.dart'; - -void main(List arguments) async { - const OUTPUT_FILE = "output-file"; - const SOURCE_DIR = "source-dir"; - - var parser = ArgParser() - ..addOption(OUTPUT_FILE, - abbr: "f", - defaultsTo: "strings.pot", - valueHelp: "Supported formats: ${exporters.keys.join(", ")}") - ..addOption(SOURCE_DIR, abbr: "s", defaultsTo: "./lib"); - - ArgResults results = parser.parse(arguments); - - String outputFilename = results[OUTPUT_FILE]; - var fileFormat = outputFilename.split(".").last; - if (!exporters.containsKey(fileFormat)) { - print("Unable to write to $outputFilename."); - print("Supported formats: ${exporters.keys.join(", ")}"); - exit(1); - } - - String? sourceDir = results[SOURCE_DIR]; - List strings = GetI18nStrings(sourceDir).run(); - - var outputFile = File(outputFilename); - await outputFile.create(); - exporters[fileFormat]!(strings).exportTo(outputFile); - - print("Wrote ${strings.length} strings to template $outputFilename"); -} diff --git a/test/getStrings/i18n_getstrings.dart b/test/getStrings/i18n_getstrings.dart deleted file mode 100644 index 1caed29..0000000 --- a/test/getStrings/i18n_getstrings.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'dart:io'; - -import 'package:analyzer/dart/analysis/utilities.dart'; -import 'package:analyzer/dart/ast/ast.dart'; -import 'package:analyzer/dart/ast/token.dart'; -import 'package:analyzer/dart/ast/visitor.dart'; -import 'package:equatable/equatable.dart'; - -enum I18nRequiredModifiers { plural } - -class I18nSuffixes { - static const String i18n = 'i18n'; - static const String fill = 'fill'; - static const String plural = 'plural'; - static const String version = 'version'; - static const String allVersions = 'allVersions'; - - static const List allSuffixes = const [ - i18n, - fill, - plural, - version, - allVersions, - ]; -} - -class ExtractedString extends Equatable { - final String string; - final int lineNumber; - final String sourceFile; - final bool pluralRequired; - - ExtractedString(this.string, this.lineNumber, - {this.pluralRequired = false, this.sourceFile = ""}); - - @override - List get props => [string, sourceFile, lineNumber, pluralRequired]; -} - -class DecodedSyntax { - DecodedSyntax._({ - required this.valid, - this.modifiers = const [], - }); - - DecodedSyntax.valid(List modifiers) - : this._(valid: true, modifiers: modifiers); - - DecodedSyntax.invalid() : this._(valid: false); - - final bool valid; - final List modifiers; -} - -class GetI18nStrings { - final String? sourceDir; - - GetI18nStrings( - this.sourceDir, - ); - - List run() { - var libDir = Directory(sourceDir!); - List sourceStrings = []; - for (var f in libDir.listSync(recursive: true)) { - if (f is File && f.path.endsWith(".dart")) { - sourceStrings += processFile(f); - } - } - return sourceStrings; - } - - List processFile(File f) { - return processString(f.readAsStringSync(), fileName: f.path); - } - - List processString(String s, {fileName = ""}) { - CompilationUnit unit = parseString(content: s, throwIfDiagnostics: false).unit; - var extractor = StringExtractor(I18nSuffixes.allSuffixes, s, fileName); - unit.visitChildren(extractor); - return extractor.strings; - } -} - -class StringExtractor extends UnifyingAstVisitor { - List strings = []; - List suffixes; - final String source; - final String fileName; - - StringExtractor(this.suffixes, this.source, this.fileName); - - @override - void visitSimpleStringLiteral(SimpleStringLiteral node) { - final DecodedSyntax syntax = _hasI18nSyntax(node, node.parent!); - _handleI18nSyntax(syntax, node); - return super.visitSimpleStringLiteral(node); - } - - @override - void visitAdjacentStrings(AdjacentStrings node) { - final DecodedSyntax syntax = _hasI18nSyntax(node.strings.last, node.parent!); - _handleI18nSyntax(syntax, node); - - // Don't call the super method here, since we don't want to visit the - // child strings. - } - - void _handleI18nSyntax(DecodedSyntax syntax, StringLiteral node) { - if (syntax.valid && node.stringValue != null) { - var lineNo = "\n".allMatches(source.substring(0, node.offset)).length + 1; - final ExtractedString s = ExtractedString(node.stringValue!, lineNo, - pluralRequired: syntax.modifiers.contains(I18nRequiredModifiers.plural), - sourceFile: fileName); - strings.add(s); - } - } - - /// Check if the next sibling in the AST is a DOT operator - /// and after that comes a literal included in our suffixes. - DecodedSyntax _hasI18nSyntax(AstNode self, AstNode parent) { - Token? here = parent.beginToken; - while (here != null && here != parent.endToken) { - if (here == self.beginToken && - here.next!.type.lexeme == "." && - suffixes.contains(here.next!.next!.value())) { - List modifiers = List.empty(growable: true); - if (here.next!.next!.value() == I18nSuffixes.plural) { - modifiers.add(I18nRequiredModifiers.plural); - } - return DecodedSyntax.valid(modifiers); - } - here = here.next; - } - return DecodedSyntax.invalid(); - } -} diff --git a/test/getStrings/io/export.dart b/test/getStrings/io/export.dart deleted file mode 100644 index 679de17..0000000 --- a/test/getStrings/io/export.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:gettext_parser/gettext_parser.dart' as gettext_parser; - -import '../i18n_getstrings.dart'; - -Map exporters = { - "pot": (s) => GettextExporter(s), - "json": (s) => JsonExporter(s) -}; - -abstract class Exporter { - HashMap getTemplate() { - HashMap template = HashMap(); - for (var s in sourceStrings) { - template[s.string] = ""; - } - return template; - } - - List sourceStrings; - Exporter(this.sourceStrings); - Future exportTo(File target); -} - -class GettextExporter extends Exporter { - GettextExporter(List sourceStrings) : super(sourceStrings); - - @override - Future exportTo(File target) async { - Map> template = {}; - - for (var string in sourceStrings) { - template[string.string] = { - "msgid": string.string, - "comments": {"reference": "${string.sourceFile}:${string.lineNumber}"}, - }; - template[string.string]?["msgstr"] = [""]; - if (string.pluralRequired) { - template[string.string]?["msgid_plural"] = string.string; - } - } - - Map out = { - "charset": "utf-8", - "headers": { - "content-type": "text/plain; charset=utf-8; nplurals=2; plural=(n != 1)" - }, - "translations": {"": template} - }; - - await target.writeAsString(gettext_parser.po.compile(out)); - } -} - -class JsonExporter extends Exporter { - JsonExporter(List sourceStrings) : super(sourceStrings); - - @override - Future exportTo(File target) async { - JsonEncoder encoder = JsonEncoder.withIndent(' ' * 4); - await target.writeAsString(encoder.convert(getTemplate())); - } -} diff --git a/test/getStrings/io/import.dart b/test/getStrings/io/import.dart deleted file mode 100644 index 1921cc1..0000000 --- a/test/getStrings/io/import.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; - -import 'package:flutter/services.dart' show rootBundle; -import 'package:gettext_parser/gettext_parser.dart' as gettext_parser; - -// - -abstract class Importer { - String get _extension; - - Map _load(String source); - - Future>> fromAssetFile( - String language, String fileName) async { - return {language: _load(await rootBundle.loadString(fileName))}; - } - - Future>> fromAssetDirectory( - String dir) async { - var manifestContent = await rootBundle.loadString("AssetManifest.json"); - Map manifestMap = json.decode(manifestContent); - - Map> translations = HashMap(); - - for (String path in manifestMap.keys) { - if (!path.startsWith(dir)) continue; - var fileName = path.split("/").last; - if (!fileName.endsWith(_extension)) { - print("➜ Ignoring file $path with unexpected file type " - "(expected: $_extension)."); - continue; - } - var languageCode = fileName.split(".")[0]; - translations.addAll(await fromAssetFile(languageCode, path)); - } - - return translations; - } - - Future>> fromString( - String language, String source) async { - return {language: _load(source)}; - } -} - -// - -class JSONImporter extends Importer { - @override - String get _extension => ".json"; - - @override - Map _load(String source) { - return Map.from(json.decode(source)); - } -} - -// -const _splitter1 = "\uFFFF"; -const _splitter2 = "\uFFFE"; - -class GettextImporter extends Importer { - @override - String get _extension => ".po"; - - @override - Map _load(String source) { - Map out = {}; - Map translations = gettext_parser.po.parse(source)["translations"]; - for (Map context in translations.values) { - for (Map translation in context.values) { - if (translation.isNotEmpty) { - if (translation["msgstr"].length == 1) { - String? msgstr = translation["msgstr"][0]; - if (msgstr != null && msgstr.isNotEmpty) { - String? msgid = translation["msgid"]; - if (msgid != null && msgid.isNotEmpty) out[msgid] = msgstr; - } - } else { - String? msgid = translation["msgid"]; - if (msgid != null && msgid.isNotEmpty) - out[msgid] = _splitter1 + - translation["msgstr"][0] + - _splitter1 + - '1' + - _splitter2 + - translation["msgstr"][0] + - _splitter1 + - 'M' + - _splitter2 + - translation["msgstr"][1]; - } - } - } - } - return out; - } -} - -// diff --git a/test/i18n_getstrings_test.dart b/test/i18n_getstrings_test.dart deleted file mode 100644 index 2d12c99..0000000 --- a/test/i18n_getstrings_test.dart +++ /dev/null @@ -1,193 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'getStrings/i18n_getstrings.dart'; - -void main() { - test("Simple case", () { - var source = """ - void main() { - print("This is a test".i18n); - print("This should not match"); - print('This is another %s test'.fill('great')); - print('This should not match'); - } - """; - var results = GetI18nStrings("").processString(source); - expect(results, [ - ExtractedString("This is a test", 2), - ExtractedString("This is another %s test", 4), - ]); - }); - - test("Triple-quoted strings", () { - var source = """ - void main() { - print("\""This is a -"test" "\"".i18n); - print(""\"This should not match"\""); - print('\''This is another test'\''.i18n); - print('\''This should not match'\''); - } - """; - var results = GetI18nStrings("").processString(source); - expect(results, [ - ExtractedString("This is a\n\"test\" ", 2), - ExtractedString("This is another test", 5), - ]); - }); - - test("Invalid Dart", () { - var source = """ - var y = '''.i18n; - var z = '''Hello''''; - """; - var results = GetI18nStrings("").processString(source); - expect(results, []); - }); - - test("Plurals in .POT", () { - var source = """ - return Padding( - padding: const EdgeInsets.all(16), - child: TranslatableRichText( - Text( - '''%s order on %s'''.plural(_orders!.length).fill( - [ - _orders!.length.toStringAsFixed( - 0, - ), - formattedPickup - ], - ), - ), - richTexts: [ - BaseRichText( - text: '%s order' - .plural(_orders!.length) - .fill([_orders!.length.toStringAsFixed(0)]), - style: AppTextStyles.vocalOrderBoldTitle.copyWith( - color: AppColors.vocalOrderErrorColor, - ), - ), - ], - ), - ); - """; - var results = GetI18nStrings("").processString(source); - expect(results, [ - ExtractedString('%s order on %s', 5, pluralRequired: true), - ExtractedString('%s order', 16, pluralRequired: true), - ]); - }); - - test("Real-world example", () { - var source = """ - import 'package:flutter/material.dart'; -import 'package:settings_ui/settings_ui.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:paperless_app/i18n.dart'; - -final _scaffoldKey = GlobalKey(); - -class SettingsRoute extends StatefulWidget { - SettingsRoute({Key key}) : super(key: key); - - @override - _SettingsRouteState createState() => _SettingsRouteState(); -} - -class _SettingsRouteState extends State { - bool invertDocumentPreview = true; - SharedPreferences prefs; - _SettingsRouteState(); - - - @override - void initState() { - loadPreferences(); - } - - void loadPreferences() async { - prefs = await SharedPreferences.getInstance(); - setState(() { - invertDocumentPreview = prefs.getBool("invert_document_preview") ?? true; - }); -} - - @override - Widget build(BuildContext context) { - return Scaffold( - key: _scaffoldKey, - body: SettingsList( - sections: [ - SettingsSection( - title: 'View'.i18n, - tiles: [ - SettingsTile.switchTile( - title: 'Invert Document Preview in Dark Mode'.i18n, - leading: Icon(Icons.invert_colors), - switchValue: invertDocumentPreview, - onToggle: (bool value) { - prefs.setBool("invert_document_preview", value); - setState(() { - invertDocumentPreview = value; - }); - }, - ), - ], - ), - ], - ), - ); - } -}"""; - var results = GetI18nStrings("").processString(source); - expect(results, [ - ExtractedString('View', 40), - ExtractedString('Invert Document Preview in Dark Mode', 43), - ]); - }); - - test("Multi-line statements", () { - var source = """ - var y = 'mysamplestring %s' - .i18n - .fill("test"); - """; - var results = GetI18nStrings("").processString(source); - expect(results, [ExtractedString("mysamplestring %s", 1)]); - }); - - test("Simple case with comments", () { - var source = """ - void main() { - print("This is a test".i18n); - // You will find it doesn't work past this point - print("This should not match"); - print('This is another %s test'.fill('great')); - print('This should not match'); - } - """; - var results = GetI18nStrings("").processString(source); - expect(results, [ - ExtractedString("This is a test", 2), - ExtractedString("This is another %s test", 5), - ]); - }); - - test("Simple case with adjacent strings", () { - var source = """ - var text = "This should be a single string, " - "hopefully it doesn't just recognise " - "the last part.".i18n; - var toxt = "This will not be translated, " - "so it shouldn't be recognised " - "at all."; - """; - var results = GetI18nStrings("").processString(source); - expect(results, [ - ExtractedString( - "This should be a single string, hopefully it doesn't just recognise the last part.", 1), - ]); - }); -} diff --git a/test/import_test.dart b/test/import_test.dart deleted file mode 100644 index a082a8b..0000000 --- a/test/import_test.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:i18n_extension/i18n_extension.dart'; - -import 'getStrings/io/import.dart'; - -void main() { - test("Import JSON translation", () async { - var jsonSource = File("test/fixtures/de_DE.json").readAsStringSync(); - var translation = await JSONImporter().fromString("de", jsonSource); - var myTranslations = Translations.byLocale("en_gb") + translation; - expect(myTranslations.translations.length, 32); - expect(localize("View", myTranslations, locale: "de"), "Ansicht"); - }); - - test("Import PO translation", () async { - var poSource = File("test/fixtures/strings-de.po").readAsStringSync(); - var translation = await GettextImporter().fromString("de", poSource); - var myTranslations = Translations.byLocale("en_gb") + translation; - expect(myTranslations.translations.length, 30); - expect(localize("View", myTranslations, locale: "de"), "Ansicht"); - expect(localizePlural(0, "Error while uploading document", myTranslations, locale: "de"), - "Fehler beim Hochladen des Dokuments"); - expect(localizePlural(1, "Error while uploading document", myTranslations, locale: "de"), - "Fehler beim Hochladen des Dokument"); - expect(localizePlural(2, "Error while uploading document", myTranslations, locale: "de"), - "Fehler beim Hochladen des Dokuments"); - }); - - test("Import PO translation with context set", () async { - var poSource = File("test/fixtures/strings-de.po").readAsStringSync(); - var translation = await GettextImporter().fromString("de", poSource); - var myTranslations = Translations.byLocale("en_gb") + translation; - expect(myTranslations.translations.length, 30); - expect(localize("View", myTranslations, locale: "de"), "Ansicht"); - expect(localizePlural(0, "Error while uploading document", myTranslations, locale: "de"), - "Fehler beim Hochladen des Dokuments"); - expect(localizePlural(1, "Error while uploading document", myTranslations, locale: "de"), - "Fehler beim Hochladen des Dokument"); - expect(localizePlural(2, "Error while uploading document", myTranslations, locale: "de"), - "Fehler beim Hochladen des Dokuments"); - }); -}