Skip to content

Commit

Permalink
feat(passkit): Work towards localizable pass files (#40)
Browse files Browse the repository at this point in the history
* localization

* add translation file parsing
  • Loading branch information
ueman authored Jul 5, 2024
1 parent ce9d136 commit 4ec5e76
Show file tree
Hide file tree
Showing 3 changed files with 98 additions and 6 deletions.
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 @@ -48,6 +49,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 @@ -94,12 +96,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 @@ -115,6 +112,7 @@ class PkPass {
personalization: personalizationJson == null
? null
: Personalization.fromJson(personalizationJson),
languageData: availableTranslations,
);
}

Expand Down Expand Up @@ -149,6 +147,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.',
});
});
}

0 comments on commit 4ec5e76

Please sign in to comment.