/// Generates lib/data/hid_key_labels.dart from the curated offline HID catalog. /// /// Usage: /// dart run scripts/generate_hid_key_labels.dart [input.json] [output.dart] library; import 'dart:convert'; import 'dart:io'; const defaultHidKeyLabelsInput = 'scripts/data/hid_key_labels.json'; const defaultHidKeyLabelsOutput = 'lib/data/hid_key_labels.dart'; final class HidKeyLabel { const HidKeyLabel({required this.id, required this.label}); final String id; final String label; int get numericId => int.parse(id, radix: 16); } final class HidKeyGroup { const HidKeyGroup({required this.name, required this.keys}); final String name; final List keys; } final class HidKeyCatalog { const HidKeyCatalog({required this.groups}); final List groups; } typedef AtomicFileWriter = Future Function(String path, String contents); HidKeyCatalog parseHidKeyLabelsCatalog(String source) { final Object? decoded; try { decoded = jsonDecode(source); } on FormatException catch (error) { throw FormatException('Invalid HID catalog JSON: ${error.message}'); } final root = _expectMap(decoded, 'catalog'); if (root['schemaVersion'] != 1) { throw const FormatException('HID catalog schemaVersion must be 1'); } final rawGroups = _expectList(root['groups'], 'groups'); if (rawGroups.isEmpty) { throw const FormatException('HID catalog groups must not be empty'); } final groups = []; final groupNames = {}; final ids = {}; var previousId = -1; for (var groupIndex = 0; groupIndex < rawGroups.length; groupIndex++) { final rawGroup = _expectMap(rawGroups[groupIndex], 'groups[$groupIndex]'); final name = _expectNonemptyString(rawGroup['name'], 'groups[$groupIndex].name'); if (!groupNames.add(name)) { throw FormatException('Duplicate HID group name: $name'); } final rawKeys = _expectList(rawGroup['keys'], 'groups[$groupIndex].keys'); if (rawKeys.isEmpty) { throw FormatException('HID group "$name" must contain at least one key'); } final keys = []; for (var keyIndex = 0; keyIndex < rawKeys.length; keyIndex++) { final path = 'groups[$groupIndex].keys[$keyIndex]'; final rawKey = _expectMap(rawKeys[keyIndex], path); final id = _expectString(rawKey['id'], '$path.id'); if (!RegExp(r'^[0-9a-f]{8}$').hasMatch(id)) { throw FormatException('$path.id must be exactly eight lowercase hexadecimal digits'); } if (!ids.add(id)) { throw FormatException('Duplicate HID key ID: $id'); } final numericId = int.parse(id, radix: 16); if (numericId <= previousId) { throw FormatException('HID key IDs must be in strictly increasing numeric order: $id'); } previousId = numericId; final label = _expectNonemptyString(rawKey['label'], '$path.label'); keys.add(HidKeyLabel(id: id, label: label)); } groups.add(HidKeyGroup(name: name, keys: List.unmodifiable(keys))); } return HidKeyCatalog(groups: List.unmodifiable(groups)); } String renderHidKeyLabels(HidKeyCatalog catalog) { final output = StringBuffer() ..writeln( '// Generated by dart run scripts/generate_hid_key_labels.dart from ' 'scripts/data/hid_key_labels.json; do not edit by hand.', ) ..writeln() ..writeln('/// Human-readable labels for physical keyboard keys, keyed by USB HID usage code.') ..writeln('const hidKeyLabels = {'); for (final group in catalog.groups) { output.writeln(' // ${group.name}'); for (final key in group.keys) { output.writeln(' 0x${key.id}: ${_dartString(key.label)},'); } } output.writeln('};'); return output.toString(); } Future 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 generateHidKeyLabels( String inputPath, String outputPath, { AtomicFileWriter writer = writeFileAtomically, }) async { final source = await File(inputPath).readAsString(); final catalog = parseHidKeyLabelsCatalog(source); final rendered = renderHidKeyLabels(catalog); await writer(outputPath, rendered); } Future main(List arguments) async { if (arguments.length > 2) { stderr.writeln('Usage: dart run scripts/generate_hid_key_labels.dart [input.json] [output.dart]'); exitCode = 64; return; } final inputPath = arguments.isEmpty ? defaultHidKeyLabelsInput : arguments[0]; final outputPath = arguments.length < 2 ? defaultHidKeyLabelsOutput : arguments[1]; await generateHidKeyLabels(inputPath, outputPath); } Map _expectMap(Object? value, String path) { if (value is! Map) { throw FormatException('$path must be a JSON object'); } return value; } List _expectList(Object? value, String path) { if (value is! List) { throw FormatException('$path must be a JSON array'); } 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; } String _dartString(String value) { if (value == r'\') return r"r'\'"; 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'"; }