diff --git a/lib/util/datetime.dart b/lib/util/datetime.dart index afc0622..b5c7230 100644 --- a/lib/util/datetime.dart +++ b/lib/util/datetime.dart @@ -1,21 +1,26 @@ import 'package:intl/intl.dart'; +import 'timezone.dart'; -const rfc822DatePattern = 'EEE, dd MMM yyyy HH:mm:ss Z'; +/// The `Z` part is not yet implemented according to https://pub.dev/documentation/intl/latest/intl/DateFormat-class.html +/// We will remove it for now and parse the timezone separately. +const rfc822DatePattern = 'EEE, dd MMM yyyy HH:mm:ss'; +final rfc822DateFormat = DateFormat(rfc822DatePattern, 'en_US'); DateTime? parseDateTime(dateString) { if (dateString == null) return null; return _parseRfc822DateTime(dateString) ?? _parseIso8601DateTime(dateString); } +/// Try to parse `dateString` as an RFC 822 date. +/// We will parse the date string as UTC and then +/// subtract the actual time offset from the parsed string. DateTime? _parseRfc822DateTime(String dateString) { try { - final num length = dateString.length.clamp(0, rfc822DatePattern.length); - final trimmedPattern = rfc822DatePattern.substring( - 0, - length - as int?); //Some feeds use a shortened RFC 822 date, e.g. 'Tue, 04 Aug 2020' - final format = DateFormat(trimmedPattern, 'en_US'); - return format.parse(dateString); + final localTime = rfc822DateFormat.parse(dateString, true); + final timezone = dateString.trim().split(' ').last; + + final timeOffset = Duration(minutes: getTimeZoneOffset(timezone) ?? 0); + return localTime.subtract(timeOffset).toUtc(); } on FormatException { return null; } diff --git a/lib/util/timezone.dart b/lib/util/timezone.dart new file mode 100644 index 0000000..f4eeb02 --- /dev/null +++ b/lib/util/timezone.dart @@ -0,0 +1,37 @@ +const timeZoneAbbreviations = { + 'EET': 2 * 60, + 'CET': 1 * 60, + 'GMT': 0, + 'AST': -4 * 60, + 'EST': -5 * 60, + 'EDT': -4 * 60, + 'CST': -6 * 60, + 'CDT': -5 * 60, + 'MST': -7 * 60, + 'MDT': -6 * 60, + 'PST': -8 * 60, + 'PDT': -7 * 60, +}; + +/// Test this regexp online at https://regex101.com/r/mem3xt/1 +final offsetRegExp = + RegExp(r'^(?[+\-]?)(?\d{2}):?(?\d{2})$'); + +/// Parse a potential timezone string and return the +/// time offset in minutes +int? getTimeZoneOffset(String timezone) { + // check if timezone is one of the known abbreviations + var offset = timeZoneAbbreviations[timezone.toUpperCase()]; + if (offset != null) return offset; + // check if the timezone is of type offset + final match = offsetRegExp.firstMatch(timezone); + if (match != null) { + final sign = match.namedGroup('sign') == '-' ? -1 : 1; + // we know and are not null because the RexExp matched + final hours = int.parse(match.namedGroup('hours')!); + final minutes = int.parse(match.namedGroup('minutes')!); + return sign * (60 * hours + minutes); + } + + return null; +} diff --git a/pubspec.lock b/pubspec.lock index 326f4dc..079b491 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,365 +5,417 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - url: "https://pub.dartlang.org" + sha256: "4897882604d919befd350648c7f91926a9d5de99e67b455bf0917cc2362f4bb8" + url: "https://pub.dev" source: hosted version: "47.0.0" analyzer: dependency: transitive description: name: analyzer - url: "https://pub.dartlang.org" + sha256: "690e335554a8385bc9d787117d9eb52c0c03ee207a607e593de3c9d71b1cfe80" + url: "https://pub.dev" source: hosted version: "4.7.0" args: dependency: transitive description: name: args - url: "https://pub.dartlang.org" + sha256: b003c3098049a51720352d219b0bb5f219b60fbfb68e7a4748139a06a5676515 + url: "https://pub.dev" source: hosted version: "2.3.1" async: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "271b8899fc99f9df4f4ed419fa14e2fff392c7b2c162fbb87b222e2e963ddc73" + url: "https://pub.dev" source: hosted version: "2.9.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "5bbf32bc9e518d41ec49718e2931cd4527292c9b0c6d2dffcf7fe6b9a8a8cf72" + url: "https://pub.dev" source: hosted version: "2.1.0" clock: dependency: transitive description: name: clock - url: "https://pub.dartlang.org" + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" source: hosted version: "1.1.1" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: ef7e3a5529178ce8f37a9d0b11cbbc8b1e025940f9cf9f76c42da6796301219d + url: "https://pub.dev" source: hosted version: "1.16.0" convert: dependency: transitive description: name: convert - url: "https://pub.dartlang.org" + sha256: "196284f26f69444b7f5c50692b55ec25da86d9e500451dc09333bf2e3ad69259" + url: "https://pub.dev" source: hosted version: "3.0.2" coverage: dependency: transitive description: name: coverage - url: "https://pub.dartlang.org" + sha256: a6016ec1bc4c645cdc4f3dc931ac281ef3c96404a6a9a3096ac9a100c941fd1f + url: "https://pub.dev" source: hosted version: "1.6.0" crypto: dependency: transitive description: name: crypto - url: "https://pub.dartlang.org" + sha256: aa274aa7774f8964e4f4f38cc994db7b6158dd36e9187aaceaddc994b35c6c67 + url: "https://pub.dev" source: hosted version: "3.0.2" file: dependency: transitive description: name: file - url: "https://pub.dartlang.org" + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" + url: "https://pub.dev" source: hosted version: "6.1.4" flutter_lints: dependency: "direct dev" description: name: flutter_lints - url: "https://pub.dartlang.org" + sha256: aeb0b80a8b3709709c9cc496cdc027c5b3216796bc0af0ce1007eaf24464fd4c + url: "https://pub.dev" source: hosted version: "2.0.1" frontend_server_client: dependency: transitive description: name: frontend_server_client - url: "https://pub.dartlang.org" + sha256: "4f4a162323c86ffc1245765cfe138872b8f069deb42f7dbb36115fa27f31469b" + url: "https://pub.dev" source: hosted version: "2.1.3" glob: dependency: transitive description: name: glob - url: "https://pub.dartlang.org" + sha256: c51b4fdfee4d281f49b8c957f1add91b815473597f76bcf07377987f66a55729 + url: "https://pub.dev" source: hosted version: "2.1.0" http: dependency: "direct dev" description: name: http - url: "https://pub.dartlang.org" + sha256: "6aa2946395183537c8b880962d935877325d6a09a2867c3970c05c0fed6ac482" + url: "https://pub.dev" source: hosted version: "0.13.5" http_multi_server: dependency: transitive description: name: http_multi_server - url: "https://pub.dartlang.org" + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" source: hosted version: "3.2.1" http_parser: dependency: transitive description: name: http_parser - url: "https://pub.dartlang.org" + sha256: db3060f22889f3d9d55f6a217565486737037eec3609f7f3eca4d0c67ee0d8a0 + url: "https://pub.dev" source: hosted version: "4.0.1" intl: dependency: "direct main" description: name: intl - url: "https://pub.dartlang.org" + sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91" + url: "https://pub.dev" source: hosted version: "0.17.0" io: dependency: transitive description: name: io - url: "https://pub.dartlang.org" + sha256: "0d4c73c3653ab85bf696d51a9657604c900a370549196a91f33e4c39af760852" + url: "https://pub.dev" source: hosted version: "1.0.3" js: dependency: transitive description: name: js - url: "https://pub.dartlang.org" + sha256: a5e201311cb08bf3912ebbe9a2be096e182d703f881136ec1e81a2338a9e120d + url: "https://pub.dev" source: hosted version: "0.6.4" lints: dependency: transitive description: name: lints - url: "https://pub.dartlang.org" + sha256: "5cfd6509652ff5e7fe149b6df4859e687fca9048437857cb2e65c8d780f396e3" + url: "https://pub.dev" source: hosted version: "2.0.0" logging: dependency: transitive description: name: logging - url: "https://pub.dartlang.org" + sha256: "293ae2d49fd79d4c04944c3a26dfd313382d5f52e821ec57119230ae16031ad4" + url: "https://pub.dev" source: hosted version: "1.0.2" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: "80c2989398773fa06e2457e9ff08580f24e9858b28462a722241cb53e5613478" + url: "https://pub.dev" source: hosted version: "0.12.12" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: "6c268b42ed578a53088d834796959e4a1814b5e9e164f147f580a386e5decf42" + url: "https://pub.dev" source: hosted version: "1.8.0" mime: dependency: transitive description: name: mime - url: "https://pub.dartlang.org" + sha256: dab22e92b41aa1255ea90ddc4bc2feaf35544fd0728e209638cad041a6e3928a + url: "https://pub.dev" source: hosted version: "1.0.2" node_preamble: dependency: transitive description: name: node_preamble - url: "https://pub.dartlang.org" + sha256: "8ebdbaa3b96d5285d068f80772390d27c21e1fa10fb2df6627b1b9415043608d" + url: "https://pub.dev" source: hosted version: "2.0.1" package_config: dependency: transitive description: name: package_config - url: "https://pub.dartlang.org" + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" source: hosted version: "2.1.0" path: dependency: transitive description: name: path - url: "https://pub.dartlang.org" + sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b + url: "https://pub.dev" source: hosted version: "1.8.2" petitparser: dependency: transitive description: name: petitparser - url: "https://pub.dartlang.org" + sha256: "2ebb289dc4764ec397f5cd3ca9881c6d17196130a7d646ed022a0dd9c2e25a71" + url: "https://pub.dev" source: hosted version: "5.0.0" pool: dependency: transitive description: name: pool - url: "https://pub.dartlang.org" + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" source: hosted version: "1.5.1" pub_semver: dependency: transitive description: name: pub_semver - url: "https://pub.dartlang.org" + sha256: "816c1a640e952d213ddd223b3e7aafae08cd9f8e1f6864eed304cc13b0272b07" + url: "https://pub.dev" source: hosted version: "2.1.1" shelf: dependency: transitive description: name: shelf - url: "https://pub.dartlang.org" + sha256: "8ec607599dd0a78931a5114cdac7d609b6dbbf479a38acc9a6dba024b2a30ea0" + url: "https://pub.dev" source: hosted version: "1.3.2" shelf_packages_handler: dependency: transitive description: name: shelf_packages_handler - url: "https://pub.dartlang.org" + sha256: aef74dc9195746a384843102142ab65b6a4735bb3beea791e63527b88cc83306 + url: "https://pub.dev" source: hosted version: "3.0.1" shelf_static: dependency: transitive description: name: shelf_static - url: "https://pub.dartlang.org" + sha256: e792b76b96a36d4a41b819da593aff4bdd413576b3ba6150df5d8d9996d2e74c + url: "https://pub.dev" source: hosted version: "1.1.1" shelf_web_socket: dependency: transitive description: name: shelf_web_socket - url: "https://pub.dartlang.org" + sha256: "6db16374bc3497d21aa0eebe674d3db9fdf82082aac0f04dc7b44e4af5b08afc" + url: "https://pub.dev" source: hosted version: "1.0.2" source_map_stack_trace: dependency: transitive description: name: source_map_stack_trace - url: "https://pub.dartlang.org" + sha256: "8c463326277f68a628abab20580047b419c2ff66756fd0affd451f73f9508c11" + url: "https://pub.dev" source: hosted version: "2.1.0" source_maps: dependency: transitive description: name: source_maps - url: "https://pub.dartlang.org" + sha256: "52de2200bb098de739794c82d09c41ac27b2e42fd7e23cce7b9c74bf653c7296" + url: "https://pub.dev" source: hosted version: "0.10.10" source_span: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + url: "https://pub.dev" source: hosted version: "1.9.1" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: f8d9f247e2f9f90e32d1495ff32dac7e4ae34ffa7194c5ff8fcc0fd0e52df774 + url: "https://pub.dev" source: hosted version: "1.10.0" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: db47e4797198ee601990820437179bb90219f918962318d494ada2b4b11e6f6d + url: "https://pub.dev" source: hosted version: "2.1.0" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "862015c5db1f3f3c4ea3b94dc2490363a84262994b88902315ed74be1155612f" + url: "https://pub.dev" source: hosted version: "1.1.1" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" source: hosted version: "1.2.1" test: dependency: "direct dev" description: name: test - url: "https://pub.dartlang.org" + sha256: de3ca3495ab15223b921cb9372fe54abfb9a273a32f673f7ca0e0e7b07129d1b + url: "https://pub.dev" source: hosted version: "1.21.4" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: b882da9bdac8e6a846796a411b13c233cd5dfd03713888225e551cf27632acdb + url: "https://pub.dev" source: hosted version: "0.4.12" test_core: dependency: transitive description: name: test_core - url: "https://pub.dartlang.org" + sha256: "3f14367ebf05fcd5811d7ded583bbc93d80a12da9ad567d812f2466ef8df3bd1" + url: "https://pub.dev" source: hosted version: "0.4.16" typed_data: dependency: transitive description: name: typed_data - url: "https://pub.dartlang.org" + sha256: "26f87ade979c47a150c9eaab93ccd2bebe70a27dc0b4b29517f2904f04eb11a5" + url: "https://pub.dev" source: hosted version: "1.3.1" vm_service: dependency: transitive description: name: vm_service - url: "https://pub.dartlang.org" + sha256: e7fb6c2282f7631712b69c19d1bff82f3767eea33a2321c14fa59ad67ea391c7 + url: "https://pub.dev" source: hosted version: "9.4.0" watcher: dependency: transitive description: name: watcher - url: "https://pub.dartlang.org" + sha256: e42dfcc48f67618344da967b10f62de57e04bae01d9d3af4c2596f3712a88c99 + url: "https://pub.dev" source: hosted version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - url: "https://pub.dartlang.org" + sha256: "3a969ddcc204a3e34e863d204b29c0752716f78b6f9cc8235083208d268a4ccd" + url: "https://pub.dev" source: hosted version: "2.2.0" webkit_inspection_protocol: dependency: transitive description: name: webkit_inspection_protocol - url: "https://pub.dartlang.org" + sha256: "67d3a8b6c79e1987d19d848b0892e582dbb0c66c57cc1fef58a177dd2aa2823d" + url: "https://pub.dev" source: hosted version: "1.2.0" xml: dependency: "direct main" description: name: xml - url: "https://pub.dartlang.org" + sha256: ac0e3f4bf00ba2708c33fbabbbe766300e509f8c82dbd4ab6525039813f7e2fb + url: "https://pub.dev" source: hosted version: "6.1.0" yaml: dependency: transitive description: name: yaml - url: "https://pub.dartlang.org" + sha256: "23812a9b125b48d4007117254bca50abb6c712352927eece9e155207b1db2370" + url: "https://pub.dev" source: hosted version: "3.1.1" sdks: - dart: ">=2.17.0 <3.0.0" + dart: ">=2.17.0 <4.0.0" diff --git a/test/datetime_test.dart b/test/datetime_test.dart new file mode 100644 index 0000000..20f1450 --- /dev/null +++ b/test/datetime_test.dart @@ -0,0 +1,73 @@ +import 'package:test/test.dart'; +import 'package:webfeed_plus/util/datetime.dart'; + +void main() { + group('RFC 822 date time', () { + test('parse GMT date time', () { + const dateString = 'Sat, 29 Apr 2023 12:00:00 GMT'; + final result = parseDateTime(dateString); + expect(result, isNotNull); + expect(result!.isUtc, true); + expect(result, DateTime.utc(2023, 4, 29, 12, 0, 0)); + }); + + test('parse EST date time', () { + const dateString = 'Sat, 29 Apr 2023 21:22:23 EST'; + final result = parseDateTime(dateString); + expect(result, isNotNull); + expect(result!.isUtc, true); + expect(result, DateTime.utc(2023, 4, 30, 2, 22, 23)); + }); + + test('parse +0000 offset date time', () { + const dateString = 'Fri, 28 Apr 2023 23:00:57 +0000'; + final result = parseDateTime(dateString); + expect(result, isNotNull); + expect(result!.isUtc, true); + expect(result, DateTime.utc(2023, 4, 28, 23, 0, 57)); + }); + + test('parse -0000 offset date time', () { + // yes, really, I saw this format here: https://feeds.megaphone.fm/bitcoinaudible + const dateString = 'Thu, 27 Apr 2023 19:17:00 -0000'; + final result = parseDateTime(dateString); + expect(result, isNotNull); + expect(result!.isUtc, true); + expect(result, DateTime.utc(2023, 4, 27, 19, 17, 0)); + }); + + test('parse +0100 offset date time', () { + const dateString = 'Fri, 28 Apr 2023 19:02:17 +0100'; + final result = parseDateTime(dateString); + expect(result, isNotNull); + expect(result!.isUtc, true); + expect(result, DateTime.utc(2023, 4, 28, 18, 2, 17)); + }); + + test('parse 02:00 offset date time', () { + const dateString = 'Thu, 27 Apr 2023 14:30:00 02:00'; + final result = parseDateTime(dateString); + expect(result, isNotNull); + expect(result!.isUtc, true); + expect(result, DateTime.utc(2023, 4, 27, 12, 30, 0)); + }); + + test('parse -0500 offset date time', () { + const dateString = 'Thu, 27 Apr 2023 14:30:00 -0500'; + final result = parseDateTime(dateString); + expect(result, isNotNull); + expect(result!.isUtc, true); + expect(result, DateTime.utc(2023, 4, 27, 19, 30, 0)); + }); + }); + + group('ISO 8601 date time', () { + test('parse +00:00 offset date time', () { + const dateString = '2023-04-24T05:02:37+00:00'; + final result = parseDateTime(dateString); + expect(result, isNotNull); + expect(result!.isUtc, true); + expect(result, DateTime.utc(2023, 4, 24, 5, 2, 37)); + }); + }); +} diff --git a/test/timezone_test.dart b/test/timezone_test.dart new file mode 100644 index 0000000..31875ec --- /dev/null +++ b/test/timezone_test.dart @@ -0,0 +1,63 @@ +import 'package:test/test.dart'; +import 'package:webfeed_plus/util/timezone.dart'; + +main() { + group('Abbreviated timezones', () { + test('parse GMT timezone', () { + final offset = getTimeZoneOffset('GMT'); + expect(offset, 0); + }); + + test('parse EET timezone', () { + final offset = getTimeZoneOffset('EET'); + expect(offset, 2 * 60); + }); + + test('parse EST timezone', () { + final offset = getTimeZoneOffset('EST'); + expect(offset, -5 * 60); + }); + }); + + group('Offset timezones', () { + test('parse 00:00 timezone', () { + final offset = getTimeZoneOffset('00:00'); + expect(offset, 0); + }); + + test('parse 0000 timezone', () { + final offset = getTimeZoneOffset('0000'); + expect(offset, 0); + }); + + test('parse +01:00 timezone', () { + final offset = getTimeZoneOffset('+01:00'); + expect(offset, 60); + }); + + test('parse 01:00 timezone', () { + final offset = getTimeZoneOffset('01:00'); + expect(offset, 60); + }); + + test('parse 0100 timezone', () { + final offset = getTimeZoneOffset('0100'); + expect(offset, 60); + }); + + test('parse -01:00 timezone', () { + final offset = getTimeZoneOffset('-01:00'); + expect(offset, -60); + }); + + test('parse -0100 timezone', () { + final offset = getTimeZoneOffset('-0100'); + expect(offset, -60); + }); + + test('parse -03:30 timezone', () { + final offset = getTimeZoneOffset('-03:30'); + expect(offset, -3 * 60 - 30); + }); + }); +}