From 77ca2cb808d68d98078801e90569c4230696a8fc Mon Sep 17 00:00:00 2001 From: Jonas Wanke Date: Mon, 17 Jan 2022 20:52:28 +0100 Subject: [PATCH] feat: add jCal support Closes: #3 --- lib/src/codecs/json/decoder.dart | 77 ++++++++++++++++++++++++++++ lib/src/codecs/json/encoder.dart | 82 ++++++++++++++++++++++++++++++ lib/src/codecs/string/encoder.dart | 16 +++--- lib/src/recurrence_rule.dart | 17 +++++-- test/codecs/json/json_test.dart | 45 ++++++++++++++++ test/codecs/utils.dart | 20 ++++++++ test/utils.dart | 1 + 7 files changed, 246 insertions(+), 12 deletions(-) create mode 100644 lib/src/codecs/json/decoder.dart create mode 100644 lib/src/codecs/json/encoder.dart create mode 100644 test/codecs/json/json_test.dart diff --git a/lib/src/codecs/json/decoder.dart b/lib/src/codecs/json/decoder.dart new file mode 100644 index 0000000..71ddbe8 --- /dev/null +++ b/lib/src/codecs/json/decoder.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +import 'package:meta/meta.dart'; + +import '../../recurrence_rule.dart'; +import '../string/decoder.dart'; + +@immutable +class RecurrenceRuleFromJsonDecoder + extends Converter, RecurrenceRule> { + const RecurrenceRuleFromJsonDecoder(); + + static const _byWeekDayEntryDecoder = ByWeekDayEntryFromStringDecoder(); + + @override + RecurrenceRule convert(Map input) { + final rawUntil = input['until'] as String?; + final rawCount = input['count'] as int?; + if (rawUntil != null && rawCount != null) { + throw FormatException('Both `until` and `count` are specified.'); + } + final rawWeekStart = input['wkst'] as String?; + return RecurrenceRule( + frequency: frequencyFromString(input['freq'] as String), + until: rawUntil == null ? null : _parseDateTime(rawUntil), + count: rawCount, + interval: input['interval'] as int?, + bySeconds: _parseIntSet('bysecond', input['bysecond']), + byMinutes: _parseIntSet('byminute', input['byminute']), + byHours: _parseIntSet('byhour', input['byhour']), + byWeekDays: + _parseSet('byday', input['byday'], _byWeekDayEntryDecoder.convert), + byMonthDays: _parseIntSet('bymonthday', input['bymonthday']), + byYearDays: _parseIntSet('byyearday', input['byyearday']), + byWeeks: _parseIntSet('byweekno', input['byweekno']), + byMonths: _parseIntSet('bymonth', input['bymonth']), + bySetPositions: _parseIntSet('bysetpos', input['bysetpos']), + weekStart: rawWeekStart == null ? null : weekDayFromString(rawWeekStart), + ); + } + + DateTime _parseDateTime(String string) { + const year = r'(?\d{4})'; + const month = r'(?\d{2})'; + const day = r'(?\d{2})'; + const hour = r'(?\d{2})'; + const minute = r'(?\d{2})'; + const second = r'(?\d{2})'; + final regEx = RegExp( + '^$year-$month-${day}T$hour:$minute:${second}Z?\$', + ); + + final match = regEx.firstMatch(string)!; + return DateTime.utc( + int.parse(match.namedGroup('year')!), + int.parse(match.namedGroup('month')!), + int.parse(match.namedGroup('day')!), + int.parse(match.namedGroup('hour')!), + int.parse(match.namedGroup('minute')!), + int.parse(match.namedGroup('second')!), + ); + } + + Set _parseIntSet(String name, dynamic json) => + _parseSet(name, json, (it) => it); + Set _parseSet(String name, dynamic json, R Function(T) parse) { + if (json == null) { + return const {}; + } else if (json is T) { + return {parse(json)}; + } else if (json is List) { + return json.map(parse).toSet(); + } else { + throw FormatException('Invalid JSON in `$name`.'); + } + } +} diff --git a/lib/src/codecs/json/encoder.dart b/lib/src/codecs/json/encoder.dart new file mode 100644 index 0000000..756a4a6 --- /dev/null +++ b/lib/src/codecs/json/encoder.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; + +import 'package:meta/meta.dart'; + +import '../../recurrence_rule.dart'; +import '../string/encoder.dart'; +import '../string/ical.dart'; + +@immutable +class RecurrenceRuleToJsonOptions { + const RecurrenceRuleToJsonOptions({ + this.isTimeUtc = false, + }); + + /// If true, all date/time strings will be suffixed with a 'Z' to indicate + /// they are in UTC. + final bool isTimeUtc; +} + +@immutable +class RecurrenceRuleToJsonEncoder + extends Converter> { + const RecurrenceRuleToJsonEncoder({ + this.options = const RecurrenceRuleToJsonOptions(), + }); + + static const _byWeekDayEntryEncoder = ByWeekDayEntryToStringEncoder(); + + final RecurrenceRuleToJsonOptions options; + + @override + Map convert(RecurrenceRule input) { + return { + 'freq': frequencyToString(input.frequency), + if (input.until != null) 'until': _formatDateTime(input.until!), + if (input.count != null) 'count': input.count, + if (input.interval != null) 'interval': input.interval, + if (input.bySeconds.isNotEmpty) 'bysecond': input.bySeconds.toList(), + if (input.byMinutes.isNotEmpty) 'byminute': input.byMinutes.toList(), + if (input.byHours.isNotEmpty) 'byhour': input.byHours.toList(), + if (input.byWeekDays.isNotEmpty) + 'byday': input.byWeekDays.map(_byWeekDayEntryEncoder.convert).toList(), + if (input.byMonthDays.isNotEmpty) + 'bymonthday': input.byMonthDays.toList(), + if (input.byYearDays.isNotEmpty) 'byyearday': input.byYearDays.toList(), + if (input.byWeeks.isNotEmpty) 'byweekno': input.byWeeks.toList(), + if (input.byMonths.isNotEmpty) 'bymonth': input.byMonths.toList(), + if (input.bySetPositions.isNotEmpty) + 'bysetpos': input.bySetPositions.toList(), + if (input.weekStart != null) 'wkst': weekDayToString(input.weekStart!), + }; + } + + String _formatDateTime(DateTime dateTime) { + // Modified version of `dateTime.toIso8601String()` without sub-second + // precision. + assert( + 0 <= dateTime.year && dateTime.year <= iCalMaxYear, + 'Years with more than four digits are not supported.', + ); + + String twoDigits(int n) => n < 10 ? '0$n' : '$n'; + + String fourDigits(int n) { + final absolute = n.abs(); + final sign = n < 0 ? '-' : ''; + if (absolute >= 1000) return '$n'; + if (absolute >= 100) return '${sign}0$absolute'; + if (absolute >= 10) return '${sign}00$absolute'; + return '${sign}000$absolute'; + } + + final year = fourDigits(dateTime.year); + final month = twoDigits(dateTime.month); + final day = twoDigits(dateTime.day); + final hour = twoDigits(dateTime.hour); + final minute = twoDigits(dateTime.minute); + final second = twoDigits(dateTime.second); + final utcSuffix = options.isTimeUtc ? 'Z' : ''; + return '$year-$month-${day}T$hour:$minute:$second$utcSuffix'; + } +} diff --git a/lib/src/codecs/string/encoder.dart b/lib/src/codecs/string/encoder.dart index 924ada1..71164f8 100644 --- a/lib/src/codecs/string/encoder.dart +++ b/lib/src/codecs/string/encoder.dart @@ -36,7 +36,7 @@ class RecurrenceRuleToStringEncoder extends Converter { @override String convert(RecurrenceRule input) { final value = StringBuffer( - '$recurRulePartFreq=${_frequencyToString(input.frequency)}'); + '$recurRulePartFreq=${frequencyToString(input.frequency)}'); if (input.until != null) { value @@ -58,8 +58,10 @@ class RecurrenceRuleToStringEncoder extends Converter { ..writeList(recurRulePartByYearDay, input.byYearDays) ..writeList(recurRulePartByWeekNo, input.byWeeks) ..writeList(recurRulePartByMonth, input.byMonths) - ..writeList(recurRulePartBySetPos, input.bySetPositions) - ..writeSingle(recurRulePartWkSt, _weekDayToString(input.weekStart)); + ..writeList(recurRulePartBySetPos, input.bySetPositions); + if (input.weekStart != null) { + value.writeSingle(recurRulePartWkSt, weekDayToString(input.weekStart!)); + } return ICalPropertyStringCodec().encode(ICalProperty( name: rruleName, @@ -68,17 +70,15 @@ class RecurrenceRuleToStringEncoder extends Converter { } } -String? _frequencyToString(Frequency? input) { +String? frequencyToString(Frequency? input) { if (input == null) return null; return recurFreqValues.entries.singleWhere((e) => e.value == input).key; } -String? _weekDayToString(int? dayOfWeek) { +String weekDayToString(int dayOfWeek) { assert(dayOfWeek.isValidRruleDayOfWeek); - if (dayOfWeek == null) return null; - return recurWeekDayValues.entries .singleWhere((e) => e.value == dayOfWeek) .key; @@ -127,5 +127,5 @@ class ByWeekDayEntryToStringEncoder extends Converter { @override String convert(ByWeekDayEntry input) => - '${input.occurrence ?? ''}${_weekDayToString(input.day)}'; + '${input.occurrence ?? ''}${weekDayToString(input.day)}'; } diff --git a/lib/src/recurrence_rule.dart b/lib/src/recurrence_rule.dart index 0353724..df146aa 100644 --- a/lib/src/recurrence_rule.dart +++ b/lib/src/recurrence_rule.dart @@ -5,9 +5,10 @@ import 'package:meta/meta.dart'; import 'by_week_day_entry.dart'; import 'cache.dart'; +import 'codecs/json/decoder.dart'; +import 'codecs/json/encoder.dart'; import 'codecs/string/decoder.dart'; import 'codecs/string/encoder.dart'; -import 'codecs/string/string.dart'; import 'codecs/text/encoder.dart'; import 'codecs/text/l10n/l10n.dart'; import 'frequency.dart'; @@ -107,9 +108,11 @@ class RecurrenceRule { String input, { RecurrenceRuleFromStringOptions options = const RecurrenceRuleFromStringOptions(), - }) { - return RecurrenceRuleStringCodec(fromStringOptions: options).decode(input); - } + }) => + RecurrenceRuleFromStringDecoder(options: options).convert(input); + + factory RecurrenceRule.fromJson(Map json) => + RecurrenceRuleFromJsonDecoder().convert(json); /// Corresponds to the `FREQ` property. final Frequency frequency; @@ -352,6 +355,12 @@ class RecurrenceRule { return RecurrenceRuleToTextEncoder(l10n).convert(this); } + /// Converts this rule to a machine-readable, RFC-7265-compliant string. + Map toJson({ + RecurrenceRuleToJsonOptions options = const RecurrenceRuleToJsonOptions(), + }) => + RecurrenceRuleToJsonEncoder(options: options).convert(this); + /// Whether this rule can be converted to a human-readable string. /// /// - Unsupported attributes: [bySeconds], [byMinutes], [byHours] diff --git a/test/codecs/json/json_test.dart b/test/codecs/json/json_test.dart new file mode 100644 index 0000000..86b18d1 --- /dev/null +++ b/test/codecs/json/json_test.dart @@ -0,0 +1,45 @@ +import 'package:rrule/rrule.dart'; + +import '../utils.dart'; + +void main() { + // There are more tests in `../../rrule_ical_test.dart`. + + testJsonCodec( + 'Annually on the 2nd Monday & the last Sunday of the month in October, 5 times', + RecurrenceRule( + frequency: Frequency.yearly, + count: 5, + byWeekDays: { + ByWeekDayEntry(DateTime.sunday, -1), + ByWeekDayEntry(DateTime.monday, 2), + }, + byMonths: {10}, + ), + json: { + 'freq': 'YEARLY', + 'count': 5, + 'byday': ['-1SU', '2MO'], + // 'bymonth': 10, + 'bymonth': [10], + }, + ); + + testJsonCodec( + 'Every other month on the 1st, 15th & last day, until Tuesday, October 1, 2013 12:00:00 AM', + RecurrenceRule( + frequency: Frequency.monthly, + interval: 2, + byMonthDays: {1, 15, -1}, + until: DateTime.utc(2013, 10, 1), + ), + json: { + 'freq': 'MONTHLY', + // 'until': '2013-10-01' + 'until': '2013-10-01T00:00:00', + 'interval': 2, + // 'bymonthday': [1, 15, -1], + 'bymonthday': [-1, 1, 15], + }, + ); +} diff --git a/test/codecs/utils.dart b/test/codecs/utils.dart index 00e19c6..1cc50cf 100644 --- a/test/codecs/utils.dart +++ b/test/codecs/utils.dart @@ -1,6 +1,9 @@ import 'dart:convert'; import 'package:meta/meta.dart'; +import 'package:rrule/rrule.dart'; +import 'package:rrule/src/codecs/json/decoder.dart'; +import 'package:rrule/src/codecs/json/encoder.dart'; import 'package:test/test.dart'; @isTestGroup @@ -15,3 +18,20 @@ void testStringCodec( test('from string', () => expect(codec.decode(string), value)); }); } + +@isTest +void testJsonCodec( + String description, + RecurrenceRule value, { + Map? json, +}) { + test(description, () { + const encoder = RecurrenceRuleToJsonEncoder(); + const decoder = RecurrenceRuleFromJsonDecoder(); + + final encoded = encoder.convert(value); + if (json != null) expect(encoded, json); + + expect(decoder.convert(encoded), value); + }); +} diff --git a/test/utils.dart b/test/utils.dart index 93fbe71..828f5f1 100644 --- a/test/utils.dart +++ b/test/utils.dart @@ -26,6 +26,7 @@ void testRrule( value: rrule, string: string, ); + testJsonCodec('JsonCodec', rrule); // TODO(JonasWanke): Remove the condition when all properties are supported. if (text != null) {