diff --git a/packages/wyatt_i18n/example/assets/i18n.en.json b/packages/wyatt_i18n/example/assets/i18n.en.json new file mode 100644 index 00000000..f59abc87 --- /dev/null +++ b/packages/wyatt_i18n/example/assets/i18n.en.json @@ -0,0 +1,13 @@ +{ + "@@locale": "en", + "youHavePushed": "You have pushed {count} times the bouton !", + "@youHavePushed": { + "placeholders": { + "count": { + "type": "int" + } + } + }, + "btnAddFile": "Add file", + "btnAddFileCaption": "Max size: 20MB" +} \ No newline at end of file diff --git a/packages/wyatt_i18n/example/assets/i18n.en.yaml b/packages/wyatt_i18n/example/assets/i18n.en.yaml new file mode 100644 index 00000000..e894e5d6 --- /dev/null +++ b/packages/wyatt_i18n/example/assets/i18n.en.yaml @@ -0,0 +1,8 @@ +"@@locale": en +youHavePushed: You have pushed {count} times the bouton ! +"@youHavePushed": + placeholders: + count: + type: int +btnAddFile: Add file +btnAddFileCaption: "Max size: 20MB" diff --git a/packages/wyatt_i18n/example/lib/main.dart b/packages/wyatt_i18n/example/lib/main.dart index dd147bd8..ed462ce9 100644 --- a/packages/wyatt_i18n/example/lib/main.dart +++ b/packages/wyatt_i18n/example/lib/main.dart @@ -45,8 +45,12 @@ class App extends StatelessWidget { const I18nRepository repository = I18RepositoryImpl(dataSource: dataSource); + + final test = (await repository.load('en')).ok; - print((await repository.load('fr')).err); + print(test?.locale); + print(test?.data); + print(test?.data['btnAddFile']); return 'test'; }), diff --git a/packages/wyatt_i18n/lib/src/core/core.dart b/packages/wyatt_i18n/lib/src/core/core.dart index cd89b9c1..575e445b 100644 --- a/packages/wyatt_i18n/lib/src/core/core.dart +++ b/packages/wyatt_i18n/lib/src/core/core.dart @@ -1,18 +1,19 @@ // Copyright (C) 2023 WYATT GROUP // Please see the AUTHORS file for details. -// +// // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // any later version. -// +// // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. -// +// // You should have received a copy of the GNU General Public License // along with this program. If not, see . export 'enums/format.dart'; export 'exceptions/exceptions.dart'; +export 'utils/utils.dart'; diff --git a/packages/wyatt_i18n/lib/src/core/enums/format.dart b/packages/wyatt_i18n/lib/src/core/enums/format.dart index adc5bb90..7ff768a8 100644 --- a/packages/wyatt_i18n/lib/src/core/enums/format.dart +++ b/packages/wyatt_i18n/lib/src/core/enums/format.dart @@ -14,24 +14,30 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +import 'package:wyatt_i18n/src/core/utils/arb_parser.dart'; +import 'package:wyatt_i18n/src/core/utils/json_parser.dart'; +import 'package:wyatt_i18n/src/core/utils/parser.dart'; + +import 'package:wyatt_i18n/src/core/utils/yaml_parser.dart'; + +/// Enum for i18n file formats and extensions. +/// +/// This enum is used to determine the parser to use for a given i18n file. enum Format { /// JSON i18 file format. - json(['json']), + json, /// YAML i18 file format. - yaml(['yaml', 'yml']), + yaml, + yml, /// ARB i18 file format. - arb(['arb']); - - const Format(this.extensions); - - final List extensions; + arb; /// Returns the [Format] that matches the given [ext]. static Format? fromExtension(String ext) { for (final format in Format.values) { - if (format.extensions.contains(ext)) { + if (format.name == ext) { return format; } } @@ -42,4 +48,16 @@ enum Format { /// Returns the [Format] that matches the given [path]. static Format? extensionOf(String path) => fromExtension(path.split('.').last); + + Parser> get parser { + switch (this) { + case Format.json: + return const JsonParser(); + case Format.yaml: + case Format.yml: + return const YamlParser(); + case Format.arb: + return const ArbParser(); + } + } } diff --git a/packages/wyatt_i18n/lib/src/core/exceptions/exceptions.dart b/packages/wyatt_i18n/lib/src/core/exceptions/exceptions.dart index aa4ae2e3..b91aaf48 100644 --- a/packages/wyatt_i18n/lib/src/core/exceptions/exceptions.dart +++ b/packages/wyatt_i18n/lib/src/core/exceptions/exceptions.dart @@ -31,6 +31,12 @@ class NoLocaleException extends ClientException { : super('No `@@locale` key found in the source nor locale provided.'); } +/// Exception thrown when the i18n locale is not valid. +class InvalidLocaleException extends ClientException { + InvalidLocaleException(String locale, String expected) + : super('Invalid locale `$locale`. Expected `$expected`.'); +} + /// Exception thrown when the key is not found in the i18n file. class KeyNotFoundException extends ClientException { KeyNotFoundException(String key, [Map arguments = const {}]) @@ -51,3 +57,8 @@ class MalformedValueException extends ClientException { MalformedValueException(String key, String value) : super('Key `$key` references a malformed value. ($value)'); } + +class ParserException extends ClientException { + ParserException(String message, StackTrace? stackTrace) + : super('$message\n\n$stackTrace'); +} diff --git a/packages/wyatt_i18n/lib/src/core/utils/arb_parser.dart b/packages/wyatt_i18n/lib/src/core/utils/arb_parser.dart new file mode 100644 index 00000000..e796b809 --- /dev/null +++ b/packages/wyatt_i18n/lib/src/core/utils/arb_parser.dart @@ -0,0 +1,26 @@ +// Copyright (C) 2023 WYATT GROUP +// Please see the AUTHORS file for details. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import 'package:wyatt_i18n/src/core/utils/json_parser.dart'; +import 'package:wyatt_i18n/src/core/utils/parser.dart'; + +class ArbParser extends Parser> { + const ArbParser() : super(); + + /// ARB files are JSON files, so we can use the JSON parser. + @override + Map parse(String input) => const JsonParser().parse(input); +} diff --git a/packages/wyatt_i18n/lib/src/core/utils/json_parser.dart b/packages/wyatt_i18n/lib/src/core/utils/json_parser.dart new file mode 100644 index 00000000..66015bc7 --- /dev/null +++ b/packages/wyatt_i18n/lib/src/core/utils/json_parser.dart @@ -0,0 +1,33 @@ +// Copyright (C) 2023 WYATT GROUP +// Please see the AUTHORS file for details. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import 'dart:convert'; + +import 'package:wyatt_i18n/src/core/exceptions/exceptions.dart'; +import 'package:wyatt_i18n/src/core/utils/parser.dart'; + +class JsonParser extends Parser> { + const JsonParser() : super(); + + @override + Map parse(String input) { + try { + return jsonDecode(input) as Map; + } catch (e, s) { + throw ParserException(e.toString(), s); + } + } +} diff --git a/packages/wyatt_i18n/lib/src/core/utils/parser.dart b/packages/wyatt_i18n/lib/src/core/utils/parser.dart new file mode 100644 index 00000000..9ef63217 --- /dev/null +++ b/packages/wyatt_i18n/lib/src/core/utils/parser.dart @@ -0,0 +1,21 @@ +// Copyright (C) 2023 WYATT GROUP +// Please see the AUTHORS file for details. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +abstract class Parser { + const Parser(); + + O parse(I input); +} diff --git a/packages/wyatt_i18n/lib/src/core/utils/utils.dart b/packages/wyatt_i18n/lib/src/core/utils/utils.dart new file mode 100644 index 00000000..6dc64f48 --- /dev/null +++ b/packages/wyatt_i18n/lib/src/core/utils/utils.dart @@ -0,0 +1,20 @@ +// Copyright (C) 2023 WYATT GROUP +// Please see the AUTHORS file for details. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +export 'arb_parser.dart'; +export 'json_parser.dart'; +export 'parser.dart'; +export 'yaml_parser.dart'; diff --git a/packages/wyatt_i18n/lib/src/core/utils/yaml_parser.dart b/packages/wyatt_i18n/lib/src/core/utils/yaml_parser.dart new file mode 100644 index 00000000..ab3414db --- /dev/null +++ b/packages/wyatt_i18n/lib/src/core/utils/yaml_parser.dart @@ -0,0 +1,34 @@ +// Copyright (C) 2023 WYATT GROUP +// Please see the AUTHORS file for details. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +import 'package:wyatt_i18n/src/core/exceptions/exceptions.dart'; +import 'package:wyatt_i18n/src/core/utils/parser.dart'; +import 'package:yaml/yaml.dart'; + +class YamlParser extends Parser> { + const YamlParser() : super(); + + @override + Map parse(String input) { + try { + final yaml = loadYaml(input) as YamlMap; + + return yaml.map((key, value) => MapEntry(key.toString(), value)); + } catch (e, s) { + throw ParserException(e.toString(), s); + } + } +} diff --git a/packages/wyatt_i18n/lib/src/data/data_sources/assets_file_data_source_impl.dart b/packages/wyatt_i18n/lib/src/data/data_sources/assets_file_data_source_impl.dart index ad2634e7..eb2a69f2 100644 --- a/packages/wyatt_i18n/lib/src/data/data_sources/assets_file_data_source_impl.dart +++ b/packages/wyatt_i18n/lib/src/data/data_sources/assets_file_data_source_impl.dart @@ -21,10 +21,34 @@ import 'package:wyatt_i18n/src/core/enums/format.dart'; import 'package:wyatt_i18n/src/core/exceptions/exceptions.dart'; import 'package:wyatt_i18n/src/domain/data_sources/i18n_data_source.dart'; +/// {@template assets_file_data_source_impl} +/// Implementation of [I18nDataSource] that loads i18n files from the assets. +/// +/// The [basePath] is the folder where the i18n files are located. +/// The [baseName] is the name of the i18n files without the extension. +/// The [format] is the format of the i18n files. +/// +/// For example, if the i18n files are located in the `assets` and are named +/// `i18n.en.arb`, `i18n.fr.arb`, etc., then the [basePath] is `assets` and +/// the [baseName] is `i18n` and the [format] is [Format.arb]. +/// {@endtemplate} class AssetsFileDataSourceImpl extends I18nDataSource { - const AssetsFileDataSourceImpl({super.format = Format.arb}) : super(); + /// {@macro assets_file_data_source_impl} + const AssetsFileDataSourceImpl({ + this.basePath = 'assets', + this.baseName = 'i18n', + super.format = Format.arb, + }) : super(); + + /// The folder where the i18n files are located. + final String basePath; + + /// The name of the i18n files without the extension. + final String baseName; /// Checks if the given [assetPath] is a local asset. + /// + /// In fact, this method loads the asset and checks if it is null. Future assetExists(String assetPath) async { final encoded = utf8.encoder.convert( Uri(path: Uri.encodeFull(assetPath)).path, @@ -35,48 +59,65 @@ class AssetsFileDataSourceImpl extends I18nDataSource { return asset != null; } - Future _tryLoad(String basePath) async { + /// Tries to load the i18n file from the given [locale]. + Future _tryLoad(String? locale) async { String? content; + final ext = format.name; + final path = locale == null + ? '$basePath/$baseName.$ext' + : '$basePath/$baseName.$locale.$ext'; try { - for (final ext in super.format.extensions) { - if (await assetExists('$basePath.$ext')) { - content = await rootBundle.loadString('$basePath.$ext'); - } + if (await assetExists(path)) { + content = await rootBundle.loadString(path); } } catch (_) { - throw SourceNotFoundException(basePath, format: format); + content = null; } + /// If the i18n file is not found, then we try to load the + /// default i18n file. + if (content == null && locale != null) { + try { + content = await rootBundle.loadString('$basePath/$baseName.$ext'); + } catch (_) { + throw SourceNotFoundException(path, format: format); + } + } + + /// If the default i18n file is not found, then we throw an exception. + /// This case should happen only if the locale is null. if (content == null) { - throw SourceNotFoundException(basePath, format: format); + throw SourceNotFoundException(path, format: format); } return content; } - Future _load(String locale) async { + /// Tries to load the i18n file from a given [uri]. + /// This method is used when the [uri] is not null. + Future _tryLoadUri(Uri uri) async { String? content; - try { - content = await _tryLoad('assets/i18n.$locale'); + content = await rootBundle.loadString(uri.toString()); } catch (_) { - content = await _tryLoad('assets/i18n'); + throw SourceNotFoundException(uri.toString(), format: format); } - /// Content can't be null at this point. - /// Because if it is, previous [_tryLoad] calls would have t - /// hrown an exception. - return content!; + return content; } /// Loads the i18n file from Assets folder. /// - /// The i18n file must be named `i18n..` and must - /// be specified in the `pubspec.yaml` file. + /// The i18n file must be in [basePath], named [baseName] + + /// `..` and must be specified in the `pubspec.yaml` file. @override - Future load(String locale) async { - final content = await _load(locale); + Future load({required String? locale}) async => _tryLoad(locale); - return content; - } + /// Loads the i18n file from Assets folder. + /// + /// If the [uri] is not null, then we try to load the i18n file + /// from the given [uri]. In this case, the [basePath] and the + /// [baseName] are ignored. And there is no fallback. + @override + Future loadFrom(Uri uri) async => _tryLoadUri(uri); } diff --git a/packages/wyatt_i18n/lib/src/data/data_sources/network_data_source_impl.dart b/packages/wyatt_i18n/lib/src/data/data_sources/network_data_source_impl.dart index 44964fd1..64280681 100644 --- a/packages/wyatt_i18n/lib/src/data/data_sources/network_data_source_impl.dart +++ b/packages/wyatt_i18n/lib/src/data/data_sources/network_data_source_impl.dart @@ -19,10 +19,16 @@ import 'package:wyatt_i18n/src/domain/data_sources/i18n_data_source.dart'; class NetworkDataSourceImpl extends I18nDataSource { const NetworkDataSourceImpl({super.format = Format.arb}) : super(); - + @override - Future load(String locale) { - // TODO(wyatt): implement load from network + Future load({required String? locale}) { + // TODO(wyatt): implement load + throw UnimplementedError(); + } + + @override + Future loadFrom(Uri uri) { + // TODO(wyatt): implement loadFrom throw UnimplementedError(); } } diff --git a/packages/wyatt_i18n/lib/src/data/repositories/i18_repository_impl.dart b/packages/wyatt_i18n/lib/src/data/repositories/i18_repository_impl.dart index 4ae477da..3bd2a38d 100644 --- a/packages/wyatt_i18n/lib/src/data/repositories/i18_repository_impl.dart +++ b/packages/wyatt_i18n/lib/src/data/repositories/i18_repository_impl.dart @@ -15,9 +15,7 @@ // along with this program. If not, see . import 'package:wyatt_architecture/wyatt_architecture.dart'; -import 'package:wyatt_i18n/src/domain/data_sources/i18n_data_source.dart'; -import 'package:wyatt_i18n/src/domain/entities/i18n.dart'; -import 'package:wyatt_i18n/src/domain/repositories/i18n_repository.dart'; +import 'package:wyatt_i18n/wyatt_i18n.dart'; import 'package:wyatt_type_utils/wyatt_type_utils.dart'; class I18RepositoryImpl extends I18nRepository { @@ -27,17 +25,98 @@ class I18RepositoryImpl extends I18nRepository { final I18nDataSource dataSource; + Future _parse( + String content, + Parser> parser, { + String? locale, + bool strict = false, + }) async { + final parsed = parser.parse(content); + + String parsedLocale; + + /// Checks if the locale is present in the parsed data. + /// If not, throws an exception. + /// If yes, sets the locale to the parsed locale. + if (parsed.containsKey('@@locale')) { + if (strict) { + /// Checks if the parsed locale is a string. + if (parsed['@@locale'] is! String) { + throw NoLocaleException(); + } + + /// Checks if the parsed locale is the same as the given locale. + if (locale != null && parsed['@@locale'] as String != locale) { + throw InvalidLocaleException(locale, parsed['@@locale'] as String); + } + } + parsedLocale = parsed['@@locale'] as String; + } else { + if (strict) { + /// Throws an exception if the locale is not present in the parsed data. + throw NoLocaleException(); + } else { + /// Sets the locale to the given locale. + /// If the given locale is null, sets the locale to 'null'. + /// This is done to prevent the locale from being null. + /// It should never be null. + parsedLocale = locale ?? 'null'; + } + } + + return I18n( + locale: parsedLocale, + unparsedData: content, + data: parsed, + ); + } + @override - FutureOrResult load(String locale) async => + FutureOrResult load({ + required String? locale, + bool strict = false, + Parser>? parser, + }) async => await Result.tryCatchAsync( () async { - final content = await dataSource.load(locale); - // TODO: Parse the content into a Map and return it. + final content = await dataSource.load(locale: locale); - return I18n(locale: locale, - unparsedData: content, - data: {},); + return _parse( + content, + parser ?? dataSource.format.parser, + locale: locale, + strict: strict, + ); }, (error) => error, ); + + @override + FutureOrResult loadFrom( + Uri uri, { + Parser>? parser, + }) async => + await Result.tryCatchAsync( + () async { + final content = await dataSource.loadFrom(uri); + + /// Strict is always true when loading from a uri. Because + /// the locale is not given and can't be inferred. + return _parse( + content, + parser ?? dataSource.format.parser, + strict: true, + ); + }, + (error) => error, + ); + + @override + Result get( + String key, [ + Map arguments = const {}, + ]) { + // TODO: implement get + throw UnimplementedError(); + } } diff --git a/packages/wyatt_i18n/lib/src/domain/data_sources/i18n_data_source.dart b/packages/wyatt_i18n/lib/src/domain/data_sources/i18n_data_source.dart index 7020ca8f..3421387b 100644 --- a/packages/wyatt_i18n/lib/src/domain/data_sources/i18n_data_source.dart +++ b/packages/wyatt_i18n/lib/src/domain/data_sources/i18n_data_source.dart @@ -28,5 +28,12 @@ abstract class I18nDataSource extends BaseDataSource { final Format format; /// Loads the i18n file from the source. - Future load(String locale); + /// If [locale] is not `null`, it will load the file with the given [locale]. + /// Otherwise, it will load the file from the default location. + Future load({required String? locale}); + + /// Loads the i18n file from the source. + /// + /// This method is used to load the i18n file from the given [Uri]. + Future loadFrom(Uri uri); } diff --git a/packages/wyatt_i18n/lib/src/domain/repositories/i18n_repository.dart b/packages/wyatt_i18n/lib/src/domain/repositories/i18n_repository.dart index aaf84b76..7716713a 100644 --- a/packages/wyatt_i18n/lib/src/domain/repositories/i18n_repository.dart +++ b/packages/wyatt_i18n/lib/src/domain/repositories/i18n_repository.dart @@ -15,12 +15,37 @@ // along with this program. If not, see . import 'package:wyatt_architecture/wyatt_architecture.dart'; +import 'package:wyatt_i18n/src/core/utils/parser.dart'; import 'package:wyatt_i18n/src/domain/entities/i18n.dart'; +import 'package:wyatt_type_utils/wyatt_type_utils.dart'; /// Base class for i18n repositories. abstract class I18nRepository extends BaseRepository { const I18nRepository() : super(); /// Loads the i18n file from the source. - FutureOrResult load(String locale); + /// If [strict] is `true`, it will throw an NoLocaleException if the + /// `@@locale` key is not found in the i18n file, otherwise it will + /// set the locale to the given [locale]. + /// If [parser] is not `null`, it will use the given parser to parse + /// the i18n file. Otherwise, it will use the default parser for the format. + FutureOrResult load({ + required String? locale, + bool strict = false, + Parser>? parser, + }); + + /// Loads the i18n file from the given [uri]. + /// If [parser] is not `null`, it will use the given parser to parse + /// the i18n file. Otherwise, it will use the default parser for the format. + FutureOrResult loadFrom( + Uri uri, { + Parser>? parser, + }); + + /// Gets the translation for the given [key]. + Result get( + String key, [ + Map arguments = const {}, + ]); } diff --git a/packages/wyatt_i18n/pubspec.yaml b/packages/wyatt_i18n/pubspec.yaml index 5f04ca90..9124a7cd 100644 --- a/packages/wyatt_i18n/pubspec.yaml +++ b/packages/wyatt_i18n/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: flutter: {sdk: flutter} path: ^1.8.0 + petitparser: ^5.1.0 wyatt_architecture: hosted: url: https://git.wyatt-studio.fr/api/packages/Wyatt-FOSS/pub/ @@ -24,6 +25,7 @@ dependencies: url: https://git.wyatt-studio.fr/api/packages/Wyatt-FOSS/pub/ name: wyatt_type_utils version: 0.0.4 + yaml: ^3.1.1 dev_dependencies: flutter_test: {sdk: flutter}