Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(passkit): Work towards localizable pass files #40

Merged
merged 2 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions passkit/lib/src/passkit/pkpass.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'package:passkit/src/passkit/pass_data.dart';
import 'package:passkit/src/passkit/pass_type.dart';
import 'package:passkit/src/passkit/personalization.dart';
import 'package:passkit/src/passkit/pk_pass_image.dart';
import 'package:passkit/src/strings_parser/naive_strings_file_parser.dart';

/// Dart uses a special fast decoder when using a fused [Utf8Decoder] and [JsonDecoder].
/// This speeds up decoding.
Expand Down Expand Up @@ -47,6 +48,7 @@ class PkPass {
this.personalizationLogo,
});

/// Parses bytes to a [PkPass] file.
static PkPass fromBytes(final List<int> bytes) {
Map<String, dynamic>? manifestJson;
Map<String, dynamic>? passJson;
Expand Down Expand Up @@ -93,12 +95,7 @@ class PkPass {
final background = _loadImage(archive, 'background');
final personalizationLogo = _loadImage(archive, 'personalizationLogo');

Map<String, Map<String, String>> availableTranslations = {};

for (final folder in archive.files.where((element) => !element.isFile)) {
final languageName = folder.name.split('.').first;
availableTranslations[languageName] = {};
}
final availableTranslations = _getTranslations(archive);

return PkPass(
pass: PassData.fromJson(passJson!),
Expand All @@ -114,6 +111,7 @@ class PkPass {
personalization: personalizationJson == null
? null
: Personalization.fromJson(personalizationJson),
languageData: availableTranslations,
);
}

Expand All @@ -134,6 +132,25 @@ class PkPass {
);
}

static Map<String, Map<String, String>> _getTranslations(Archive archive) {
final languageData = <String, Map<String, String>>{};

// The Archive object doesn't have APIs to work with folders.
// Instead the file name contains a `/` indicating the file is within a folder.
// Example: `file.name == en.lproj/pass.strings`
final translationFiles = archive.files
.where((element) => element.isFile)
.where((file) => file.name.endsWith('.lproj/pass.strings'));

for (final languageFile in translationFiles) {
final language = languageFile.name.split('.').first;

languageData[language] =
parseStringsFile(languageFile.content as List<int>);
}
return languageData;
}

final PassData pass;

final Map<String, dynamic> manifest;
Expand Down
41 changes: 41 additions & 0 deletions passkit/lib/src/strings_parser/naive_strings_file_parser.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import 'dart:convert';

/// Parses [content] to a [Map<String, String>] which contains the
/// key-value-pairs for translations.
Map<String, String> parseStringsFile(List<int> content) {
final string = _stringsFileDecoder.convert(content);
return naiveStringsFileParser(string);
}

// TODO(ueman): `.strings` files should be read as UTF-16, not UTF-8.
final Converter<List<int>, String> _stringsFileDecoder = const Utf8Decoder();

/// Here's a breakdown of the pattern:
/// - r'"((?:\"|[^"])*)"': This section matches the key, which is enclosed in
/// double quotes. Inside the quotes, it captures any character sequence that
/// is either a backslash-escaped double quote or any character that is not a
/// double quote.
/// - \s?=\s?: This part matches the equals sign with optional whitespace before
/// and after it.
/// - r'"((?:\"|[^"])*)"': This section matches the value, using a similar
/// pattern to capture any character sequence within double quotes.
/// - \s?;: This last part matches optional whitespace followed by a semicolon.
final _stringsParserRegEx =
RegExp(r'"((?:\\"|[^"])*)"\s?=\s?"((?:\\"|[^"])*)"\s?;');

/// This method uses a quite naive approach to parse Apples `strings` file
/// format with a [RegExp]. It doesn't support placeholders.
///
/// The returned map has key value pairs.
///
/// See also:
/// - https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingResources/Strings/Strings.html
/// - https://localizely.com/apple-strings-file/
Map<String, String> naiveStringsFileParser(String stringsFile) {
final matches = _stringsParserRegEx
.allMatches(stringsFile)
.where((match) => match.group(1) != null && match.group(2) != null)
.map((match) => MapEntry(match.group(1)!, match.group(2)!));

return Map.fromEntries(matches);
}
34 changes: 34 additions & 0 deletions passkit/test/strings_file_parser_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import 'package:passkit/src/strings_parser/naive_strings_file_parser.dart';
import 'package:test/test.dart';

// Taken from https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html
final _testString = '''"origin_SVQ" = "Sevilla";
"destination_LHR" = "Londres";
''';

// Taken from https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/LoadingResources/Strings/Strings.html
final _testStringWithComments = '''/* Insert Element menu item */
"Insert Element" = "Insert Element";
/* Error string used for unknown error types. */
"ErrorString_1" = "An unknown error occurred.";
''';

void main() {
test('naiveStringsFileParser() parses example correctly', () {
final keyValue = naiveStringsFileParser(_testString);
expect(keyValue, {
'origin_SVQ': 'Sevilla',
'destination_LHR': 'Londres',
});
});

test(
'naiveStringsFileParser() parsess example correctly despite comments in it',
() {
final keyValue = naiveStringsFileParser(_testStringWithComments);
expect(keyValue, {
'Insert Element': 'Insert Element',
'ErrorString_1': 'An unknown error occurred.',
});
});
}