Skip to content

Commit

Permalink
feat: add jCal support
Browse files Browse the repository at this point in the history
Closes: #3
  • Loading branch information
JonasWanke committed Jan 17, 2022
1 parent 84f6bb0 commit 77ca2cb
Show file tree
Hide file tree
Showing 7 changed files with 246 additions and 12 deletions.
77 changes: 77 additions & 0 deletions lib/src/codecs/json/decoder.dart
Original file line number Diff line number Diff line change
@@ -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<Map<String, dynamic>, RecurrenceRule> {
const RecurrenceRuleFromJsonDecoder();

static const _byWeekDayEntryDecoder = ByWeekDayEntryFromStringDecoder();

@override
RecurrenceRule convert(Map<String, dynamic> 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'(?<year>\d{4})';
const month = r'(?<month>\d{2})';
const day = r'(?<day>\d{2})';
const hour = r'(?<hour>\d{2})';
const minute = r'(?<minute>\d{2})';
const second = r'(?<second>\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<int> _parseIntSet(String name, dynamic json) =>
_parseSet<int, int>(name, json, (it) => it);
Set<R> _parseSet<T, R>(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<T>) {
return json.map(parse).toSet();
} else {
throw FormatException('Invalid JSON in `$name`.');
}
}
}
82 changes: 82 additions & 0 deletions lib/src/codecs/json/encoder.dart
Original file line number Diff line number Diff line change
@@ -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<RecurrenceRule, Map<String, dynamic>> {
const RecurrenceRuleToJsonEncoder({
this.options = const RecurrenceRuleToJsonOptions(),
});

static const _byWeekDayEntryEncoder = ByWeekDayEntryToStringEncoder();

final RecurrenceRuleToJsonOptions options;

@override
Map<String, dynamic> convert(RecurrenceRule input) {
return <String, dynamic>{
'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';
}
}
16 changes: 8 additions & 8 deletions lib/src/codecs/string/encoder.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class RecurrenceRuleToStringEncoder extends Converter<RecurrenceRule, String> {
@override
String convert(RecurrenceRule input) {
final value = StringBuffer(
'$recurRulePartFreq=${_frequencyToString(input.frequency)}');
'$recurRulePartFreq=${frequencyToString(input.frequency)}');

if (input.until != null) {
value
Expand All @@ -58,8 +58,10 @@ class RecurrenceRuleToStringEncoder extends Converter<RecurrenceRule, String> {
..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,
Expand All @@ -68,17 +70,15 @@ class RecurrenceRuleToStringEncoder extends Converter<RecurrenceRule, String> {
}
}

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;
Expand Down Expand Up @@ -127,5 +127,5 @@ class ByWeekDayEntryToStringEncoder extends Converter<ByWeekDayEntry, String> {

@override
String convert(ByWeekDayEntry input) =>
'${input.occurrence ?? ''}${_weekDayToString(input.day)}';
'${input.occurrence ?? ''}${weekDayToString(input.day)}';
}
17 changes: 13 additions & 4 deletions lib/src/recurrence_rule.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<String, dynamic> json) =>
RecurrenceRuleFromJsonDecoder().convert(json);

/// Corresponds to the `FREQ` property.
final Frequency frequency;
Expand Down Expand Up @@ -352,6 +355,12 @@ class RecurrenceRule {
return RecurrenceRuleToTextEncoder(l10n).convert(this);
}

/// Converts this rule to a machine-readable, RFC-7265-compliant string.
Map<String, dynamic> 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]
Expand Down
45 changes: 45 additions & 0 deletions test/codecs/json/json_test.dart
Original file line number Diff line number Diff line change
@@ -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: <String, dynamic>{
'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: <String, dynamic>{
'freq': 'MONTHLY',
// 'until': '2013-10-01'
'until': '2013-10-01T00:00:00',
'interval': 2,
// 'bymonthday': [1, 15, -1],
'bymonthday': [-1, 1, 15],
},
);
}
20 changes: 20 additions & 0 deletions test/codecs/utils.dart
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,3 +18,20 @@ void testStringCodec<T>(
test('from string', () => expect(codec.decode(string), value));
});
}

@isTest
void testJsonCodec(
String description,
RecurrenceRule value, {
Map<String, dynamic>? 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);
});
}
1 change: 1 addition & 0 deletions test/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down

0 comments on commit 77ca2cb

Please sign in to comment.