240 lines
7.5 KiB
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'";
|
|
}
|