Files
plezy/scripts/generate_iso_639_data.dart

240 lines
7.5 KiB
Dart

/// Generates lib/data/iso_639_data.dart from the curated offline ISO 639 catalog.
///
/// Usage:
/// dart run scripts/generate_iso_639_data.dart [input.json] [output.dart]
library;
import 'dart:convert';
import 'dart:io';
const defaultIso639Input = 'scripts/data/iso_639_codes.json';
const defaultIso639Output = 'lib/data/iso_639_data.dart';
final class Iso639CatalogEntry {
const Iso639CatalogEntry({
required this.primary,
required this.terminology,
required this.bibliographic,
required this.name,
});
final String primary;
final String terminology;
final String? bibliographic;
final String name;
}
final class Iso639Catalog {
const Iso639Catalog({required this.entries});
final List<Iso639CatalogEntry> entries;
}
typedef AtomicFileWriter = Future<void> Function(String path, String contents);
Iso639Catalog parseIso639Catalog(String source) {
final Object? decoded;
try {
decoded = jsonDecode(source);
} on FormatException catch (error) {
throw FormatException('Invalid ISO 639 catalog JSON: ${error.message}');
}
final root = _expectMap(decoded, 'catalog');
if (root['schemaVersion'] != 1) {
throw const FormatException('ISO 639 catalog schemaVersion must be 1');
}
final languages = _expectMap(root['languages'], 'languages');
if (languages.isEmpty) {
throw const FormatException('ISO 639 catalog languages must not be empty');
}
final entries = <Iso639CatalogEntry>[];
final allCodes = <String>{};
String? previousPrimary;
for (final catalogEntry in languages.entries) {
final key = catalogEntry.key;
if (!_isPrimaryCode(key)) {
throw FormatException('Language key must be exactly two lowercase letters: $key');
}
if (previousPrimary != null && key.compareTo(previousPrimary) <= 0) {
throw FormatException('Language keys must be in strictly increasing order: $key');
}
previousPrimary = key;
final rawEntry = _expectMap(catalogEntry.value, 'languages.$key');
final primary = _expectString(rawEntry['primary'], 'languages.$key.primary');
if (primary != key) {
throw FormatException('Language key $key does not match primary code $primary');
}
if (!_isPrimaryCode(primary)) {
throw FormatException('languages.$key.primary must be exactly two lowercase letters');
}
if (!allCodes.add(primary)) {
throw FormatException('Duplicate ISO 639 code: $primary');
}
final terminology = _expectString(rawEntry['terminology'], 'languages.$key.terminology');
if (!_isAliasCode(terminology)) {
throw FormatException('languages.$key.terminology must be exactly three lowercase letters');
}
if (!allCodes.add(terminology)) {
throw FormatException('Duplicate ISO 639 code: $terminology');
}
final rawBibliographic = rawEntry['bibliographic'];
final String? bibliographic;
if (rawBibliographic == null) {
bibliographic = null;
} else {
bibliographic = _expectString(rawBibliographic, 'languages.$key.bibliographic');
if (!_isAliasCode(bibliographic)) {
throw FormatException('languages.$key.bibliographic must be null or exactly three lowercase letters');
}
if (!allCodes.add(bibliographic)) {
throw FormatException('Duplicate ISO 639 code: $bibliographic');
}
}
final name = _expectNonemptyString(rawEntry['name'], 'languages.$key.name');
entries.add(
Iso639CatalogEntry(primary: primary, terminology: terminology, bibliographic: bibliographic, name: name),
);
}
return Iso639Catalog(entries: List.unmodifiable(entries));
}
String renderIso639Data(Iso639Catalog catalog) {
final output = StringBuffer()
..writeln(
'// Generated by dart run scripts/generate_iso_639_data.dart from '
'scripts/data/iso_639_codes.json; do not edit by hand.',
)
..writeln()
..writeln('class LanguageEntry {')
..writeln(' final String code1;')
..writeln(' final String code2;')
..writeln(' final String? code2B;')
..writeln(' final String name;')
..writeln(' const LanguageEntry(this.code1, this.code2, this.code2B, this.name);')
..writeln('}')
..writeln()
..writeln('const languageEntries = <String, LanguageEntry>{');
for (final entry in catalog.entries) {
final bibliographic = entry.bibliographic == null ? 'null' : _dartString(entry.bibliographic!);
output.writeln(
' ${_dartString(entry.primary)}: LanguageEntry('
'${_dartString(entry.primary)}, ${_dartString(entry.terminology)}, $bibliographic, ${_dartString(entry.name)}),',
);
}
output
..writeln('};')
..writeln()
..writeln('const code2ToCode1 = <String, String>{');
for (final entry in catalog.entries) {
output.writeln(' ${_dartString(entry.terminology)}: ${_dartString(entry.primary)},');
}
output
..writeln('};')
..writeln()
..writeln('const code2BToCode1 = <String, String>{');
for (final entry in catalog.entries) {
final bibliographic = entry.bibliographic;
if (bibliographic != null) {
output.writeln(' ${_dartString(bibliographic)}: ${_dartString(entry.primary)},');
}
}
output.writeln('};');
return output.toString();
}
Future<void> writeFileAtomically(String path, String contents) async {
final output = File(path);
await output.parent.create(recursive: true);
final temporary = File('$path.tmp.$pid.${DateTime.now().microsecondsSinceEpoch}');
try {
await temporary.writeAsString(contents, flush: true);
await temporary.rename(path);
} finally {
if (await temporary.exists()) {
await temporary.delete();
}
}
}
Future<void> generateIso639Data(
String inputPath,
String outputPath, {
AtomicFileWriter writer = writeFileAtomically,
}) async {
final source = await File(inputPath).readAsString();
final catalog = parseIso639Catalog(source);
final rendered = renderIso639Data(catalog);
await writer(outputPath, rendered);
}
Future<void> main(List<String> arguments) async {
if (arguments.length > 2) {
stderr.writeln('Usage: dart run scripts/generate_iso_639_data.dart [input.json] [output.dart]');
exitCode = 64;
return;
}
final inputPath = arguments.isEmpty ? defaultIso639Input : arguments[0];
final outputPath = arguments.length < 2 ? defaultIso639Output : arguments[1];
await generateIso639Data(inputPath, outputPath);
}
Map<String, Object?> _expectMap(Object? value, String path) {
if (value is! Map<String, Object?>) {
throw FormatException('$path must be a JSON object');
}
return value;
}
String _expectString(Object? value, String path) {
if (value is! String) {
throw FormatException('$path must be a string');
}
return value;
}
String _expectNonemptyString(Object? value, String path) {
final string = _expectString(value, path);
if (string.trim().isEmpty) {
throw FormatException('$path must not be empty');
}
return string;
}
bool _isPrimaryCode(String code) => RegExp(r'^[a-z]{2}$').hasMatch(code);
bool _isAliasCode(String code) => RegExp(r'^[a-z]{3}$').hasMatch(code);
String _dartString(String value) {
if (value.contains("'")) {
final escaped = value
.replaceAll(r'\', r'\\')
.replaceAll('"', r'\"')
.replaceAll(r'$', r'\$')
.replaceAll('\r', r'\r')
.replaceAll('\n', r'\n')
.replaceAll('\t', r'\t');
return '"$escaped"';
}
final escaped = value
.replaceAll(r'\', r'\\')
.replaceAll("'", r"\'")
.replaceAll(r'$', r'\$')
.replaceAll('\r', r'\r')
.replaceAll('\n', r'\n')
.replaceAll('\t', r'\t');
return "'$escaped'";
}