diff --git a/LICENSE b/LICENSE index 15cd6e0..00adc82 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +1,6 @@ BSD 3-Clause License +Copyright (c) 2020, Jonas Franz Copyright (c) 2020, Lukas Himsel All rights reserved. diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 9397cdd..86cd0fc 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -2,8 +2,8 @@ name: example environment: sdk: '>=2.1.0 <3.0.0' dependencies: - intl: ^0.15.7 - nanoid: ^0.0.6 + intl: any + nanoid: any ical: path: .. \ No newline at end of file diff --git a/lib/serializer.dart b/lib/serializer.dart index 623f34c..53bad3e 100644 --- a/lib/serializer.dart +++ b/lib/serializer.dart @@ -2,6 +2,7 @@ export 'src/abstract.dart'; export 'src/subcomponents.dart'; export 'src/calendar.dart'; export 'src/event.dart'; +export 'src/parser.dart'; //export 'src/journal.dart'; //export 'src/timezone.dart'; //export 'src/todo.dart'; diff --git a/lib/src/abstract.dart b/lib/src/abstract.dart index 9a36b2d..ea444d6 100644 --- a/lib/src/abstract.dart +++ b/lib/src/abstract.dart @@ -1,4 +1,5 @@ import 'package:ical/serializer.dart'; +import 'package:ical/src/structure.dart'; import 'package:ical/src/utils.dart'; import 'utils.dart' as utils; @@ -9,6 +10,10 @@ abstract class AbstractSerializer { String serialize(); } +abstract class AbstractDeserializer { + void deserialize(ICalStructure structure); +} + class IClass { final String _label; @override @@ -105,7 +110,8 @@ class IOrganizer { } } -abstract class ICalendarElement extends AbstractSerializer { +abstract class ICalendarElement + implements AbstractSerializer, AbstractDeserializer { IOrganizer organizer; String uid; String summary; @@ -143,12 +149,27 @@ abstract class ICalendarElement extends AbstractSerializer { if (summary != null) out.writeln('SUMMARY:${escapeValue(summary)}'); if (url != null) out.writeln('URL:${url}'); if (classification != null) out.writeln('CLASS:$classification'); - if (description != null) + if (description != null) { out.writeln('DESCRIPTION:${escapeValue(description)}'); + } if (rrule != null) out.write(rrule.serialize()); return out.toString(); } + + @override + void deserialize(ICalStructure structure) { + uid = structure["UID"]?.value; + categories = structure["CATEGORIES"]?.value?.split(","); + comment = structure["COMMENT"]?.value; + summary = structure["SUMMARY"]?.value; + url = structure["URL"]?.value; + final classificationString = structure["CLASSIFICATION"]?.value; + if (classificationString != null) + classification = IClass._(classificationString); + // TODO support rrule + } + // TODO ATTENDEE // TODO CONTACT } diff --git a/lib/src/calendar.dart b/lib/src/calendar.dart index 407595c..be40313 100644 --- a/lib/src/calendar.dart +++ b/lib/src/calendar.dart @@ -1,7 +1,9 @@ -import 'abstract.dart'; +import 'package:ical/serializer.dart'; +import 'package:ical/src/structure.dart'; + import 'utils.dart' as utils; -class ICalendar extends AbstractSerializer { +class ICalendar implements AbstractSerializer, AbstractDeserializer { List _elements = []; String company; String product; @@ -18,6 +20,8 @@ class ICalendar extends AbstractSerializer { addAll(List elements) => _elements.addAll(elements); addElement(ICalendarElement element) => _elements.add(element); + List get events => _elements.whereType().toList(); + @override String serialize() { var out = StringBuffer() @@ -37,4 +41,13 @@ class ICalendar extends AbstractSerializer { out.writeln('END:VCALENDAR'); return out.toString(); } + + @override + void deserialize(ICalStructure structure) { + _elements = structure.children + .where((child) => child.type == "VEVENT") + .map((event) => IEvent()..deserialize(event)) + .cast() + .toList(); + } } diff --git a/lib/src/event.dart b/lib/src/event.dart index 28ee194..96fabcc 100644 --- a/lib/src/event.dart +++ b/lib/src/event.dart @@ -1,3 +1,5 @@ +import 'package:ical/src/structure.dart'; + import 'abstract.dart'; import 'subcomponents.dart'; import 'utils.dart' as utils; @@ -79,6 +81,27 @@ class IEvent extends ICalendarElement with EventToDo { ..writeln('END:VEVENT'); return out.toString(); } + + @override + void deserialize(ICalStructure structure) { + super.deserialize(structure); + start = _parseDate(structure["DTSTART"]); + end = _parseDate(structure["DTEND"]); + // TODO support duration + transparency = structure["TRANSP"] != null + ? ITimeTransparency._(structure["TRANSP"].value) + : null; + status = structure["STATUS"] != null + ? IEventStatus._(structure["STATUS"].value) + : null; + // TODO support missing event to do; + } + + DateTime _parseDate(ICalRow row) { + if(row == null || row.value == null) return null; + // TODO: add TZID support + return DateTime.parse(row.value); + } } class IEventStatus { diff --git a/lib/src/parser.dart b/lib/src/parser.dart new file mode 100644 index 0000000..e3c757c --- /dev/null +++ b/lib/src/parser.dart @@ -0,0 +1,42 @@ +import 'dart:convert'; + +import 'package:ical/serializer.dart'; +import 'package:ical/src/structure.dart'; +import 'package:ical/src/utils.dart'; + +const _rowRegex = r"""^([\w-]+);?([\w-]+="[^"]*"|.*?):(.*)$"""; +const _middleRegex = r"""(?[^=;]+)=(?[^;]+)"""; + +class ICalParser { + List parseText(String text) { + // unfold strings + text = text.replaceAll("\r\n", "\n").replaceAll("\n ", ""); + return LineSplitter() + .convert(text) + .map(_parseLine) + .where((element) => element != null) + .toList(); + } + + ICalRow _parseLine(String line) { + final regex = RegExp(_rowRegex); + final match = regex.firstMatch(line); + if(match == null) return null; + final String key = match.group(1); + final String middle = match.group(2); + final String value = unescapeValue(match.group(3)); + return ICalRow(key, value, properties: _parseMiddlePart(middle)); + } + + Map _parseMiddlePart(String middle) { + final matches = RegExp(_middleRegex).allMatches(middle); + final entries = matches + .map((match) => MapEntry(match.namedGroup("key"), unescapeValue(match.namedGroup("value")))); + return Map.fromEntries(entries); + } + + ICalendar parseCalender(String text) { + final rows = parseText(text); + return ICalendar()..deserialize(ICalStructure.fromRows(rows)); + } +} \ No newline at end of file diff --git a/lib/src/structure.dart b/lib/src/structure.dart new file mode 100644 index 0000000..acc260e --- /dev/null +++ b/lib/src/structure.dart @@ -0,0 +1,42 @@ +class ICalStructure { + final String type; + final List rows; + final List children; + + ICalRow operator [](String key) { + return rows.firstWhere((row) => row.key == key, orElse: () => null); + } + + const ICalStructure(this.type, this.rows, this.children); + + factory ICalStructure.fromRows(List rows) { + final beginRow = rows.removeAt(0); + if(beginRow == null) throw Exception("No begin row found"); + final type = beginRow.value; + final List children = []; + + var beginIndex = rows.indexWhere((element) => element.key == "BEGIN"); + while(beginIndex != -1) { + final subType = rows[beginIndex].value; + final endIndex = rows.indexWhere((element) => element.key == "END" && element.value == subType) + 1; + if(endIndex == 0) throw Exception("No END row found for type $subType"); + final subStructure = rows.getRange(beginIndex, endIndex); + children.add(ICalStructure.fromRows(subStructure.toList())); + rows.removeRange(beginIndex, endIndex); + beginIndex = rows.indexWhere((element) => element.key == "BEGIN"); + } + // remove END row + // TODO check if end row is valid + final endRow = rows.removeLast(); + if(endRow.key != "END" || endRow.value != type) throw Exception("Invalid END row"); + return ICalStructure(type, rows, children); + } +} + +class ICalRow { + final String key; + final String value; + final Map properties; + + const ICalRow(this.key, this.value, {this.properties = const {}}); +} \ No newline at end of file diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 05ea47a..929d734 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -25,3 +25,10 @@ String escapeValue(String val) => val .replaceAll('\t', '\\t') .replaceAll(',', '\\,') .replaceAll(';', '\\;'); + +String unescapeValue(String val) => val + .replaceAll('\\\\', '\\') + .replaceAll('\\n', '\n') + .replaceAll('\\t', '\t') + .replaceAll('\\,', ',') + .replaceAll('\\;', ';'); diff --git a/pubspec.yaml b/pubspec.yaml index 7beba18..96ca6ae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,10 +9,9 @@ environment: sdk: ">=2.0.0 <3.0.0" dependencies: - intl: ^0.16.0 - nanoid: ^0.1.0 + intl: ^0.17.0 + nanoid: ^1.0.0 dev_dependencies: test: ^1.5.1+1 pedantic: ^1.8.0 - diff --git a/test/parser.dart b/test/parser.dart new file mode 100644 index 0000000..a3e2291 --- /dev/null +++ b/test/parser.dart @@ -0,0 +1,83 @@ +import 'package:ical/src/parser.dart'; +import 'package:test/test.dart'; + +main() { + group('Parser', () { + ICalParser parser; + setUp(() { + parser = ICalParser(); + }); + test('parse single row', () { + const withMiddle = "DTSTART;VALUE=DATE:20200908"; + const withoutMiddle = "CREATED:20200827T080438Z"; + + var result = parser.parseText(withMiddle); + var row = result.firstWhere((element) => element.key == "DTSTART"); + expect(row?.value, "20200908"); + expect(row?.properties["VALUE"], "DATE"); + + result = parser.parseText(withoutMiddle); + row = result.firstWhere((element) => element.key == "CREATED"); + expect(row?.value, "20200827T080438Z"); + expect(row?.properties?.length, 0); + }); + + test('parse multiple rows', () { + const testIcs = """CREATED:20200827T080438Z +DTSTAMP:20200827T080438Z +LAST-MODIFIED:20200827T080438Z +DTSTART;VALUE=DATE:20200907 +DTEND;VALUE=DATE:20200908"""; + final result = parser.parseText(testIcs); + expect(result.length, 5); + expect(result.map((e) => e.key), + ["CREATED", "DTSTAMP", "LAST-MODIFIED", "DTSTART", "DTEND"]); + }); + + test('parse calendar', () { + const foldedRow = """TEST:testtesttesttesttesttest + testtest"""; + final row = parser + .parseText(foldedRow) + .firstWhere((element) => element.key == "TEST"); + expect(row?.value, "testtesttesttesttesttesttesttest"); + }); + + test('parse example ICalStructure', () { + const testIcs = """BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +BEGIN:VEVENT +SUMMARY:Access-A-Ride Pickup +DTSTART;TZID=America/New_York:20130802T103400 +DTEND;TZID=America/New_York:20130802T110400 +LOCATION:1000 Broadway Ave.\, Brooklyn +DESCRIPTION: Access-A-Ride trip to 900 Jay St.\, Brooklyn +STATUS:CONFIRMED +SEQUENCE:3 +BEGIN:VALARM +TRIGGER:-PT10M +DESCRIPTION:Pickup Reminder +ACTION:DISPLAY +END:VALARM +END:VEVENT +BEGIN:VEVENT +SUMMARY:Access-A-Ride Pickup +DTSTART;TZID=America/New_York:20130802T200000 +DTEND;TZID=America/New_York:20130802T203000 +LOCATION:900 Jay St.\, Brooklyn +DESCRIPTION: Access-A-Ride trip to 1000 Broadway Ave.\, Brooklyn +STATUS:CONFIRMED +SEQUENCE:3 +BEGIN:VALARM +TRIGGER:-PT10M +DESCRIPTION:Pickup Reminder +ACTION:DISPLAY +END:VALARM +END:VEVENT +END:VCALENDAR"""; + final result = parser.parseCalender(testIcs); + expect(result.events.length, 2); + }); + }); +} diff --git a/test/structure.dart b/test/structure.dart new file mode 100644 index 0000000..d5d67ad --- /dev/null +++ b/test/structure.dart @@ -0,0 +1,52 @@ +import 'package:ical/src/parser.dart'; +import 'package:ical/src/structure.dart'; +import 'package:test/test.dart'; + +main() { + group('Structure', () { + ICalParser parser; + setUp(() { + parser = ICalParser(); + }); + + test('parse example ICalStructure', () { + const testIcs = """BEGIN:VCALENDAR +VERSION:2.0 +CALSCALE:GREGORIAN +BEGIN:VEVENT +SUMMARY:Access-A-Ride Pickup +DTSTART;TZID=America/New_York:20130802T103400 +DTEND;TZID=America/New_York:20130802T110400 +LOCATION:1000 Broadway Ave.\, Brooklyn +DESCRIPTION: Access-A-Ride trip to 900 Jay St.\, Brooklyn +STATUS:CONFIRMED +SEQUENCE:3 +BEGIN:VALARM +TRIGGER:-PT10M +DESCRIPTION:Pickup Reminder +ACTION:DISPLAY +END:VALARM +END:VEVENT +BEGIN:VEVENT +SUMMARY:Access-A-Ride Pickup +DTSTART;TZID=America/New_York:20130802T200000 +DTEND;TZID=America/New_York:20130802T203000 +LOCATION:900 Jay St.\, Brooklyn +DESCRIPTION: Access-A-Ride trip to 1000 Broadway Ave.\, Brooklyn +STATUS:CONFIRMED +SEQUENCE:3 +BEGIN:VALARM +TRIGGER:-PT10M +DESCRIPTION:Pickup Reminder +ACTION:DISPLAY +END:VALARM +END:VEVENT +END:VCALENDAR"""; + final result = parser.parseText(testIcs); + final structure = ICalStructure.fromRows(result); + expect(structure.type, "VCALENDAR"); + expect(structure.children.length, 2); + expect(structure["VERSION"]?.value, "2.0"); + }); + }); +}