diff --git a/bin/refresh.dart b/bin/refresh.dart new file mode 100644 index 0000000..4d3acf0 --- /dev/null +++ b/bin/refresh.dart @@ -0,0 +1,5 @@ +import '../tool/refresh.dart' as runner; + +Future main(List args) async { + await runner.main(args); +} diff --git a/lib/src/env.dart b/lib/src/env.dart index 1902688..d136e5a 100644 --- a/lib/src/env.dart +++ b/lib/src/env.dart @@ -12,7 +12,7 @@ const String tzDataDefaultFilename = 'latest.tzf'; final _UTC = Location('UTC', [minTime], [0], [TimeZone.UTC]); final _database = LocationDatabase(); -late Location _local; +Location _local = _UTC; /// Global TimeZone database LocationDatabase get timeZoneDatabase => _database; diff --git a/lib/src/location.dart b/lib/src/location.dart index 16c932f..e25b3c5 100644 --- a/lib/src/location.dart +++ b/lib/src/location.dart @@ -45,27 +45,55 @@ class Location { // since January 1, 1970 UTC, to match the argument // to lookup. static final int _cacheNow = DateTime.now().millisecondsSinceEpoch; - int _cacheStart = 0; - int _cacheEnd = 0; - late TimeZone _cacheZone; - - Location(this.name, this.transitionAt, this.transitionZone, this.zones) { + final int _cacheStart; + final int _cacheEnd; + final TimeZone _cacheZone; + + Location._( + this.name, + this.transitionAt, + this.transitionZone, + this.zones, + this._cacheStart, + this._cacheEnd, + this._cacheZone, + ); + + factory Location( + String name, + List transitionAt, + List transitionZone, + List zones, + ) { // Fill in the cache with information about right now, // since that will be the most common lookup. + int cacheStart = 0; + int cacheEnd = maxTime; + TimeZone cacheZone = TimeZone.UTC; // fallback + for (var i = 0; i < transitionAt.length; i++) { final tAt = transitionAt[i]; if ((tAt <= _cacheNow) && ((i + 1 == transitionAt.length) || (_cacheNow < transitionAt[i + 1]))) { - _cacheStart = tAt; - _cacheEnd = maxTime; + cacheStart = tAt; + cacheEnd = maxTime; if (i + 1 < transitionAt.length) { - _cacheEnd = transitionAt[i + 1]; + cacheEnd = transitionAt[i + 1]; } - _cacheZone = zones[transitionZone[i]]; + cacheZone = zones[transitionZone[i]]; } } + return Location._( + name, + transitionAt, + transitionZone, + zones, + cacheStart, + cacheEnd, + cacheZone, + ); } /// translate instant in time expressed as milliseconds since diff --git a/pubspec.yaml b/pubspec.yaml index 3a84fa7..8c7d95b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,3 +14,6 @@ dev_dependencies: lints: ^3.0.0 logging: ^1.2.0 test: ^1.16.0 + +executables: + refresh: bin/refresh.dart diff --git a/tool/encode_dart.dart b/tool/encode_dart.dart index 42e700d..ce478a8 100644 --- a/tool/encode_dart.dart +++ b/tool/encode_dart.dart @@ -14,6 +14,15 @@ Future main(List args) async { File(dartLibraryPath).writeAsStringSync(generatedDartFile); } +Future encodeDart(String tzDataPath, String filePath) async { + final bytes = File(tzDataPath).readAsBytesSync(); + final generatedDartFile = generateDartFile( + name: p.basenameWithoutExtension(tzDataPath), + data: bytesAsString(bytes), + ); + File(filePath).writeAsStringSync(generatedDartFile); +} + String bytesAsString(Uint8List bytes) { assert(bytes.length.isEven); return bytes.buffer @@ -26,8 +35,7 @@ String generateDartFile({required String name, required String data}) => '''// This is a generated file. Do not edit. import 'dart:typed_data'; -import 'package:timezone/src/env.dart'; -import 'package:timezone/src/exceptions.dart'; +import 'package:timezone/timezone.dart'; /// Initialize Time Zone database from $name. /// diff --git a/tool/encode_tzf.dart b/tool/encode_tzf.dart index 8444820..86ad82c 100644 --- a/tool/encode_tzf.dart +++ b/tool/encode_tzf.dart @@ -86,3 +86,60 @@ Future main(List arguments) async { await write(args['output-common'] as String, commonDb.db); await write(args['output-10y'] as String, common_10y_Db.db); } + +Future encodeTzf( + {required String zoneInfoPath, + String outputAll = 'lib/data/latest_all.tzf', + String outputCommon = 'lib/data/latest.tzf', + String output10y = 'lib/data/latest_10y.tzf'}) async { + // Initialize logger + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((LogRecord rec) { + print('${rec.level.name}: ${rec.time}: ${rec.message}'); + }); + final log = Logger('main'); + + final db = LocationDatabase(); + + log.info('Importing zoneinfo files'); + final files = await Glob('**').list(root: zoneInfoPath).toList(); + for (final f in files) { + if (f is pkg_file.File) { + final name = p.relative(f.path, from: zoneInfoPath).replaceAll('\\', '/'); + log.info('- $name'); + db.add(tzfileLocationToNativeLocation( + tzfile.Location.fromBytes(name, await f.readAsBytes()))); + } + } + + void logReport(FilterReport r) { + log.info(' + locations: ${r.originalLocationsCount} => ' + '${r.newLocationsCount}'); + log.info(' + transitions: ${r.originalTransitionsCount} => ' + '${r.newTransitionsCount}'); + } + + log.info('Building location databases:'); + + log.info('- all locations'); + final allDb = filterTimeZoneData(db); + logReport(allDb.report); + + log.info('- common locations from all locations'); + final commonDb = filterTimeZoneData(allDb.db, locations: commonLocations); + logReport(commonDb.report); + + log.info('- [+- 5 years] from common locations'); + final common_10y_Db = filterTimeZoneData(commonDb.db, + dateFrom: DateTime(DateTime.now().year - 5, 1, 1).millisecondsSinceEpoch, + dateTo: DateTime(DateTime.now().year + 5, 1, 1).millisecondsSinceEpoch, + locations: commonLocations); + logReport(common_10y_Db.report); + + log.info('Serializing location databases'); + Future write(String file, LocationDatabase db) => + File(file).writeAsBytes(tzdbSerialize(db), flush: true); + await write(outputAll, allDb.db); + await write(outputCommon, commonDb.db); + await write(output10y, common_10y_Db.db); +} diff --git a/tool/refresh.dart b/tool/refresh.dart new file mode 100644 index 0000000..d098d71 --- /dev/null +++ b/tool/refresh.dart @@ -0,0 +1,191 @@ +import 'dart:io'; +import 'package:args/args.dart'; + +import 'encode_dart.dart' as encode_dart; +import 'encode_tzf.dart' as encode_tzf; + +const String _defaultSourceUrl = + "https://data.iana.org/time-zones/tzdata-latest.tar.gz"; + +const String _defaultFilePrefix = "latest"; + +Future main(List args) async { + try { + await _checkCommand('curl'); + await _checkCommand('tar'); + await _checkCommand('make'); + await _checkCommand('zic'); + } on Exception catch (e) { + print(e); + return; + } + + final parser = ArgParser() + ..addOption('output', + abbr: 'o', help: 'Output directory)', defaultsTo: 'lib/data') + ..addOption('source', + abbr: 's', + help: 'Source URL for timezone data', + defaultsTo: _defaultSourceUrl) + ..addOption('file-prefix', + abbr: 'p', + help: + // ignore: lines_longer_than_80_chars + 'Prefix for the generated files. E.g.: "latest", "2025", etc.\nRequired when passing a custom source', + defaultsTo: _defaultFilePrefix) + ..addFlag('help', abbr: 'h', help: 'Show help information'); + + final argResults = parser.parse(args); + + if (argResults['help'] == true) { + print( + "\nWELCOME TO TIMEZONE DATA GENERATOR\n\nThis utility is used to generate/regenerate timezone files (*.tzf/*.dart) from IANA timezone data archives. See https://data.iana.org/time-zones.\n\nIMPORTANT NOTE: This utility only works on Linux and Unix-like systems due its dependence on ZIC utility. So If you are using Windows, please run this in a WSL environment.\n\nOptions:\n"); + print('Timezone Data Generator Tool'); + print(parser.usage); + return; + } + + final sourceURL = argResults['source'] as String; + final filePrefix = argResults['file-prefix'] as String; + + if (sourceURL != _defaultSourceUrl && filePrefix == _defaultFilePrefix) { + print( + // ignore: lines_longer_than_80_chars + "Error: When using a custom source URL, you must also provide a custom --file-prefix to avoid overwriting default files."); + return; + } + + final outputPath = argResults['output'] as String; + final outputDir = Directory(outputPath); + + await outputDir.create(recursive: true); + print('Writing output to: ${outputDir.absolute.path}'); + + final tmpDir = await _makeTempDirectory(); + + try { + await _downloadAndExtractTarGz(Uri.parse(sourceURL), tmpDir); + await runMake(tmpDir); + await runZic(tmpDir); + + await runEncodeTzf(filePrefix, '${tmpDir.path}/zoneinfo', outputDir.path); + await runEmbedScopes(filePrefix, outputDir.path); + await formatDartFiles(outputDir.path); + } finally { + print('Cleaning up temp files...'); + await tmpDir.delete(recursive: true); + } + + print('Done!'); +} + +Future _makeTempDirectory() async { + final tempDir = + Directory('__tmp__${DateTime.now().microsecondsSinceEpoch}__tz__'); + var exists = await tempDir.exists(); + if (exists) { + return _makeTempDirectory(); + } + return tempDir; +} + +Future _downloadAndExtractTarGz(Uri url, Directory outputDir) async { + await outputDir.create(recursive: true); + + final curl = await Process.start('curl', ['-sL', url.toString()]); + final tar = await Process.start('tar', ['-zx', '-C', outputDir.path]); + + // Pipe curl stdout to tar stdin + await curl.stdout.pipe(tar.stdin); + + curl.stderr.transform(SystemEncoding().decoder).listen(stderr.write); + tar.stderr.transform(SystemEncoding().decoder).listen(stderr.write); + + final curlExit = await curl.exitCode; + final tarExit = await tar.exitCode; + + if (curlExit != 0 || tarExit != 0) { + throw Exception( + 'Failed to download and extract. Exit codes: curl=$curlExit, tar=$tarExit'); + } + + print('Extracted tzdata to ${outputDir.path}'); +} + +Future formatDartFiles(String outputPath) async { + print('Formatting Dart files in $outputPath...'); + final result = await Process.run('dart', ['format', outputPath]); + + if (result.exitCode != 0) { + print('Formatting failed:\n${result.stderr}'); + throw Exception('dart format failed'); + } + + print('Formatting complete'); +} + +Future runEmbedScopes(String filePrefix, String outputPath) async { + final scopes = [filePrefix, '${filePrefix}_all', '${filePrefix}_10y']; + + for (final scope in scopes) { + final tzfPath = '$outputPath/$scope.tzf'; + final dartPath = '$outputPath/$scope.dart'; + + print('Creating embedding: $scope...'); + await encode_dart.encodeDart(tzfPath, dartPath); + print('Created: $dartPath'); + } +} + +Future runEncodeTzf( + String filePrefix, String zoneInfoPath, String outputPath) async { + print('Running encode_tzf.dart...'); + await encode_tzf.encodeTzf( + zoneInfoPath: zoneInfoPath, + outputCommon: '$outputPath/$filePrefix.tzf', + outputAll: '$outputPath/${filePrefix}_all.tzf', + output10y: '$outputPath/${filePrefix}_10y.tzf', + ); +} + +Future runZic(Directory dir) async { + print('Running zic...'); + final zoneInfoDir = Directory('${dir.path}/zoneinfo'); + await zoneInfoDir.create(); + + final result = await Process.run( + 'zic', + ['-d', zoneInfoDir.absolute.path, '-b', 'fat', 'rearguard.zi'], + workingDirectory: dir.path, + ); + + if (result.exitCode != 0) { + print('zic failed:\n${result.stderr}'); + throw Exception('zic failed'); + } + + print('zic compilation complete'); +} + +Future runMake(Directory dir) async { + print('Running make rearguard.zi...'); + final result = await Process.run( + 'make', + ['rearguard.zi'], + workingDirectory: dir.path, + ); + + if (result.exitCode != 0) { + print('make failed:\n${result.stderr}'); + throw Exception('make failed'); + } + + print('make rearguard.zi succeeded'); +} + +Future _checkCommand(String cmd) async { + final result = await Process.run('which', [cmd]); + if (result.exitCode != 0) { + throw Exception('Required command `$cmd` not found. Please install it.'); + } +}