diff --git a/.idea/libraries/Dart_SDK.xml b/.idea/libraries/Dart_SDK.xml
new file mode 100644
index 0000000..c0d7005
--- /dev/null
+++ b/.idea/libraries/Dart_SDK.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..b900b48
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 0000000..5b3388c
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.metadata b/.metadata
new file mode 100644
index 0000000..d536721
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,10 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: f4abaa0735eba4dfd8f33f73363911d63931fe03
+ channel: stable
+
+project_type: package
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8e63453
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.1.0
+
+* Initial release with full parsing and high level API support.
diff --git a/README.md b/README.md
index be091ed..602d72d 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,74 @@
# enough_icalendar
-icalendar library in pure Dart. Fully compliant with RFC 5545.
+icalendar library in pure Dart. Fully compliant with [RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545).
+
+## Usage
+
+Using `enough_icalendar` is pretty straight forward:
+
+```dart
+import 'package:enough_icalendar/enough_icalendar.dart';
+
+void main() {
+ final text = '' final text = '''BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:uid1@example.com
+DTSTAMP:19970714T170000Z
+ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
+DTSTART:19970714T170000Z
+DTEND:19970715T035959Z
+SUMMARY:Bastille Day Party
+GEO:48.85299;2.36885
+END:VEVENT
+END:VCALENDAR''';
+ final icalendar = Component.parse(text) as VCalendar;
+ print(icalendar.productId);
+ final event = icalendar.children.first as VEvent;
+ print(event.summary); // Bastille Day Party
+ print(event.start); // 1997-06-14 at 17:00
+ print(event.end); // 1997-07-15 at 03:59:59
+ print(event.organizer?.commonName); // John Doe
+ print(event.organizer?.email); // john.doe@example.com
+ print(event.geoLocation?.latitude); // 48.85299
+ print(event.geoLocation?.longitude); // 2.36885
+}
+```
+
+## Installation
+Add this dependency your pubspec.yaml file:
+
+```
+dependencies:
+ enough_icalendar: ^0.1.0
+```
+The latest version or `enough_icalendar` is [![enough_icalendar version](https://img.shields.io/pub/v/enough_icalendar.svg)](https://pub.dartlang.org/packages/enough_icalendar).
+
+
+
+## API Documentation
+Check out the full API documentation at https://pub.dev/documentation/enough_icalendar/latest/
+
+
+## Features and bugs
+
+`enough_icalendar` supports all icalendar components and provides easy to access mmodels:
+* `VCALENDAR`
+* `VEVENT`
+* `VTIMEZONE` with the `STANDARD` and `DAYLIGHT` subcomponents
+* `VALARM`
+* `VFREEBUSY`
+* `VTODO`
+* `VJOURNAL`
+
+
+Please file feature requests and bugs at the [issue tracker][tracker].
+
+[tracker]: https://github.com/Enough-Software/enough_icalendar/issues
+
+## Null-Safety
+`enough_icalendar` is null-safe.
+
+## License
+`enough_icalendar` is licensed under the commercial friendly [Mozilla Public License 2.0](LICENSE)
+
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..026ff19
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1 @@
+package:lints/recommended.yaml
\ No newline at end of file
diff --git a/enough_icalendar.iml b/enough_icalendar.iml
new file mode 100644
index 0000000..6048a33
--- /dev/null
+++ b/enough_icalendar.iml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/example/enough_icalendar_example.dart b/example/enough_icalendar_example.dart
new file mode 100644
index 0000000..ed3038b
--- /dev/null
+++ b/example/enough_icalendar_example.dart
@@ -0,0 +1,27 @@
+import 'package:enough_icalendar/enough_icalendar.dart';
+
+void main() {
+ final text = '''BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:uid1@example.com
+DTSTAMP:19970714T170000Z
+ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
+DTSTART:19970714T170000Z
+DTEND:19970715T035959Z
+SUMMARY:Bastille Day Party
+GEO:48.85299;2.36885
+END:VEVENT
+END:VCALENDAR''';
+ final icalendar = Component.parse(text) as VCalendar;
+ print(icalendar.productId);
+ final event = icalendar.children.first as VEvent;
+ print(event.summary); // Bastille Day Party
+ print(event.start); // 1997-06-14 at 17:00
+ print(event.end); // 1997-07-15 at 03:59:59
+ print(event.organizer?.commonName); // John Doe
+ print(event.organizer?.email); // john.doe@example.com
+ print(event.geoLocation?.latitude); // 48.85299
+ print(event.geoLocation?.longitude); // 2.36885
+}
diff --git a/lib/enough_icalendar.dart b/lib/enough_icalendar.dart
new file mode 100644
index 0000000..883a197
--- /dev/null
+++ b/lib/enough_icalendar.dart
@@ -0,0 +1,6 @@
+library enough_icalendar;
+
+export 'src/parameters.dart';
+export 'src/properties.dart';
+export 'src/components.dart';
+export 'src/types.dart';
diff --git a/lib/src/components.dart b/lib/src/components.dart
new file mode 100644
index 0000000..b51d0a9
--- /dev/null
+++ b/lib/src/components.dart
@@ -0,0 +1,767 @@
+import 'package:enough_icalendar/enough_icalendar.dart';
+
+import 'properties.dart';
+import 'package:collection/collection.dart' show IterableExtension;
+
+/// The type of the component, convenient for switch cases
+enum ComponentType {
+ calendar,
+ event,
+ todo,
+ journal,
+ freeBusy,
+ timezone,
+ timezonePhaseStandard,
+ timezonePhaseDaylight,
+ alarm,
+
+ /// reserved for future / custom components
+ other
+}
+
+/// Commmon properties
+class Component {
+ /// The type of the component, convenient for switch cases
+ final ComponentType componentType;
+
+ /// The name of the component like `VEVENT` or `VCALENDAR`
+ final String name;
+
+ /// The properties of the component
+ final List properties = [];
+
+ /// The parent component, if any
+ final Component? parent;
+
+ /// The children of this component, empty when there a no children
+ final List children = [];
+
+ Component(this.name, [this.parent]) : componentType = _getComponentType(name);
+
+ static ComponentType _getComponentType(String name) {
+ switch (name) {
+ case VCalendar.componentName:
+ return ComponentType.calendar;
+ case VEvent.componentName:
+ return ComponentType.event;
+ case VTimezone.componentName:
+ return ComponentType.timezone;
+ case VTimezonePhase.componentNameStandard:
+ return ComponentType.timezonePhaseStandard;
+ case VTimezonePhase.componentNameDaylight:
+ return ComponentType.timezonePhaseDaylight;
+ case VTodo.componentName:
+ return ComponentType.todo;
+ case VJournal.componentName:
+ return ComponentType.journal;
+ case VAlarm.componentName:
+ return ComponentType.alarm;
+ case VFreeBusy.componentName:
+ return ComponentType.freeBusy;
+ }
+ print(
+ 'Warning: Component not registered: $name (in Component._getComponentType)');
+ return ComponentType.other;
+ }
+
+ /// Gets the version of this calendar, typically `2.0`
+ String? get version =>
+ getProperty(VersionProperty.propertyName)?.textValue;
+
+ /// Checks if this version is `2.0`, which is assumed to be true unless a different version is specified.
+ bool get isVersion2 =>
+ getProperty(VersionProperty.propertyName)?.isVersion2 ??
+ true;
+
+ /// Retrieves the product identifier that generated this iCalendar object
+ String? get productId =>
+ getProperty(TextProperty.propertyNameProductIdentifier)
+ ?.textValue;
+
+ /// Classes can implement this to check the validity.
+ ///
+ /// If the component missed required information, throw a [FormatException] with details.
+ void checkValidity() {}
+
+ /// Retrieves all properties with the name [propertyName]
+ Iterable findProperties(final String propertyName) =>
+ properties.where((p) => p.name == propertyName);
+
+ /// Retrieves the property with the [propertyName]
+ ///
+ /// Optionally specify the type [T] for not needing to cast yourself
+ T? getProperty(final String propertyName) =>
+ properties.firstWhereOrNull((prop) => prop.name == propertyName) as T?;
+
+ /// Retrieves all matching properties with the name [propertyName].
+ ///
+ /// Optionally specify the type [T] for not needing to cast yourself
+ Iterable getProperties(final String propertyName) =>
+ properties.where((prop) => prop.name == propertyName).map((e) => e as T);
+
+ /// Retrieves the first property with the name [propertyName]
+ Property? operator [](final String propertyName) =>
+ properties.firstWhereOrNull((prop) => prop.name == propertyName);
+
+ /// Sets the property [property], replacing other properties with the given [propertyName] first.
+ operator []=(final String propertyName, final Property property) {
+ properties.removeWhere((prop) => prop.name == propertyName);
+ properties.add(property);
+ }
+
+ /// Parses the component from the specified [text].
+ ///
+ /// When succeeding, this returns a [VCalendar], [VEvent] or similar component as defined by the given [text].
+ /// The [text] can either contain `\r\n` (`CRLF`) or `\n` linebreaks, when both linebreak types are present in the [text], `CRLF` linebreaks are assumed.
+ /// Folded lines are unfolded automatically.
+ /// When you have a custom line delimiter, use [parseLines] instead.
+ static Component parse(String text,
+ {Property? Function(String name, String definition)? customParser}) {
+ final containsStandardCompliantLineBreaks = text.contains('\r\n');
+ final foldedLines = containsStandardCompliantLineBreaks
+ ? text.split('\r\n')
+ : text.split('\n');
+ final lines = unfold(
+ foldedLines,
+ containsStandardCompliantLineBreaks: containsStandardCompliantLineBreaks,
+ );
+ if (lines.isEmpty) {
+ throw FormatException('Invalid input: [$text]');
+ }
+ return parseLines(lines);
+ }
+
+ /// Parses the component from the specified text [lines].
+ ///
+ /// Compare [parse] for details.
+ static Component parseLines(List lines) {
+ Component root = _createComponent(lines.first);
+ Component current = root;
+ for (var i = 1; i < lines.length; i++) {
+ final line = lines[i];
+ if (line.startsWith('BEGIN:')) {
+ final child = _createComponent(line, parent: current);
+ current.children.add(child);
+ current = child;
+ } else if (line.startsWith('END:')) {
+ final expected = 'END:${current.name}';
+ if (line != expected) {
+ throw FormatException('Received $line but expected $expected');
+ }
+ current.checkValidity();
+ final parent = current.parent;
+ if (parent != null) {
+ current = parent;
+ }
+ } else {
+ final property = Property.parseProperty(line);
+ current.properties.add(property);
+ }
+ }
+ return root;
+ }
+
+ /// Creates the component based on the first line
+ static Component _createComponent(String line, {Component? parent}) {
+ switch (line) {
+ case 'BEGIN:VCALENDAR':
+ return VCalendar(parent: parent);
+ case 'BEGIN:VEVENT':
+ return VEvent(parent: parent);
+ case 'BEGIN:VTIMEZONE':
+ return VTimezone(parent: parent);
+ case 'BEGIN:STANDARD':
+ return VTimezonePhase(VTimezonePhase.componentNameStandard,
+ parent: parent as VTimezone);
+ case 'BEGIN:DAYLIGHT':
+ return VTimezonePhase(VTimezonePhase.componentNameDaylight,
+ parent: parent as VTimezone);
+ case 'BEGIN:VTODO':
+ return VTodo(parent: parent);
+ case 'BEGIN:VJOURNAL':
+ return VJournal(parent: parent);
+ case 'BEGIN:VALARM':
+ return VAlarm(parent: parent);
+ case 'BEGIN:VFREEBUSY':
+ return VFreeBusy(parent: parent);
+ default:
+ throw FormatException('Unknown component: $line');
+ }
+ }
+
+ /// Unfolds the given [input] lines
+ ///
+ /// When [containsStandardCompliantLineBreaks] is not the default `true`, then extra care is taken
+ /// to re-include lines that have been split in error.
+ static List unfold(List input,
+ {bool containsStandardCompliantLineBreaks = true}) {
+ final output = [];
+ StringBuffer? buffer;
+ for (var i = 0; i < input.length; i++) {
+ final current = input[i];
+ if (buffer != null) {
+ if (current.startsWithWhiteSpace() && current.length > 1) {
+ buffer.write(current.trimLeft());
+ if (i == input.length - 1) {
+ output.add(buffer.toString());
+ }
+ continue;
+ } else if (!containsStandardCompliantLineBreaks &&
+ !current.contains(':')) {
+ // this can happen when the description or similiar fields also contain \n linebreaks
+ buffer..write('\n')..write(current.trimLeft());
+ if (i == input.length - 1) {
+ output.add(buffer.toString());
+ }
+ continue;
+ } else {
+ // this is then end of the current fold:
+ output.add(buffer.toString());
+ buffer = null;
+ }
+ }
+ if (i < input.length - 1) {
+ final next = input[i + 1];
+ if (next.startsWithWhiteSpace()) {
+ buffer = StringBuffer();
+ buffer.write(current);
+ } else if (current.isNotEmpty) {
+ output.add(current);
+ }
+ } else if (current.isNotEmpty) {
+ output.add(current);
+ }
+ }
+ return output;
+ }
+
+ /// Checks if the property with the given [name] is present.
+ ///
+ /// Throws [FormatException] when the property is missing.
+ void checkMandatoryProperty(String name) {
+ if (this[name] == null) {
+ throw FormatException('Mandatory property "$name" is missing.');
+ }
+ }
+}
+
+extension WhiteSpaceDetector on String {
+ bool startsWithWhiteSpace() {
+ return startsWith(' ') || startsWith('\t');
+ }
+}
+
+/// Contains a `VCALENDAR` component
+///
+/// Often the parent component for others such as [VEvent]
+class VCalendar extends Component {
+ static const String componentName = 'VCALENDAR';
+ VCalendar({Component? parent}) : super(componentName, parent);
+
+ /// Retrieves the scale of the calendar, typically `GREGORIAN`
+ ///
+ /// Compare [isGregorian]
+ String get calendarScale =>
+ getProperty(CalendarScaleProperty.propertyName)
+ ?.textValue ??
+ 'GREGORIAN';
+
+ /// Checks if this calendar has a Gregorian scale.
+ ///
+ /// Compare [calendarScale]
+ bool get isGregorian => calendarScale == 'GREGORIAN';
+
+ /// Retrieves the method by which answers are expected
+ String? get method =>
+ getProperty(TextProperty.propertyNameMethod)?.textValue;
+
+ /// Retrieves the global timezone ID like `America/New_York` or `Europe/Berlin` using the propriety but common `X-WR-TIMEZONE` property.
+ ///
+ /// Any dates of subsequent components without explicit timezoneId should be interpreted according to this
+ /// timezone ID. For caveats compare https://blog.jonudell.net/2011/10/17/x-wr-timezone-considered-harmful/
+ String? get timezoneId =>
+ getProperty(TextProperty.propertyNameXWrTimezone)
+ ?.textValue;
+}
+
+class _UidMandatoryComponent extends Component {
+ _UidMandatoryComponent(String name, [Component? parent])
+ : super(name, parent);
+
+ /// Retrieves the UID identifying this calendar component
+ String get uid => this[TextProperty.propertyNameUid]!.textValue;
+
+ /// Sets the UID identifying this calendar component
+ set uid(String value) => this[TextProperty.propertyNameUid] =
+ TextProperty('${TextProperty.propertyNameUid}:$value');
+
+ /// Mandatory timestamp / `DTSTAMP` property
+ DateTime get timeStamp =>
+ getProperty(DateTimeProperty.propertyNameTimeStamp)!
+ .dateTime;
+
+ @override
+ void checkValidity() {
+ super.checkValidity();
+ checkMandatoryProperty(TextProperty.propertyNameUid);
+ checkMandatoryProperty(DateTimeProperty.propertyNameTimeStamp);
+ }
+}
+
+class _EventTodoJournalComponent extends _UidMandatoryComponent {
+ _EventTodoJournalComponent(String name, Component? parent)
+ : super(name, parent);
+
+ /// This property defines the access classification for a calendar component
+ Classification? get classification =>
+ getProperty(ClassificationProperty.propertyName)
+ ?.classification;
+
+ /// Retrieves the attachments
+ List get attachments =>
+ getProperties(AttachmentProperty.propertyName)
+ .toList();
+
+ /// Retrieves the free text categories
+ List? get categories =>
+ getProperty(CategoriesProperty.propertyName)
+ ?.categories;
+
+ /// Gets the summmary / title
+ String? get summary =>
+ getProperty(TextProperty.propertyNameSummary)?.textValue;
+
+ /// Retrieves the description
+ String? get description =>
+ getProperty(TextProperty.propertyNameDescription)?.text;
+
+ /// Retrieves the comment
+ String? get comment =>
+ getProperty(TextProperty.propertyNameComment)?.text;
+
+ /// Retrieves the attendees
+ List get attendees =>
+ getProperties(AttendeeProperty.propertyName).toList();
+
+ /// Retrieves the organizer of this event
+ OrganizerProperty? get organizer =>
+ getProperty(OrganizerProperty.propertyName);
+
+ /// Retrieves the contact for details
+ UserProperty? get contact =>
+ getProperty(UserProperty.propertyNameContact);
+
+ /// Identifies a particular instance of a recurring event, to-do, or journal.
+ ///
+ /// For a given pair of "UID" and "SEQUENCE" property values, the
+ /// "RECURRENCE-ID" value for a recurrence instance is fixed.
+ DateTime? get recurrenceId =>
+ getProperty(DateTimeProperty.propertyNameRecurrenceId)
+ ?.dateTime;
+
+ /// Retrieves the recurrence rule of this event
+ ///
+ /// Compare [additionalRecurrenceDates], [excludingRecurrenceDates]
+ Recurrence? get recurrenceRule =>
+ getProperty(RecurrenceRuleProperty.propertyName)
+ ?.rule;
+
+ /// Retrieves additional reccurrence dates or durations as defined in the `RDATE` property
+ ///
+ /// Compare [excludingRecurrenceDates], [recurrenceRule]
+ List? get additionalRecurrenceDates =>
+ getProperty(
+ RecurrenceDateProperty.propertyNameRDate)
+ ?.dates;
+
+ /// Retrieves excluding reccurrence dates or durations as defined in the `EXDATE` property
+ ///
+ /// Compare [additionalRecurrenceDates], [recurrenceRule]
+ List? get excludingRecurrenceDates =>
+ getProperty(
+ RecurrenceDateProperty.propertyNameExDate)
+ ?.dates;
+
+ /// Retrieves the UID of a related event, to-do or journal.
+ String? get relatedTo =>
+ getProperty(TextProperty.propertyNameRelatedTo)?.text;
+
+ /// Retrieves the URL for additional information
+ Uri? get url => getProperty(UriProperty.propertyNameUrl)?.uri;
+
+ /// The creation date
+ DateTime? get created =>
+ getProperty(DateTimeProperty.propertyNameCreated)
+ ?.dateTime;
+
+ /// The date of the last modification / update of this event.
+ DateTime? get lastModified =>
+ getProperty(DateTimeProperty.propertyNameLastModified)
+ ?.dateTime;
+
+ /// Gets the revision sequence number of this component
+ int? get sequence =>
+ getProperty(IntegerProperty.propertyNameSequence)
+ ?.intValue;
+
+ /// Retrieves the request status, e.g. `4.1;Event conflict. Date-time is busy.`
+ String? get requestStatus =>
+ getProperty(RequestStatusProperty.propertyName)
+ ?.requestStatus;
+}
+
+/// Contains information about an event.
+class VEvent extends _EventTodoJournalComponent {
+ static const String componentName = 'VEVENT';
+ VEvent({Component? parent}) : super(componentName, parent);
+
+ /// Tries to the timezone ID like `America/New_York` or `Europe/Berlin` from `DTSTART` property.
+ String? get timezoneId {
+ final prop = getProperty(
+ DateTimeProperty.propertyNameStart) ??
+ getProperty(DateTimeProperty.propertyNameEnd) ??
+ getProperty(DateTimeProperty.propertyNameTimeStamp);
+ return prop?.timezoneId;
+ }
+
+ /// The start time (inclusive) of this event.
+ ///
+ /// is REQUIRED if the component appears in an iCalendar object that doesn't
+ /// specify the "METHOD" property; otherwise, it is OPTIONAL; in any case, it MUST NOT occur
+ /// more than once.
+ DateTime? get start =>
+ getProperty(DateTimeProperty.propertyNameStart)
+ ?.dateTime;
+
+ /// The end date (exclusive) of this event.
+ ///
+ /// either `DTEND` or `DURATION` may occur, but not both
+ /// Compare [duration]
+ DateTime? get end =>
+ getProperty(DateTimeProperty.propertyNameEnd)?.dateTime;
+
+ /// The duration of this event.
+ ///
+ /// either `DTEND` or `DURATION` may occur, but not both
+ /// Compare [end]
+ IsoDuration? get duration =>
+ getProperty(DurationProperty.propertyName)?.duration;
+
+ /// The location e.g. room number / name
+ String? get location =>
+ getProperty(TextProperty.propertyNameLocation)?.textValue;
+
+ /// The geo location of this event.
+ GeoLocation? get geoLocation =>
+ getProperty(GeoProperty.propertyName)?.location;
+
+ /// Retrieves the transparency of this event in regards to busy time searches.
+ TimeTransparency get timeTransparency =>
+ getProperty(
+ TimeTransparencyProperty.propertyName)
+ ?.transparency ??
+ TimeTransparency.opaque;
+
+ /// Retrieves the status of this event
+ EventStatus? get status =>
+ getProperty(StatusProperty.propertyName)?.eventStatus;
+
+ /// Retrieves the priority as a numeric value between 1 (highest) and 9 (lowest) priority.
+ int? get priorityInt =>
+ getProperty(PriorityProperty.propertyName)?.intValue;
+
+ /// Retrieves the priority of this event
+ Priority? get priority =>
+ getProperty(PriorityProperty.propertyName)?.priority;
+
+ /// Retrieves the resources required for this event
+ String? get resources =>
+ getProperty(TextProperty.propertyNameResources)?.text;
+ // @override
+ // void checkValidity() {
+ // super.checkValidity();
+ // }
+}
+
+class VTodo extends _EventTodoJournalComponent {
+ static const String componentName = 'VTODO';
+ VTodo({Component? parent}) : super(componentName, parent);
+
+ /// Gets the revision sequence number of this component
+ int? get sequence =>
+ getProperty(IntegerProperty.propertyNameSequence)
+ ?.intValue;
+
+ /// Retrieves the attendees
+ List get attendees =>
+ getProperties(AttendeeProperty.propertyName).toList();
+
+ /// Retrieves the organizer of this event
+ OrganizerProperty? get organizer =>
+ getProperty(OrganizerProperty.propertyName);
+
+ /// The summmary / title
+ String? get summary =>
+ getProperty(TextProperty.propertyNameSummary)?.textValue;
+
+ /// The description
+ String? get description =>
+ getProperty(TextProperty.propertyNameDescription)
+ ?.textValue;
+
+ /// The status of this todo
+ TodoStatus get status =>
+ getProperty(StatusProperty.propertyName)?.todoStatus ??
+ TodoStatus.unknown;
+
+ /// Retrieves the due date of this task
+ DateTime? get due =>
+ getProperty(DateTimeProperty.propertyNameDue)?.dateTime;
+
+ /// Retrieves the start date of this task
+ DateTime? get start =>
+ getProperty(DateTimeProperty.propertyNameStart)
+ ?.dateTime;
+
+ /// Retrieves the date when this task was completed
+ DateTime? get completed =>
+ getProperty(DateTimeProperty.propertyNameCompleted)
+ ?.dateTime;
+
+ /// Retrieves the duration of the task
+ IsoDuration? get duration =>
+ getProperty(DurationProperty.propertyName)?.duration;
+
+ /// The geo location of this event.
+ GeoLocation? get geoLocation =>
+ getProperty(GeoProperty.propertyName)?.location;
+
+ /// The location e.g. room number / name
+ String? get location =>
+ getProperty(TextProperty.propertyNameLocation)?.textValue;
+
+ /// Retrieves the percentage value between 0 and 100 that shows how much is done of this task,
+ ///
+ /// 100 means the task is fully done; 0 means the task has not been started.
+ int? get percentComplete =>
+ getProperty(IntegerProperty.propertyNamePercentComplete)
+ ?.intValue;
+
+ /// Retrieves the priority as a numeric value between 1 (highest) and 9 (lowest) priority.
+ int? get priorityInt =>
+ getProperty(PriorityProperty.propertyName)?.intValue;
+
+ /// Retrieves the priority of this task
+ Priority? get priority =>
+ getProperty(PriorityProperty.propertyName)?.priority;
+
+ /// Retrieves the resources required for this task
+ String? get resources =>
+ getProperty(TextProperty.propertyNameResources)?.text;
+}
+
+class VJournal extends _EventTodoJournalComponent {
+ static const String componentName = 'VJOURNAL';
+ VJournal({Component? parent}) : super(componentName, parent);
+
+ /// Gets the revision sequence number of this component
+ int? get sequence =>
+ getProperty(IntegerProperty.propertyNameSequence)
+ ?.intValue;
+
+ /// The status of this journal entry
+ JournalStatus get status =>
+ getProperty(StatusProperty.propertyName)?.journalStatus ??
+ JournalStatus.unknown;
+}
+
+class VTimezone extends Component {
+ static const String componentName = 'VTIMEZONE';
+ VTimezone({Component? parent}) : super(componentName, parent);
+
+ /// Retrieves the ID such as `America/New_York` or `Europe/Berlin`
+ String get timezoneId =>
+ getProperty(TextProperty.propertyNameTimezoneId)!.textValue;
+
+ Uri? get uri =>
+ getProperty(UriProperty.propertyNameTimezoneUrl)?.uri;
+
+ /// The date of the last modification / update of this event.
+ DateTime? get lastModified =>
+ getProperty(DateTimeProperty.propertyNameLastModified)
+ ?.dateTime;
+
+ @override
+ void checkValidity() {
+ super.checkValidity();
+ checkMandatoryProperty(TextProperty.propertyNameTimezoneId);
+ if (children.length < 2) {
+ throw FormatException(
+ 'A valid VTIMEZONE requires at least one STANDARD and one DAYLIGHT sub-component');
+ }
+ var numberOfStandardChildren = 0, numberOfDaylightChildren = 0;
+ for (final phase in children) {
+ if (phase.componentType == ComponentType.timezonePhaseStandard) {
+ numberOfStandardChildren++;
+ } else if (phase.componentType == ComponentType.timezonePhaseDaylight) {
+ numberOfDaylightChildren++;
+ }
+ }
+ if (numberOfStandardChildren == 0 || numberOfDaylightChildren == 0) {
+ throw FormatException(
+ 'A valid VTIMEZONE requires at least one STANDARD and one DAYLIGHT sub-component');
+ }
+ }
+}
+
+class VTimezonePhase extends Component {
+ static const String componentNameStandard = 'STANDARD';
+ static const String componentNameDaylight = 'DAYLIGHT';
+
+ VTimezonePhase(String componentName, {required VTimezone parent})
+ : super(componentName, parent);
+ DateTime get start =>
+ getProperty(DateTimeProperty.propertyNameStart)!
+ .dateTime;
+
+ UtcOffset get from => getProperty(
+ UtfOffsetProperty.propertyNameTimezoneOffsetFrom)!
+ .offset;
+
+ UtcOffset get to => getProperty(
+ UtfOffsetProperty.propertyNameTimezoneOffsetTo)!
+ .offset;
+
+ //TODO the name property can occur more than once
+ String? get timezoneName =>
+ getProperty(TextProperty.propertyNameTimezoneName)
+ ?.textValue;
+
+ /// Retrieves the comment
+ String? get comment =>
+ getProperty(TextProperty.propertyNameComment)?.text;
+
+ /// Retrieves the recurrence rule of this event
+ ///
+ /// Compare [additionalRecurrenceDates], [excludingRecurrenceDates]
+ Recurrence? get recurrenceRule =>
+ getProperty(RecurrenceRuleProperty.propertyName)
+ ?.rule;
+
+ /// Retrieves additional reccurrence dates or durations as defined in the `RDATE` property
+ ///
+ /// Compare [excludingRecurrenceDates], [recurrenceRule]
+ List? get additionalRecurrenceDates =>
+ getProperty(
+ RecurrenceDateProperty.propertyNameRDate)
+ ?.dates;
+
+ /// Retrieves excluding reccurrence dates or durations as defined in the `EXDATE` property
+ ///
+ /// Compare [additionalRecurrenceDates], [recurrenceRule]
+ List? get excludingRecurrenceDates =>
+ getProperty(
+ RecurrenceDateProperty.propertyNameExDate)
+ ?.dates;
+
+ @override
+ void checkValidity() {
+ super.checkValidity();
+ checkMandatoryProperty(DateTimeProperty.propertyNameStart);
+ checkMandatoryProperty(UtfOffsetProperty.propertyNameTimezoneOffsetFrom);
+ checkMandatoryProperty(UtfOffsetProperty.propertyNameTimezoneOffsetTo);
+ }
+}
+
+class VAlarm extends Component {
+ static const String componentName = 'VALARM';
+ VAlarm({Component? parent}) : super(componentName, parent);
+
+ DateTime? get triggerDate =>
+ getProperty(TriggerProperty.propertyName)?.dateTime;
+ IsoDuration? get triggerRelativeDuration =>
+ getProperty(TriggerProperty.propertyName)?.duration;
+
+ int get repeat =>
+ getProperty(IntegerProperty.propertyNameRepeat)
+ ?.intValue ??
+ 0;
+
+ AlarmAction get action =>
+ getProperty(ActionProperty.propertyName)?.action ??
+ AlarmAction.other;
+
+ String? get actionText =>
+ getProperty(ActionProperty.propertyName)?.textValue;
+
+ /// Retrieves the duration of the alarm
+ IsoDuration? get duration =>
+ getProperty(DurationProperty.propertyName)?.duration;
+
+ /// Retrieves the attachments
+ List get attachments =>
+ getProperties(AttachmentProperty.propertyName)
+ .toList();
+
+ /// Gets the summmary / title
+ String? get summary =>
+ getProperty(TextProperty.propertyNameSummary)?.textValue;
+
+ /// Retrieves the description
+ String? get description =>
+ getProperty(TextProperty.propertyNameDescription)?.text;
+
+ /// Retrieves the attendees
+ List get attendees =>
+ getProperties(AttendeeProperty.propertyName).toList();
+
+ @override
+ void checkValidity() {
+ super.checkValidity();
+ checkMandatoryProperty(TriggerProperty.propertyName);
+ checkMandatoryProperty(ActionProperty.propertyName);
+ }
+}
+
+/// Provides information about free and busy times of a particular user
+class VFreeBusy extends _UidMandatoryComponent {
+ static const String componentName = 'VFREEBUSY';
+ VFreeBusy({Component? parent}) : super(componentName, parent);
+
+ /// Retrieves the list of free busy entries
+ List get freeBusyProperties =>
+ getProperties(FreeBusyProperty.propertyName).toList();
+
+ /// Retrieves the comment
+ String? get comment =>
+ getProperty(TextProperty.propertyNameComment)?.text;
+
+ /// The start time (inclusive) of the free busy time.
+ DateTime? get start =>
+ getProperty(DateTimeProperty.propertyNameStart)
+ ?.dateTime;
+
+ /// The end date (exclusive) of the free busy time.
+ DateTime? get end =>
+ getProperty(DateTimeProperty.propertyNameEnd)?.dateTime;
+
+ /// Retrieves the contact for details
+ UserProperty? get contact =>
+ getProperty(UserProperty.propertyNameContact);
+
+ /// Retrieves the request status, e.g. `4.1;Event conflict. Date-time is busy.`
+ String? get requestStatus =>
+ getProperty(RequestStatusProperty.propertyName)
+ ?.requestStatus;
+
+ /// Retrieves the URL for additional information
+ Uri? get url => getProperty(UriProperty.propertyNameUrl)?.uri;
+
+ /// Retrieves the attendees
+ List get attendees =>
+ getProperties(AttendeeProperty.propertyName).toList();
+
+ /// Retrieves the organizer of this event
+ OrganizerProperty? get organizer =>
+ getProperty(OrganizerProperty.propertyName);
+}
diff --git a/lib/src/parameters.dart b/lib/src/parameters.dart
new file mode 100644
index 0000000..ed4c02a
--- /dev/null
+++ b/lib/src/parameters.dart
@@ -0,0 +1,535 @@
+import 'dart:core';
+import 'types.dart';
+import 'util.dart';
+
+/// Contains a property parameter
+///
+/// In contrast to properties, the type of a parameter is always predefined.
+abstract class Parameter {
+ /// the standard type or ParameterType.other
+ final ParameterType type;
+
+ /// the name of this parameter
+ final String name;
+
+ /// the unparsed text value of this parameter
+ final String textValue;
+
+ /// The parsed value
+ final T value;
+
+ /// The type of the value
+ final ValueType valueType;
+
+ /// Creates a new parameter
+ Parameter(this.type, this.name, this.textValue, this.valueType, this.value);
+
+ /// Parses the given [definition] and generates a corresponding Parameter.
+ static Parameter parse(String definition) {
+ final splitIndex = definition.indexOf('=');
+ if (splitIndex == -1) {
+ throw FormatException(
+ 'No equals sign (=) found in parameter [$definition]');
+ }
+ final name = definition.substring(0, splitIndex);
+ final value = definition.substring(splitIndex + 1);
+ switch (name) {
+ case 'ALTREP':
+ return UriParameter(ParameterType.alternateRepresentation, name, value);
+ case 'CN':
+ return TextParameter(ParameterType.commonName, name, value);
+ case 'CUTYPE':
+ return CalendarUserTypeParameter(
+ ParameterType.calendarUserType, name, value);
+ case 'DELEGATED-FROM':
+ return CalendarAddressParameter(
+ ParameterType.delegateFrom, name, value);
+ case 'DELEGATED-TO':
+ return CalendarAddressParameter(ParameterType.delegateTo, name, value);
+ case 'DIR':
+ return UriParameter(ParameterType.directory, name, value);
+ case 'ENCODING':
+ return TextParameter(ParameterType.encoding, name, value);
+ case 'FMTTYPE':
+ return TextParameter(ParameterType.formatType, name, value);
+ case 'FBTYPE':
+ return FreeBusyStatusParameter(
+ ParameterType.freeBusyTimeType, name, value);
+ case 'LANGUAGE':
+ return TextParameter(ParameterType.language, name, value);
+ case 'MEMBER':
+ return UriListParameter(ParameterType.member, name, value);
+ case 'PARTSTAT':
+ return ParticipantStatusParameter(
+ ParameterType.participantStatus, name, value);
+ case 'RANGE':
+ return RangeParameter(ParameterType.range, name, value);
+ case 'RELATED':
+ return AlarmTriggerRelationshipParameter(
+ ParameterType.alarmTriggerRelationship, name, value);
+ case 'RELTYPE':
+ return RelationshipParameter(
+ ParameterType.relationshipType, name, value);
+ case 'ROLE':
+ return ParticipantRoleParameter(
+ ParameterType.participantRole, name, value);
+ case 'RSVP':
+ return BooleanParameter(ParameterType.rsvp, name, value);
+ case 'SENT-BY':
+ return CalendarAddressParameter(ParameterType.sentBy, name, value);
+ case 'TZID':
+ return TextParameter(ParameterType.timezoneId, name, value);
+ case 'VALUE':
+ return ValueParameter(ParameterType.value, name, value);
+ default:
+ print('Encountered unsupported parameter [$name]');
+ return TextParameter(ParameterType.other, name, value);
+ }
+ }
+}
+
+/// Parameter that contain an URI like `ALTREP`
+class UriParameter extends Parameter {
+ /// Retrieves the value as Uri
+ Uri get uri => value;
+
+ UriParameter(ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.uri, parse(textValue));
+
+ static Uri parse(String textValue) {
+ if (textValue.startsWith('"')) {
+ textValue = textValue.substring(1, textValue.length - 1);
+ }
+ return Uri.parse(textValue);
+ }
+}
+
+/// Parameter or value that contains one or several URIs like `MEMBER`
+class UriListParameter extends Parameter> {
+ /// Retrieves the value as Uri
+ List get uris => value;
+
+ UriListParameter(ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.typeUriList, parse(textValue));
+
+ static List parse(final String textValue) {
+ final runes = textValue.runes.toList();
+ final result = [];
+ var isInQuote = false;
+ var isLastBackslash = false;
+ var lastQuoteStart = 0;
+ for (var i = 0; i < runes.length; i++) {
+ final rune = runes[i];
+ if (isLastBackslash) {
+ // ignore this char
+ isLastBackslash = false;
+ } else {
+ if (rune == Rune.runeDoubleQuote) {
+ if (isInQuote) {
+ // this is the URI end
+ final uriText = textValue.substring(lastQuoteStart, i);
+ final uri = Uri.parse(uriText);
+ result.add(uri);
+ lastQuoteStart = i + 1;
+ isInQuote = false;
+ } else {
+ isInQuote = true;
+ lastQuoteStart = i;
+ }
+ } else if (rune == Rune.runeComma && !isInQuote) {
+ if (lastQuoteStart < i - 2) {
+ final uriText = textValue.substring(lastQuoteStart, i);
+ final uri = Uri.parse(uriText);
+ result.add(uri);
+ }
+ lastQuoteStart = i + 1;
+ } else if (rune == Rune.runeBackslash) {
+ isLastBackslash = true;
+ }
+ }
+ }
+ return result;
+ }
+}
+
+/// Parameter containing text
+class TextParameter extends Parameter {
+ String get text => value;
+
+ TextParameter(ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.text, parse(textValue));
+
+ static String parse(String textValue) {
+ if (textValue.startsWith('"')) {
+ textValue = textValue.substring(1, textValue.length - 1);
+ }
+ return textValue;
+ }
+}
+
+/// Parameter containing boolean values
+class BooleanParameter extends Parameter {
+ bool get boolValue => value;
+
+ BooleanParameter(ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.text, parse(textValue));
+
+ static bool parse(String textValue) {
+ return textValue == 'TRUE';
+ }
+}
+
+/// Parameter containing a single user information
+class CalendarAddressParameter extends Parameter {
+ String get email => value;
+ CalendarAddressParameter(ParameterType type, String name, String textValue)
+ : super(
+ type, name, textValue, ValueType.calendarAddress, parse(textValue));
+
+ static String parse(String textValue) {
+ if (textValue.startsWith('"')) {
+ textValue = textValue.substring(1, textValue.length - 1);
+ }
+ if (textValue.startsWith('mailto:')) {
+ return textValue.substring('mailto:'.length);
+ }
+ if (textValue.contains('@')) {
+ return textValue;
+ }
+ throw FormatException('Invalid calendar user address: [$textValue]');
+ }
+}
+
+/// Parameter defining the type of calendar user
+class CalendarUserTypeParameter extends Parameter {
+ CalendarUserTypeParameter(ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.other, parse(textValue));
+
+ static CalendarUserType parse(String textValue) {
+ switch (textValue) {
+ case 'INDIVIDUAL':
+ return CalendarUserType.individual;
+ case 'GROUP':
+ return CalendarUserType.group;
+ case 'RESOURCE':
+ return CalendarUserType.resource;
+ case 'ROOM':
+ return CalendarUserType.room;
+ case 'UNKNOWN':
+ return CalendarUserType.unknown;
+ default:
+ return CalendarUserType.other;
+ }
+ }
+}
+
+/// Parameter defining the status of a free busy property
+class FreeBusyStatusParameter extends Parameter {
+ FreeBusyStatus get status => value;
+
+ FreeBusyStatusParameter(ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.typeFreeBusy, parse(textValue));
+
+ static FreeBusyStatus parse(String textValue) {
+ switch (textValue) {
+ case 'FREE':
+ return FreeBusyStatus.free;
+ case 'BUSY':
+ return FreeBusyStatus.busy;
+ case 'BUSY-UNAVAILABLE':
+ return FreeBusyStatus.busyUnavailable;
+ case 'BUSY-TENTATIVE':
+ return FreeBusyStatus.busyTentative;
+ default:
+ return FreeBusyStatus.other;
+ }
+ }
+}
+
+/// Parameter definining the participant status
+class ParticipantStatusParameter extends Parameter {
+ ParticipantStatus get status => value;
+
+ ParticipantStatusParameter(ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.typeParticipantStatus,
+ parse(textValue));
+
+ static ParticipantStatus parse(String textValue) {
+ switch (textValue) {
+ case 'NEEDS-ACTION':
+ return ParticipantStatus.needsAction;
+ case 'ACCEPTED':
+ return ParticipantStatus.accepted;
+ case 'DECLINED':
+ return ParticipantStatus.declined;
+ case 'TENTATIVE':
+ return ParticipantStatus.tentative;
+ case 'DELEGATED':
+ return ParticipantStatus.delegated;
+ case 'IN-PROCESS':
+ return ParticipantStatus.inProcess;
+ case 'COMPLETED':
+ return ParticipantStatus.completed;
+ default:
+ return ParticipantStatus.other;
+ }
+ }
+}
+
+/// Parameter defining the range of a change
+///
+/// This parameter can be specified on a property that
+/// specifies a recurrence identifier. The parameter specifies the
+/// effective range of recurrence instances that is specified by the
+/// property. The effective range is from the recurrence identifier
+/// specified by the property. If this parameter is not specified on
+/// an allowed property, then the default range is the single instance
+/// specified by the recurrence identifier value of the property. The
+/// parameter value can only be "THISANDFUTURE" to indicate a range
+/// defined by the recurrence identifier and all subsequent instances.
+/// The value "THISANDPRIOR" is deprecated by this revision of
+/// iCalendar and MUST NOT be generated by applications.
+class RangeParameter extends Parameter {
+ Range get range => value;
+ bool get isThisAndFuture => range == Range.thisAndFuture;
+
+ RangeParameter(ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.typeRange, parse(textValue));
+
+ static Range parse(String textValue) {
+ switch (textValue) {
+ case 'THISANDFUTURE':
+ return Range.thisAndFuture;
+ default:
+ throw FormatException('Invalid range: [$textValue]');
+ }
+ }
+}
+
+/// Specifies the relationship of the alarm trigger with respect to the start or end of the calendar component.
+///
+/// Example is the `RELATED` parameter.
+class AlarmTriggerRelationshipParameter
+ extends Parameter {
+ AlarmTriggerRelationship get relationship => value;
+
+ AlarmTriggerRelationshipParameter(
+ ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.typeAlarmTriggerRelationship,
+ parse(textValue));
+
+ static AlarmTriggerRelationship parse(String textValue) {
+ switch (textValue) {
+ case 'START':
+ return AlarmTriggerRelationship.start;
+ case 'END':
+ return AlarmTriggerRelationship.end;
+ }
+ throw FormatException('Invalid RELATED content [$textValue]');
+ }
+}
+
+/// Defines the relationship of the parameter's property
+class RelationshipParameter extends Parameter {
+ Relationship get relationship => value;
+
+ RelationshipParameter(ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.typeRelationship,
+ parse(textValue));
+
+ static Relationship parse(String textValue) {
+ switch (textValue) {
+ case 'PARENT':
+ return Relationship.parent;
+ case 'CHILD':
+ return Relationship.child;
+ case 'SIBLING':
+ return Relationship.sibling;
+ default:
+ return Relationship.other;
+ }
+ }
+}
+
+/// Defines the role of a given user
+class ParticipantRoleParameter extends Parameter {
+ Role get role => value;
+
+ ParticipantRoleParameter(ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.typeRole, parse(textValue));
+
+ static Role parse(String content) {
+ switch (content) {
+ case 'CHAIR':
+ return Role.chair;
+ case 'REQ-PARTICIPANT':
+ return Role.requiredParticipant;
+ case 'OPT-PARTICIPANT':
+ return Role.optionalParticipant;
+ case 'NON-PARTICIPANT':
+ return Role.nonParticpant;
+ default:
+ return Role.other;
+ }
+ }
+}
+
+/// Defines the value type of the corresponding property.
+///
+/// With this mechanism a single property can have different value types.
+class ValueParameter extends Parameter {
+ ValueType get valueType => value;
+
+ ValueParameter(ParameterType type, String name, String textValue)
+ : super(type, name, textValue, ValueType.typeValue, parse(textValue));
+
+ static ValueType parse(String content) {
+ switch (content) {
+ case 'BINARY':
+ return ValueType.binary;
+ case 'BOOLEAN':
+ return ValueType.boolean;
+ case 'CAL-ADDRESS':
+ return ValueType.calendarAddress;
+ case 'DATE':
+ return ValueType.date;
+ case 'DATE-TIME':
+ return ValueType.dateTime;
+ case 'DURATION':
+ return ValueType.duration;
+ case 'FLOAT':
+ return ValueType.float;
+ case 'INTEGER':
+ return ValueType.integer;
+ case 'PERIOD':
+ return ValueType.period;
+ case 'RECUR':
+ return ValueType.recurrence;
+ case 'TEXT':
+ return ValueType.text;
+ case 'TIME':
+ return ValueType.time;
+ case 'URI':
+ return ValueType.uri;
+ case 'UTC-OFFSET':
+ return ValueType.utcOffset;
+ default:
+ return ValueType.other;
+ }
+ }
+}
+
+/// Common parameter types
+enum ParameterType {
+ /// `ALTREP` Alternate text representation
+ alternateRepresentation,
+
+ /// `CN` common name
+ commonName,
+
+ /// `CUTYPE` calendar user type
+ calendarUserType,
+
+ /// `DELEGATED-FROM` delegator
+ delegateFrom,
+
+ /// `DELEGATED-TO` delgatee
+ delegateTo,
+
+ /// `DIR` directory
+ directory,
+
+ /// `ENCODING` inline encoding
+ encoding,
+
+ /// `FMTTYPE` format type / media type / mime type, e.g. `text/plain` or `image/png`
+ formatType,
+
+ /// `FBTTYPE` free busy time type
+ freeBusyTimeType,
+
+ /// `LANGUAGE` language
+ language,
+
+ /// `MEMBER` group or list membership
+ member,
+
+ /// `PARTSTAT` participant status
+ participantStatus,
+
+ /// `RANGE` recurrence identifier range
+ range,
+
+ /// `RELATED` alarm trigger relationship
+ alarmTriggerRelationship,
+
+ /// `RELTYPE` relationship type
+ relationshipType,
+
+ /// `ROLE` participant role
+ participantRole,
+
+ /// `RSVP` répondez s'il vous plaît - answer is asked for
+ rsvp,
+
+ /// `SENT-BY` sent by
+ sentBy,
+
+ /// `TZID` reference to time zone object
+ timezoneId,
+
+ /// `VALUE` property value data type, e.g. `BINARY`
+ value,
+
+ /// Any other parameter type
+ other,
+}
+
+extension ExtensionParameterType on ParameterType {
+ String? get name {
+ switch (this) {
+ case ParameterType.alternateRepresentation:
+ return 'ALTREP';
+ case ParameterType.commonName:
+ return 'CN';
+ case ParameterType.calendarUserType:
+ return 'CUTYPE';
+ case ParameterType.delegateFrom:
+ return 'DELEGATED-FROM';
+ case ParameterType.delegateTo:
+ return 'DELEGATED-TO';
+ case ParameterType.directory:
+ return 'DIR';
+ case ParameterType.encoding:
+ return 'ENCODING';
+ case ParameterType.formatType:
+ return 'FMTTYPE';
+ case ParameterType.freeBusyTimeType:
+ return 'FBTYPE';
+ case ParameterType.language:
+ return 'LANGUAGE';
+ case ParameterType.member:
+ return 'MEMBER';
+ case ParameterType.participantStatus:
+ return 'PARTSTAT';
+ case ParameterType.range:
+ return 'RANGE';
+ case ParameterType.alarmTriggerRelationship:
+ return 'RELATED';
+ case ParameterType.relationshipType:
+ return 'RELTYPE';
+ case ParameterType.participantRole:
+ return 'ROLE';
+ case ParameterType.rsvp:
+ return 'RSVP';
+ case ParameterType.sentBy:
+ return 'SENT-BY';
+ case ParameterType.timezoneId:
+ return 'TZID';
+ case ParameterType.value:
+ return 'VALUE';
+ case ParameterType.other:
+ return null;
+ }
+ }
+}
diff --git a/lib/src/properties.dart b/lib/src/properties.dart
new file mode 100644
index 0000000..b3cf856
--- /dev/null
+++ b/lib/src/properties.dart
@@ -0,0 +1,715 @@
+import 'parameters.dart';
+import 'types.dart';
+import 'util.dart';
+
+class Property {
+ Property(this.definition, ValueType defaultValueType)
+ : name = _getName(definition),
+ textValue = _getTextContent(definition),
+ parameters = _parseParameters(definition) {
+ value = _parsePropertyValue(this, defaultValueType);
+ }
+
+ /// The value of this property
+ late dynamic value;
+
+ /// Full property content, e.g. `DTSTAMP;TZID=America/New_York:19970610T172345Z`
+ final String definition;
+
+ /// Name of the property, e.g. `DTSTAMP`
+ final String name;
+
+ /// Value of the property, e.g. `19970610T172345Z`
+ final String textValue;
+
+ /// Additional parameters for this property, e.g. `{'TZID' : TextValueType('America/New_York')}}
+ final Map parameters;
+
+ dynamic parse(String textValue) {
+ throw FormatException(
+ 'Implement parse to allow custom value in property: $definition');
+ }
+
+ Parameter? operator [](ParameterType param) => parameters[param.name];
+
+ operator []=(String name, Parameter value) => parameters[name] = value;
+
+ T? getParameterValue(ParameterType param) =>
+ parameters[param.name]?.value as T?;
+
+ static String? _lastContent;
+ static List? _lastRunes;
+ static int _getNameEndIndex(String content) => _getIndex(content);
+ static int _getValueStartIndex(String content) =>
+ _getIndex(content, searchNameEndIndex: false);
+
+ static int _getIndex(String content, {bool searchNameEndIndex = true}) {
+ final runes =
+ content == _lastContent ? _lastRunes! : content.runes.toList();
+ var isInQuote = false;
+ var isLastBackSlash = false;
+ int? index;
+ for (int i = 0; i < runes.length; i++) {
+ final rune = runes[i];
+ if (isLastBackSlash) {
+ isLastBackSlash = false;
+ } else {
+ if (rune == Rune.runeDoubleQuote) {
+ isInQuote = !isInQuote;
+ } else {
+ if (rune == Rune.runeBackslash) {
+ isLastBackSlash = true;
+ } else if (!isInQuote) {
+ if (rune == Rune.runeColon) {
+ index = i;
+ break;
+ } else if (searchNameEndIndex && rune == Rune.runeSemicolon) {
+ index = i;
+ break;
+ }
+ }
+ }
+ }
+ }
+ if (index == null) {
+ throw FormatException('Invalid property: no colon : found in [$content]');
+ }
+ _lastContent = content;
+ _lastRunes = runes;
+ return index;
+ }
+
+ static String _getName(String content) {
+ final nameEndIndex = _getNameEndIndex(content);
+ return content.substring(0, nameEndIndex);
+ }
+
+ static String _getTextContent(String content) {
+ final valueStartIndex = _getValueStartIndex(content);
+ return content.substring(valueStartIndex + 1);
+ }
+
+ static int? _getNextParameterStartIndex(List runes, int startIndex) {
+ var isInQuote = false;
+ var isLastBackSlash = false;
+ for (int i = startIndex; i < runes.length; i++) {
+ final rune = runes[i];
+ if (isLastBackSlash) {
+ isLastBackSlash = false;
+ } else {
+ if (rune == Rune.runeDoubleQuote) {
+ isInQuote = !isInQuote;
+ } else {
+ if (rune == Rune.runeBackslash) {
+ isLastBackSlash = true;
+ } else if (!isInQuote) {
+ if (rune == Rune.runeColon) {
+ return i;
+ } else if (rune == Rune.runeSemicolon) {
+ return i;
+ }
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ static Map _parseParameters(String content) {
+ final result = {};
+ final nameEndIndex = _getNameEndIndex(content);
+ final valueStartIndex = _getValueStartIndex(content);
+ if (valueStartIndex > nameEndIndex + 1) {
+ final parametersText =
+ content.substring(nameEndIndex + 1, valueStartIndex);
+ final runes = parametersText.runes.toList();
+ var lastStartIndex = 0;
+ int? nextStartIndex;
+ while (true) {
+ nextStartIndex = _getNextParameterStartIndex(runes, lastStartIndex);
+ final parameterText = nextStartIndex == null
+ ? parametersText.substring(lastStartIndex)
+ : parametersText.substring(lastStartIndex, nextStartIndex);
+ try {
+ final parameter = Parameter.parse(parameterText);
+ result[parameter.name] = parameter;
+ } on FormatException catch (e, s) {
+ print(e.message);
+ print(s);
+ throw FormatException('${e.message} in property $content');
+ }
+ if (nextStartIndex == null) {
+ break;
+ }
+ lastStartIndex = nextStartIndex + 1;
+ }
+ }
+ return result;
+ }
+
+ static dynamic _parsePropertyValue(
+ Property property, ValueType defaultValueType) {
+ final valueType =
+ (property[ParameterType.value] as ValueParameter?)?.valueType ??
+ defaultValueType;
+ final textValue = property.textValue;
+ switch (valueType) {
+ case ValueType.binary:
+ return Binary(
+ value: textValue,
+ mediaType: property[ParameterType.formatType]?.textValue,
+ encoding: property[ParameterType.encoding]?.textValue,
+ );
+ case ValueType.boolean:
+ return BooleanParameter.parse(textValue);
+ case ValueType.calendarAddress:
+ return CalendarAddressParameter.parse(textValue);
+ case ValueType.date:
+ return DateParser.parseDate(textValue);
+ case ValueType.dateTime:
+ return DateParser.parseDateTime(textValue);
+ case ValueType.duration:
+ return IsoDuration.parse(textValue);
+ case ValueType.float:
+ return double.parse(textValue);
+ case ValueType.integer:
+ return int.parse(textValue);
+ case ValueType.period:
+ return Period.parse(textValue);
+ case ValueType.periodList:
+ return textValue.split(',').map((text) => Period.parse(text)).toList();
+ case ValueType.recurrence:
+ return Recurrence.parse(textValue);
+ case ValueType.text:
+ return textValue;
+ case ValueType.time:
+ return TimeOfDayWithSeconds.parse(textValue);
+ case ValueType.uri:
+ return UriParameter.parse(textValue);
+ case ValueType.utcOffset:
+ return UtcOffset(textValue);
+ case ValueType.typeUriList:
+ return UriListParameter.parse(textValue);
+ case ValueType.typeClassification:
+ return ClassificationParser.parse(textValue);
+ case ValueType.other:
+ return property.parse(textValue);
+ case ValueType.typeFreeBusy:
+ case ValueType.typeParticipantStatus:
+ case ValueType.typeRange:
+ case ValueType.typeAlarmTriggerRelationship:
+ case ValueType.typeRelationship:
+ case ValueType.typeRole:
+ case ValueType.typeValue:
+ throw FormatException(
+ 'Unable to parse ${property.name} with value $textValue and invalid valueType of $valueType');
+ case ValueType.typeDateTimeList:
+ return textValue
+ .split(',')
+ .map((text) => DateTimeOrDuration.parse(text, ValueType.dateTime))
+ .toList();
+ }
+ }
+
+ static Property parseProperty(String definition,
+ {Property? Function(String name, String definition)? customParser}) {
+ final name = _getName(definition);
+ switch (name) {
+ case TextProperty.propertyNameUid:
+ case TextProperty.propertyNameMethod:
+ case TextProperty.propertyNameProductIdentifier:
+ case TextProperty.propertyNameComment:
+ case TextProperty.propertyNameDescription:
+ case TextProperty.propertyNameSummary:
+ case TextProperty.propertyNameLocation:
+ case TextProperty.propertyNameResources:
+ case TextProperty.propertyNameTimezoneId:
+ case TextProperty.propertyNameTimezoneName:
+ case TextProperty.propertyNameRelatedTo:
+ case TextProperty.propertyNameXWrTimezone:
+ return TextProperty(definition);
+ case DateTimeProperty.propertyNameCompleted:
+ case DateTimeProperty.propertyNameDue:
+ case DateTimeProperty.propertyNameEnd:
+ case DateTimeProperty.propertyNameStart:
+ case DateTimeProperty.propertyNameTimeStamp:
+ case DateTimeProperty.propertyNameCreated:
+ case DateTimeProperty.propertyNameLastModified:
+ case DateTimeProperty.propertyNameRecurrenceId:
+ return DateTimeProperty(definition);
+ case IntegerProperty.propertyNamePercentComplete:
+ case IntegerProperty.propertyNameSequence:
+ case IntegerProperty.propertyNameRepeat:
+ return IntegerProperty(definition);
+ case CalendarScaleProperty.propertyName:
+ return CalendarScaleProperty(definition);
+ case UtfOffsetProperty.propertyNameTimezoneOffsetFrom:
+ case UtfOffsetProperty.propertyNameTimezoneOffsetTo:
+ return UtfOffsetProperty(definition);
+ case RecurrenceRuleProperty.propertyName:
+ return RecurrenceRuleProperty(definition);
+ case UriProperty.propertyNameTimezoneUrl:
+ case UriProperty.propertyNameUrl:
+ return UriProperty(definition);
+ case GeoProperty.propertyName:
+ return GeoProperty(definition);
+ case AttendeeProperty.propertyName:
+ return AttendeeProperty(definition);
+ case OrganizerProperty.propertyName:
+ return OrganizerProperty(definition);
+ case UserProperty.propertyNameContact:
+ return UserProperty(definition);
+ case VersionProperty.propertyName:
+ return VersionProperty(definition);
+ case AttachmentProperty.propertyName:
+ return AttachmentProperty(definition);
+ case CategoriesProperty.propertyName:
+ return CategoriesProperty(definition);
+ case ClassificationProperty.propertyName:
+ return ClassificationProperty(definition);
+ case PriorityProperty.propertyName:
+ return PriorityProperty(definition);
+ case StatusProperty.propertyName:
+ return StatusProperty(definition);
+ case DurationProperty.propertyName:
+ return DurationProperty(definition);
+ case TimeTransparencyProperty.propertyName:
+ return TimeTransparencyProperty(definition);
+ case FreeBusyProperty.propertyName:
+ return FreeBusyProperty(definition);
+ case ActionProperty.propertyName:
+ return ActionProperty(definition);
+ case TriggerProperty.propertyName:
+ return TriggerProperty(definition);
+ case RecurrenceDateProperty.propertyNameRDate:
+ case RecurrenceDateProperty.propertyNameExDate:
+ return RecurrenceDateProperty(definition);
+
+ default:
+ if (customParser != null) {
+ final prop = customParser(name, definition);
+ if (prop != null) {
+ return prop;
+ }
+ }
+ print('No property implementation found for $definition');
+ return Property(definition, ValueType.text);
+ }
+ }
+}
+
+class RecurrenceRuleProperty extends Property {
+ static const String propertyName = 'RRULE';
+ Recurrence get rule => value as Recurrence;
+
+ RecurrenceRuleProperty(String definition)
+ : super(definition, ValueType.other);
+
+ Recurrence parse(String texValue) {
+ return Recurrence.parse(textValue);
+ }
+}
+
+class UriProperty extends Property {
+ static const String propertyNameTimezoneUrl = 'TZURL';
+ static const String propertyNameUrl = 'URL';
+
+ Uri get uri => value as Uri;
+ UriProperty(String definition) : super(definition, ValueType.uri);
+}
+
+class UserProperty extends UriProperty {
+ static const String propertyNameContact = 'CONTACT';
+ UserProperty(String definition) : super(definition);
+
+ String? get commonName => getParameterValue(ParameterType.commonName);
+
+ Uri? get directory => getParameterValue(ParameterType.directory);
+
+ Uri? get alternateRepresentation =>
+ getParameterValue(ParameterType.alternateRepresentation);
+
+ CalendarUserType? get userType =>
+ getParameterValue(ParameterType.calendarUserType);
+
+ String? get email => uri.isScheme('MAILTO') ? uri.path : null;
+}
+
+class AttendeeProperty extends UserProperty {
+ static const String propertyName = 'ATTENDEE';
+ Uri get attendee => uri;
+
+ bool get rsvp => getParameterValue(ParameterType.rsvp) ?? false;
+
+ Role get role =>
+ getParameterValue(ParameterType.participantRole) ??
+ Role.requiredParticipant;
+
+ ParticipantStatus? get participantStatus =>
+ getParameterValue(ParameterType.participantStatus);
+
+ AttendeeProperty(String definition) : super(definition);
+}
+
+class OrganizerProperty extends UserProperty {
+ static const String propertyName = 'ORGANIZER';
+ Uri get organizer => uri;
+
+ Uri? get sentBy => getParameterValue(ParameterType.sentBy);
+
+ OrganizerProperty(String definition) : super(definition);
+}
+
+class GeoProperty extends Property {
+ static const String propertyName = 'GEO';
+
+ GeoLocation get location => value as GeoLocation;
+ GeoProperty(String definition) : super(definition, ValueType.other);
+
+ GeoProperty.value(GeoLocation location)
+ : this('GEO:${location.latitude};${location.longitude}');
+
+ GeoLocation parse(String content) {
+ final semicolonIndex = content.indexOf(';');
+ if (semicolonIndex == -1) {
+ throw FormatException('Invalid GEO property $content');
+ }
+ final latitudeText = content.substring(0, semicolonIndex);
+ final latitude = double.tryParse(latitudeText);
+ if (latitude == null) {
+ throw FormatException(
+ 'Invalid GEO property - unable to parse latitude value $latitudeText in $content');
+ }
+ final longitudeText = content.substring(semicolonIndex + 1);
+ final longitude = double.tryParse(longitudeText);
+ if (longitude == null) {
+ throw FormatException(
+ 'Invalid GEO property - unable to parse longitude value $longitudeText in $content');
+ }
+ return GeoLocation(latitude, longitude);
+ }
+}
+
+class AttachmentProperty extends Property {
+ static const String propertyName = 'ATTACH';
+
+ /// Retrieves the URI of the data such as `https://domain.com/assets/image.png`
+ Uri? get uri => value is Uri ? value : null;
+
+ /// Retrieves the binary data information
+ Binary? get binary => value is Binary ? value : null;
+
+ /// Retrieves the mime type / media type / format type like `image/png` as specified in the `FMTTYPE` parameter.
+ String? get mediaType => getParameterValue(ParameterType.formatType);
+
+ /// Retrieves the encoding such as `BASE64`, only relevant when the content is binary
+ ///
+ /// Compare [isBinary]
+ String? get encoding => getParameterValue(ParameterType.encoding);
+
+ /// Checks if this contains binary data
+ ///
+ /// Compare [binary]
+ bool get isBinary => value is Binary;
+
+ AttachmentProperty(String content) : super(content, ValueType.uri);
+}
+
+class CalendarScaleProperty extends Property {
+ static const String propertyName = 'CALSCALE';
+ bool get isGregorianCalendar => textValue == 'GREGORIAN';
+
+ CalendarScaleProperty(String definition) : super(definition, ValueType.text);
+}
+
+class VersionProperty extends Property {
+ static const String propertyName = 'VERSION';
+
+ bool get isVersion2 => textValue == '2.0';
+
+ VersionProperty(String definition) : super(definition, ValueType.text);
+}
+
+class CategoriesProperty extends Property {
+ static const String propertyName = 'CATEGORIES';
+
+ List get categories => textValue.split(',');
+
+ CategoriesProperty(String definition) : super(definition, ValueType.text);
+}
+
+class ClassificationProperty extends Property {
+ static const String propertyName = 'CLASS';
+
+ Classification get classification => value as Classification;
+
+ ClassificationProperty(String definition)
+ : super(definition, ValueType.typeClassification);
+}
+
+class TextProperty extends Property {
+ static const String propertyNameComment = 'COMMENT';
+ static const String propertyNameDescription = 'DESCRIPTION';
+ static const String propertyNameProductIdentifier = 'PRODID';
+ static const String propertyNameMethod = 'METHOD';
+ static const String propertyNameSummary = 'SUMMARY';
+ static const String propertyNameLocation = 'LOCATION';
+ static const String propertyNameResources = 'RESOURCES';
+ static const String propertyNameUid = 'UID';
+ static const String propertyNameXWrTimezone = 'X-WR-TIMEZONE';
+ static const String propertyNameTimezoneId = 'TZID';
+ static const String propertyNameTimezoneName = 'TZNAME';
+ static const String propertyNameRelatedTo = 'RELATED-TO';
+
+ String? get language => this[ParameterType.language]?.textValue;
+ Uri? get alternateRepresentation =>
+ (this[ParameterType.alternateRepresentation] as UriParameter?)?.uri;
+
+ String get text => value as String;
+
+ TextProperty(String definition) : super(definition, ValueType.text);
+}
+
+class IntegerProperty extends Property {
+ static const String propertyNamePercentComplete = 'PERCENT-COMPLETE';
+ static const String propertyNameSequence = 'SEQUENCE';
+ static const String propertyNameRepeat = 'REPEAT';
+
+ int get intValue => value as int;
+
+ IntegerProperty(String definition) : super(definition, ValueType.integer);
+}
+
+class DateTimeProperty extends Property {
+ static const String propertyNameCompleted = 'COMPLETED';
+ static const String propertyNameEnd = 'DTEND';
+ static const String propertyNameStart = 'DTSTART';
+ static const String propertyNameDue = 'DUE';
+ static const String propertyNameTimeStamp = 'DTSTAMP';
+ static const String propertyNameCreated = 'CREATED';
+ static const String propertyNameLastModified = 'LAST-MODIFIED';
+ static const String propertyNameRecurrenceId = 'RECURRENCE-ID';
+
+ DateTime get dateTime => value as DateTime;
+
+ /// Retrieves the timezone ID like `America/New_York` or `Europe/Berlin` from the `TZID` parameter.
+ String? get timezoneId => this[ParameterType.timezoneId]?.textValue;
+
+ DateTimeProperty(String definition) : super(definition, ValueType.dateTime);
+}
+
+class DurationProperty extends Property {
+ static const String propertyName = 'DURATION';
+
+ IsoDuration get duration => value as IsoDuration;
+
+ DurationProperty(String definition) : super(definition, ValueType.duration);
+}
+
+class PeriodProperty extends Property {
+ //static const String propertyNameFreeBusy = 'FREEBUSY';
+
+ Period get period => value as Period;
+
+ PeriodProperty(String definition) : super(definition, ValueType.period);
+}
+
+class UtfOffsetProperty extends Property {
+ static const String propertyNameTimezoneOffsetFrom = 'TZOFFSETFROM';
+ static const String propertyNameTimezoneOffsetTo = 'TZOFFSETTO';
+
+ UtcOffset get offset => value as UtcOffset;
+
+ UtfOffsetProperty(String definition) : super(definition, ValueType.utcOffset);
+}
+
+class FreeBusyProperty extends Property {
+ static const String propertyName = 'FREEBUSY';
+
+ FreeBusyStatus get freeBusyType =>
+ getParameterValue(ParameterType.freeBusyTimeType) ??
+ FreeBusyStatus.busy;
+
+ List get periods => value as List;
+
+ FreeBusyProperty(String definition) : super(definition, ValueType.periodList);
+}
+
+enum Priority { high, medium, low, undefined }
+
+extension ExtensionPriority on Priority {
+ int toInt() {
+ switch (this) {
+ case Priority.high:
+ return 1;
+ case Priority.medium:
+ return 5;
+ case Priority.low:
+ return 9;
+ case Priority.undefined:
+ return 0;
+ }
+ }
+}
+
+class PriorityProperty extends IntegerProperty {
+ static const String propertyName = 'PRIORITY';
+
+ Priority get priority {
+ final number = intValue;
+ if (number == 0) {
+ return Priority.undefined;
+ } else if (number < 5) {
+ return Priority.high;
+ } else if (number == 5) {
+ return Priority.medium;
+ } else if (number < 10) {
+ return Priority.low;
+ }
+ return Priority.undefined;
+ }
+
+ PriorityProperty(String definition) : super(definition);
+}
+
+enum EventStatus { tentative, confirmed, cancelled, unknown }
+
+enum TodoStatus { needsAction, completed, inProcess, cancelled, unknown }
+
+enum JournalStatus { draft, finalized, cancelled, unknown }
+
+class StatusProperty extends TextProperty {
+ static const String propertyName = 'STATUS';
+
+ EventStatus get eventStatus {
+ final text = textValue;
+ switch (text) {
+ case 'TENTATIVE':
+ return EventStatus.tentative;
+ case 'CONFIRMED':
+ return EventStatus.confirmed;
+ case 'CANCELLED':
+ return EventStatus.cancelled;
+ default:
+ return EventStatus.unknown;
+ }
+ }
+
+ TodoStatus get todoStatus {
+ final text = textValue;
+ switch (text) {
+ case 'NEEDS-ACTION':
+ return TodoStatus.needsAction;
+ case 'IN-PROCESS':
+ return TodoStatus.inProcess;
+ case 'COMPLETED':
+ return TodoStatus.completed;
+ case 'CANCELLED':
+ return TodoStatus.cancelled;
+ default:
+ return TodoStatus.unknown;
+ }
+ }
+
+ JournalStatus get journalStatus {
+ final text = textValue;
+ switch (text) {
+ case 'DRAFT':
+ return JournalStatus.draft;
+ case 'FINAL':
+ return JournalStatus.finalized;
+ case 'CANCELLED':
+ return JournalStatus.cancelled;
+ default:
+ return JournalStatus.unknown;
+ }
+ }
+
+ StatusProperty(String definition) : super(definition);
+}
+
+/// Transparency for busy time searches
+enum TimeTransparency {
+ /// The associated event's timeslot is visible in busy time searches
+ opaque,
+
+ /// The associated event's timeslot is hiddem from busy time searches
+ transparent,
+}
+
+/// This property defines whether or not an event is transparent to busy time searches.
+class TimeTransparencyProperty extends TextProperty {
+ static const String propertyName = 'TRANSP';
+
+ /// Retrieves the transparency
+ TimeTransparency get transparency {
+ final text = textValue;
+ switch (text) {
+ case 'OPAQUE':
+ return TimeTransparency.opaque;
+ case 'TRANSPARENT':
+ return TimeTransparency.transparent;
+ default:
+ return TimeTransparency.opaque;
+ }
+ }
+
+ TimeTransparencyProperty(String definition) : super(definition);
+}
+
+class RecurrenceDateProperty extends Property {
+ static const String propertyNameRDate = 'RDATE';
+ static const String propertyNameExDate = 'EXDATE';
+
+ List get dates => value as List;
+
+ RecurrenceDateProperty(String definition)
+ : super(definition, ValueType.typeDateTimeList);
+}
+
+class TriggerProperty extends Property {
+ static const String propertyName = 'TRIGGER';
+
+ IsoDuration? get duration => value is IsoDuration ? value : null;
+ DateTime? get dateTime => value is DateTime ? value : null;
+
+ TriggerProperty(String definition) : super(definition, ValueType.duration);
+}
+
+class ActionProperty extends TextProperty {
+ static const String propertyName = 'ACTION';
+
+ AlarmAction? _action;
+ AlarmAction get action {
+ var act = _action;
+ if (act == null) {
+ switch (textValue) {
+ case 'AUDIO':
+ act = AlarmAction.audio;
+ break;
+ case 'DISPLAY':
+ act = AlarmAction.display;
+ break;
+ case 'EMAIL':
+ act = AlarmAction.email;
+ break;
+ default:
+ act = AlarmAction.other;
+ break;
+ }
+ _action = act;
+ }
+ return act;
+ }
+
+ ActionProperty(String definition) : super(definition);
+}
+
+class RequestStatusProperty extends TextProperty {
+ static const String propertyName = 'REQUEST-STATUS';
+
+ //TODO consider extracting status code from text, compare https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.4.5
+ String get requestStatus => text;
+
+ RequestStatusProperty(String definition) : super(definition);
+}
diff --git a/lib/src/types.dart b/lib/src/types.dart
new file mode 100644
index 0000000..e6b1e57
--- /dev/null
+++ b/lib/src/types.dart
@@ -0,0 +1,1216 @@
+/// To explicitly specify the value type format for a property value.
+enum ValueType {
+ binary,
+ boolean,
+ calendarAddress,
+ date,
+ dateTime,
+ duration,
+ float,
+ integer,
+ period,
+ periodList,
+ recurrence,
+ text,
+ time,
+ uri,
+ utcOffset,
+ other,
+
+ typeClassification,
+ typeUriList,
+ typeDateTimeList,
+ typeFreeBusy,
+ typeParticipantStatus,
+ typeRange,
+ typeAlarmTriggerRelationship,
+ typeRelationship,
+ typeRole,
+ typeValue,
+}
+
+extension ExtensionValueType on ValueType {
+ String? get name {
+ switch (this) {
+ case ValueType.binary:
+ return 'BINARY';
+ case ValueType.boolean:
+ return 'BOOLEAN';
+ case ValueType.calendarAddress:
+ return 'CAL-ADDRESS';
+ case ValueType.date:
+ return 'DATE';
+ case ValueType.dateTime:
+ return 'DATE-TIME';
+ case ValueType.duration:
+ return 'DURATION';
+ case ValueType.float:
+ return 'FLOAT';
+ case ValueType.integer:
+ return 'INTEGER';
+ case ValueType.period:
+ return 'PERIOD';
+ case ValueType.recurrence:
+ return 'RECUR';
+ case ValueType.text:
+ return 'TEXT';
+ case ValueType.time:
+ return 'TIME';
+ case ValueType.uri:
+ return 'URI';
+ case ValueType.utcOffset:
+ return 'UTC-OFFSET';
+ case ValueType.other:
+ case ValueType.typeFreeBusy:
+ case ValueType.periodList:
+ case ValueType.typeDateTimeList:
+ case ValueType.typeParticipantStatus:
+ case ValueType.typeRange:
+ case ValueType.typeAlarmTriggerRelationship:
+ case ValueType.typeRelationship:
+ case ValueType.typeRole:
+ case ValueType.typeUriList:
+ case ValueType.typeValue:
+ case ValueType.typeClassification:
+ return null;
+ }
+ }
+}
+
+enum Classification { public, private, confidential, other }
+
+extension ExtensionClassificationValue on Classification {
+ String? get name {
+ switch (this) {
+ case Classification.public:
+ return 'PUBLIC';
+ case Classification.private:
+ return 'PRIVATE';
+ case Classification.confidential:
+ return 'CONFIDENTIAL';
+ case Classification.other:
+ return null;
+ }
+ }
+}
+
+/// `FREQ` part of a recurrence role
+///
+/// Compare [Recurrence]
+enum RecurrenceFrequency {
+ secondly,
+ minutely,
+ hourly,
+ daily,
+ weekly,
+ monthly,
+ yearly,
+}
+
+extension ExtensionRecurrenceFrequency on RecurrenceFrequency {
+ String get name {
+ switch (this) {
+ case RecurrenceFrequency.secondly:
+ return 'SECONDLY';
+ case RecurrenceFrequency.minutely:
+ return 'MINUTELY';
+ case RecurrenceFrequency.hourly:
+ return 'HOURLY';
+ case RecurrenceFrequency.daily:
+ return 'DAILY';
+ case RecurrenceFrequency.weekly:
+ return 'WEEKLY';
+ case RecurrenceFrequency.monthly:
+ return 'MONTHLY';
+ case RecurrenceFrequency.yearly:
+ return 'YEARLY';
+ }
+ }
+}
+
+/// This value type is used to identify properties that contain a recurrence rule specification.
+class Recurrence {
+ /// The `FREQ` rule part identifies the type of recurrence rule.
+ ///
+ /// This rule part MUST be specified in the recurrence rule. Valid values
+ /// include SECONDLY, to specify repeating events based on an interval
+ /// of a second or more; MINUTELY, to specify repeating events based
+ /// on an interval of a minute or more; HOURLY, to specify repeating
+ /// events based on an interval of an hour or more; DAILY, to specify
+ /// repeating events based on an interval of a day or more; WEEKLY, to
+ /// specify repeating events based on an interval of a week or more;
+ /// MONTHLY, to specify repeating events based on an interval of a
+ /// month or more; and YEARLY, to specify repeating events based on an
+ /// interval of a year or more.
+ final RecurrenceFrequency frequency;
+
+ /// The `UNTIL` rule part defines a DATE or DATE-TIME value that bounds the recurrence rule in an inclusive manner.
+ ///
+ /// If the value
+ /// specified by UNTIL is synchronized with the specified recurrence,
+ /// this DATE or DATE-TIME becomes the last instance of the
+ /// recurrence. The value of the UNTIL rule part MUST have the same
+ /// value type as the "DTSTART" property. Furthermore, if the
+ /// "DTSTART" property is specified as a date with local time, then
+ /// the UNTIL rule part MUST also be specified as a date with local
+ /// time. If the "DTSTART" property is specified as a date with UTC
+ /// time or a date with local time and time zone reference, then the
+ /// UNTIL rule part MUST be specified as a date with UTC time. In the
+ /// case of the "STANDARD" and "DAYLIGHT" sub-components the UNTIL
+ /// rule part MUST always be specified as a date with UTC time. If
+ /// specified as a DATE-TIME value, then it MUST be specified in a UTC
+ /// time format. If not present, and the COUNT rule part is also not
+ /// present, the "RRULE" is considered to repeat forever.
+ final DateTime? until;
+
+ /// The `COUNT` rule part defines the number of occurrences at which to range-bound the recurrence.
+ ///
+ /// The "DTSTART" property value always counts as the first occurrence.
+ final int? count;
+
+ /// The `INTERVAL` rule part contains a positive integer representing at which intervals the recurrence rule repeats.
+ ///
+ /// The default value is
+ /// "1", meaning every second for a SECONDLY rule, every minute for a
+ /// MINUTELY rule, every hour for an HOURLY rule, every day for a
+ /// DAILY rule, every week for a WEEKLY rule, every month for a
+ /// MONTHLY rule, and every year for a YEARLY rule. For example,
+ /// within a DAILY rule, a value of "8" means every eight days.
+ final int interval;
+
+ /// Seconds modifier / limiter for this Recurrence.
+ ///
+ /// BYxxx rule parts modify the recurrence in some manner.
+ /// BYxxx rule parts for a period of time which is the same or greater than the frequency generally reduce or limit the number
+ /// of occurrences of the recurrence generated. For example, "FREQ=DAILY;BYMONTH=1" reduces the number of recurrence instances
+ /// from all days (if BYMONTH tag is not present) to all days in January. BYxxx rule parts for a period of time less than the
+ /// frequency generally increase or expand the number of occurrences of the recurrence. For example, "FREQ=YEARLY;BYMONTH=1,2"
+ /// increases the number of days within the yearly recurrence set from 1 (if BYMONTH tag is not present) to 2.
+ ///
+ /// ```
+ /// +----------+--------+--------+-------+-------+------+-------+------+
+ /// | |SECONDLY|MINUTELY|HOURLY |DAILY |WEEKLY|MONTHLY|YEARLY|
+ /// +----------+--------+--------+-------+-------+------+-------+------+
+ /// |BYMONTH |Limit |Limit |Limit |Limit |Limit |Limit |Expand|
+ /// +----------+--------+--------+-------+-------+------+-------+------+
+ /// |BYWEEKNO |N/A |N/A |N/A |N/A |N/A |N/A |Expand|
+ /// +----------+--------+--------+-------+-------+------+-------+------+
+ /// |BYYEARDAY |Limit |Limit |Limit |N/A |N/A |N/A |Expand|
+ /// +----------+--------+--------+-------+-------+------+-------+------+
+ /// |BYMONTHDAY|Limit |Limit |Limit |Limit |N/A |Expand |Expand|
+ /// +----------+--------+--------+-------+-------+------+-------+------+
+ /// |BYDAY |Limit |Limit |Limit |Limit |Expand|Note 1 |Note 2|
+ /// +----------+--------+--------+-------+-------+------+-------+------+
+ /// |BYHOUR |Limit |Limit |Limit |Expand |Expand|Expand |Expand|
+ /// +----------+--------+--------+-------+-------+------+-------+------+
+ /// |BYMINUTE |Limit |Limit |Expand |Expand |Expand|Expand |Expand|
+ /// +----------+--------+--------+-------+-------+------+-------+------+
+ /// |BYSECOND |Limit |Expand |Expand |Expand |Expand|Expand |Expand|
+ /// +----------+--------+--------+-------+-------+------+-------+------+
+ /// |BYSETPOS |Limit |Limit |Limit |Limit |Limit |Limit |Limit |
+ /// +----------+--------+--------+-------+-------+------+-------+------+
+ /// ```
+ ///
+ /// Note 1: Limit if BYMONTHDAY is present; otherwise, special expand
+ /// for MONTHLY.
+ ///
+ /// Note 2: Limit if BYYEARDAY or BYMONTHDAY is present; otherwise,
+ /// special expand for WEEKLY if BYWEEKNO present; otherwise,
+ /// special expand for MONTHLY if BYMONTH present; otherwise,
+ /// special expand for YEARLY.
+ ///
+ /// Compare [byMinute]
+ final List? bySecond;
+
+ /// `BYMINUTE` modifier / limiter for this Recurrence.
+ ///
+ /// Compare [bySecond] for details
+ final List? byMinute;
+
+ /// `BYHOUR` modifier / limiter for this Recurrence.
+ ///
+ /// Compare [bySecond] for details
+ final List? byHour;
+
+ /// `BYDAY` modifier / limiter for this Recurrence. 1 = Monday / DateTime.monday, 7 = Sunday / DateTime.sunday
+ ///
+ /// Compare [bySecond] for details
+ final List? byWeekDay;
+
+ /// `BYMONTHDAY` modifier / limiter for this Recurrence.
+ final List? byMonthDay;
+
+ /// `BYYEARDAY` modifier / limiter for this Recurrence.
+ final List? byYearDay;
+
+ /// `BYWEEKNO` modifier / limiter for this Recurrence.
+ ///
+ /// Compare [bySecond] for details
+ final List? byWeek;
+
+ /// `BYMONTH` modifier / limiter for this Recurrence.
+ ///
+ /// Compare [bySecond] for details
+ final List? byMonth;
+
+ /// BYSETPOS modifier / limiter for this Recurrence.
+ ///
+ /// The BYSETPOS rule part specifies a COMMA-separated list of values
+ /// that corresponds to the nth occurrence within the set of
+ /// recurrence instances specified by the rule. BYSETPOS operates on
+ /// a set of recurrence instances in one interval of the recurrence
+ /// rule. For example, in a WEEKLY rule, the interval would be one
+ /// week A set of recurrence instances starts at the beginning of the
+ /// interval defined by the FREQ rule part. Valid values are 1 to 366
+ /// or -366 to -1. It MUST only be used in conjunction with another
+ /// BYxxx rule part. For example "the last work day of the month"
+ /// could be represented as:
+ ///
+ /// `FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1`
+ final List? bySetPos;
+
+ /// The `WKST` rule part specifies the day on which the workweek starts.
+ ///
+ /// Valid values are MO, TU, WE, TH, FR, SA, and SU. This is
+ /// significant when a WEEKLY "RRULE" has an interval greater than 1,
+ /// and a BYDAY rule part is specified. This is also significant when
+ /// in a YEARLY "RRULE" when a BYWEEKNO rule part is specified. The
+ /// default value is MO.
+ final int startOfWorkWeek;
+
+ Recurrence(
+ this.frequency, {
+ this.until,
+ this.count,
+ this.interval = 1,
+ this.bySecond,
+ this.byMinute,
+ this.byHour,
+ this.byWeekDay,
+ this.byYearDay,
+ this.byWeek,
+ this.byMonth,
+ this.byMonthDay,
+ this.startOfWorkWeek = DateTime.monday,
+ this.bySetPos,
+ });
+
+ static String? _lastContent;
+ static Map? _lastResult;
+ static Map _split(String content) {
+ if (content == _lastContent) {
+ return _lastResult!;
+ }
+ final result = {};
+ final pairs = content.split(';');
+ for (final pair in pairs) {
+ final index = pair.indexOf('=');
+ if (index != -1) {
+ result[pair.substring(0, index)] = pair.substring(index + 1);
+ } else {
+ result[pair] = '';
+ }
+ }
+ _lastResult = result;
+ _lastContent = content;
+ return result;
+ }
+
+ static RecurrenceFrequency _parseFrequency(String content) {
+ final freq = _split(content)['FREQ'];
+ if (freq == null) {
+ throw FormatException('No FREQ found in RECUR $content');
+ }
+ switch (freq) {
+ case 'SECONDLY':
+ return RecurrenceFrequency.secondly;
+ case 'MINUTELY':
+ return RecurrenceFrequency.minutely;
+ case 'HOURLY':
+ return RecurrenceFrequency.hourly;
+ case 'DAILY':
+ return RecurrenceFrequency.daily;
+ case 'WEEKLY':
+ return RecurrenceFrequency.weekly;
+ case 'MONTHLY':
+ return RecurrenceFrequency.monthly;
+ case 'YEARLY':
+ return RecurrenceFrequency.yearly;
+ }
+ throw FormatException('Invalid FREQ value: $freq in RECUR $content');
+ }
+
+ static DateTime? _parseUntil(String content) {
+ final until = _split(content)['UNTIL'];
+ if (until == null) {
+ return null;
+ }
+ if (until.contains('T')) {
+ return DateParser.parseDateTime(until);
+ }
+ return DateParser.parseDate(until);
+ }
+
+ static int? _parseIntValue(String content, String fieldName) {
+ final text = _split(content)[fieldName];
+ if (text == null) {
+ return null;
+ }
+ final value = int.tryParse(text);
+ if (value == null) {
+ throw FormatException('Invalid $fieldName $text in RECUR $content');
+ }
+ return value;
+ }
+
+ static int? _parseCount(String content) => _parseIntValue(content, 'COUNT');
+
+ static int? _parseInterval(String content) =>
+ _parseIntValue(content, 'INTERVAL');
+
+ static List? _parseStringList(String content, String fieldName) {
+ final listText = _split(content)[fieldName];
+ if (listText == null) {
+ return null;
+ }
+ return listText.split(',');
+ }
+
+ static List? _parseIntList(
+ String content, String fieldName, int allowedMin, int allowedMax,
+ [int? disallowedValue]) {
+ final texts = _parseStringList(content, fieldName);
+ if (texts == null) {
+ return null;
+ }
+ final result = [];
+ for (final text in texts) {
+ final value = int.tryParse(text);
+ if (value == null ||
+ value < allowedMin ||
+ value > allowedMax ||
+ value == disallowedValue) {
+ throw FormatException(
+ 'Invalid $fieldName: part $text invalid in RECUR $content');
+ }
+ result.add(value);
+ }
+ if (result.isEmpty) {
+ throw FormatException('Invalid $fieldName: empty in RECUR $content');
+ }
+ return result;
+ }
+
+ static List? _parseBySecond(String content) =>
+ _parseIntList(content, 'BYSECOND', 0, 60);
+
+ static List? _parseByMinute(String content) =>
+ _parseIntList(content, 'BYMINUTE', 0, 59);
+ static List? _parseByHour(String content) =>
+ _parseIntList(content, 'BYHOUR', 0, 23);
+
+ static final _weekdaysByName = {
+ 'MO': DateTime.monday,
+ 'TU': DateTime.tuesday,
+ 'WE': DateTime.wednesday,
+ 'TH': DateTime.thursday,
+ 'FR': DateTime.friday,
+ 'SA': DateTime.saturday,
+ 'SU': DateTime.sunday,
+ };
+
+ static List? _parseByWeekDay(String content) {
+ final texts = _parseStringList(content, 'BYDAY');
+ if (texts == null) {
+ return null;
+ }
+ final result = [];
+ for (final text in texts) {
+ final weekday = _weekdaysByName[text];
+ if (weekday != null) {
+ result.add(ByDayRule(weekday));
+ } else {
+ // this is a more complex value with a week definition at the beginning:
+ // Definition:
+ // weekdaynum = [[plus / minus] ordwk] weekday
+ // plus = "+"
+ // minus = "-"
+ // ordwk = 1*2DIGIT ;1 to 53
+ // weekday = "SU" / "MO" / "TU" / "WE" / "TH" / "FR" / "SA"
+ final weekText = text.substring(0, text.length - 2);
+ final week = int.tryParse(weekText);
+ if (week == null || week == 0 || week > 53 || week < -53) {
+ throw FormatException(
+ 'Invalid week $weekText in BYDAY rule part $text in RECUR $content');
+ }
+ final dayText = text.substring(text.length - 2);
+ final day = _weekdaysByName[dayText];
+ if (day == null) {
+ throw FormatException(
+ 'Invalid weekday $dayText in BYDAY rule part $text in RECUR $content');
+ }
+ result.add(ByDayRule(day, week: week));
+ }
+ }
+ return result;
+ }
+
+ static List? _parseByMonthDay(String content) =>
+ _parseIntList(content, 'BYMONTHDAY', -31, 31, 0);
+
+ static List? _parseByYearDay(String content) =>
+ _parseIntList(content, 'BYYEARDAY', -366, 366, 0);
+
+ static List? _parseByWeek(String content) =>
+ _parseIntList(content, 'BYWEEKNO', -53, 53, 0);
+
+ static List? _parseByMonth(String content) =>
+ _parseIntList(content, 'BYMONTH', 1, 12);
+
+ static List? _parseBySetPos(String content) =>
+ _parseIntList(content, 'BYSETPOS', -366, 366, 0);
+
+ static int? _parseWorkWeekStart(String content) {
+ final startOfWorkWeekText = _split(content)['WKST'];
+ if (startOfWorkWeekText == null) {
+ return null;
+ }
+ final weekday = _weekdaysByName[startOfWorkWeekText];
+ if (weekday == null) {
+ throw FormatException(
+ 'Invalid weekday $startOfWorkWeekText in WKST part of RECUR $content');
+ }
+ return weekday;
+ }
+
+ static Recurrence parse(String content) {
+ final frequency = _parseFrequency(content);
+ final until = _parseUntil(content);
+ final count = _parseCount(content);
+ final interval = _parseInterval(content) ?? 1;
+ final bySecond = _parseBySecond(content);
+ final byMinute = _parseByMinute(content);
+ final byHour = _parseByHour(content);
+ final byWeekDay = _parseByWeekDay(content);
+ final byMonthDay = _parseByMonthDay(content);
+ final byYearDay = _parseByYearDay(content);
+ final byWeek = _parseByWeek(content);
+ final byMonth = _parseByMonth(content);
+ final bySetPos = _parseBySetPos(content);
+ final startOfWorkWeek = _parseWorkWeekStart(content) ?? DateTime.monday;
+ return Recurrence(frequency,
+ until: until,
+ count: count,
+ interval: interval,
+ bySecond: bySecond,
+ byMinute: byMinute,
+ byHour: byHour,
+ byWeekDay: byWeekDay,
+ byMonthDay: byMonthDay,
+ byYearDay: byYearDay,
+ byWeek: byWeek,
+ byMonth: byMonth,
+ bySetPos: bySetPos,
+ startOfWorkWeek: startOfWorkWeek);
+ }
+}
+
+/// Contains BYDAY weekday rules
+class ByDayRule {
+ /// Weekday 1 = Monday / DateTime.monday, 7 = Sunday / DateTime.sunday
+ final int weekday;
+
+ /// The week, e.g. 1 for first week, 2 for the second week, -1 for the last week, -2 for the second last week, etc
+ ///
+ /// This value is relative to the DTSTART / DateTimeStart property
+ final int? week;
+
+ ByDayRule(this.weekday, {this.week});
+
+ @override
+ int get hashCode => weekday + (week ?? 0) * 10;
+
+ @override
+ bool operator ==(Object other) {
+ return other is ByDayRule && other.weekday == weekday && other.week == week;
+ }
+
+ @override
+ String toString() {
+ final buffer = StringBuffer();
+ switch (weekday) {
+ case DateTime.monday:
+ buffer.write('Monday');
+ break;
+ case DateTime.tuesday:
+ buffer.write('Tuesday');
+ break;
+ case DateTime.wednesday:
+ buffer.write('Wednesday');
+ break;
+ case DateTime.thursday:
+ buffer.write('Thursday');
+ break;
+ case DateTime.friday:
+ buffer.write('Friday');
+ break;
+ case DateTime.saturday:
+ buffer.write('Saturday');
+ break;
+ case DateTime.sunday:
+ buffer.write('Sunday');
+ break;
+ default:
+ buffer..write('Invalid day ')..write(weekday);
+ break;
+ }
+ if (week != null) {
+ buffer..write(' in week ')..write(week);
+ }
+ return buffer.toString();
+ }
+}
+
+class TimeOfDayWithSeconds {
+ final int hour;
+ final int minute;
+ final int second;
+
+ TimeOfDayWithSeconds(
+ {required this.hour, required this.minute, required this.second});
+
+ static TimeOfDayWithSeconds parse(String content) {
+ final hour = int.tryParse(content.substring(0, 2));
+ final minute = int.tryParse(content.substring(2, 4));
+ final second = int.tryParse(content.substring(4, 6));
+ if (hour == null || minute == null || second == null) {
+ throw FormatException('Invalid time definition: $content');
+ }
+ return TimeOfDayWithSeconds(hour: hour, minute: minute, second: second);
+ }
+}
+
+/// This value type is used to identify properties that contain an offset from UTC to local time.
+class UtcOffset {
+ final int offsetHour;
+ final int offsetMinute;
+
+ UtcOffset(String content)
+ : offsetHour = _parseHour(content),
+ offsetMinute = _parseMinute(content);
+
+ UtcOffset.value({required this.offsetHour, required this.offsetMinute});
+
+ @override
+ String toString() {
+ final buffer = StringBuffer();
+ if (offsetHour < 0) {
+ buffer.write('-');
+ }
+ final hour = offsetHour.abs();
+ if (hour < 10) {
+ buffer.write('0');
+ }
+ buffer.write(hour);
+ if (offsetMinute < 10) {
+ buffer.write('0');
+ }
+ buffer.write(offsetMinute);
+ return buffer.toString();
+ }
+
+ @override
+ int get hashCode => offsetHour + (offsetMinute * 60);
+
+ @override
+ bool operator ==(Object other) {
+ return other is UtcOffset &&
+ other.offsetHour == offsetHour &&
+ other.offsetMinute == offsetMinute;
+ }
+
+ static int _parseHour(String content) {
+ if (content.length < 5) {
+ throw FormatException('Invalid UTC-OFFSET $content');
+ }
+ final hourText = content.substring(0, 3);
+ final hour = int.tryParse(hourText);
+ if (hour == null) {
+ throw FormatException('Invalid UTC-OFFSET $content');
+ }
+ return hour;
+ }
+
+ static int _parseMinute(String content) {
+ if (content.length < 5) {
+ throw FormatException('Invalid UTC-OFFSET $content');
+ }
+ final minuteText =
+ content.length > 5 ? content.substring(3, 5) : content.substring(3);
+ final minute = int.tryParse(minuteText);
+ if (minute == null) {
+ throw FormatException('Invalid UTC-OFFSET $content');
+ }
+ return minute;
+ }
+}
+
+class DateParser {
+ DateParser._();
+
+ static DateTime parseDate(String content) {
+ if (content.length != 4 + 2 + 2) {
+ throw FormatException('Invalid date definition: $content');
+ }
+ final year = int.tryParse(content.substring(0, 4));
+ final month = int.tryParse(content.substring(4, 6));
+ final day = int.tryParse(content.substring(6));
+ if (year == null || month == null || day == null) {
+ throw FormatException('Invalid date definition: $content');
+ }
+ return DateTime(year, month, day);
+ }
+
+ static DateTime parseDateTime(String content) {
+ final tIndex = content.indexOf('T');
+ if (content.length < 4 + 2 + 2 + 1 + 6 || tIndex != 4 + 2 + 2) {
+ throw FormatException('Invalid datetime definition: $content');
+ }
+ final date = DateParser.parseDate(content.substring(0, 4 + 2 + 2));
+ final time = TimeOfDayWithSeconds.parse(content.substring(tIndex + 1));
+ return DateTime(
+ date.year, date.month, date.day, time.hour, time.minute, time.second);
+ }
+}
+
+class DateTimeOrDuration {
+ final DateTime? dateTime;
+ final IsoDuration? duration;
+
+ DateTimeOrDuration(this.dateTime, this.duration);
+
+ static DateTimeOrDuration parse(String textValue, ValueType type) {
+ DateTime? dateTime;
+ IsoDuration? duration;
+ if (type == ValueType.dateTime) {
+ dateTime = DateParser.parseDateTime(textValue);
+ } else if (type == ValueType.date) {
+ dateTime = DateParser.parseDate(textValue);
+ } else if (type == ValueType.duration) {
+ duration = IsoDuration.parse(textValue);
+ } else {
+ throw FormatException(
+ 'Unsupported type for DateTimeOrDuration: $type with text [$textValue].');
+ }
+ return DateTimeOrDuration(dateTime, duration);
+ }
+}
+
+/// Contains a precise period of time.
+class Period {
+ /// The startdate
+ final DateTime startDate;
+
+ /// The duration
+ ///
+ /// Either the [duration] or the [enddate] will be defined.
+ final IsoDuration? duration;
+
+ /// The end date
+ ///
+ /// Either the [duration] or the [enddate] will be defined.
+ final DateTime? endDate;
+
+ Period(this.startDate, {this.duration, this.endDate})
+ : assert(duration != null || endDate != null,
+ 'Either duration or endDate must be set.'),
+ assert(!(duration != null && endDate != null),
+ 'Not both duration and endDate can be set at the same time.');
+ Period.text(String content)
+ : startDate = _parseStartDate(content),
+ endDate = _parseEndDate(content),
+ duration = _parseDuration(content);
+
+ static int _getSeparatorIndex(String content) {
+ final separatorIndex = content.indexOf('/');
+ if (separatorIndex == -1) {
+ throw FormatException(
+ 'Invalid period definition, no / separator found in $content');
+ }
+ return separatorIndex;
+ }
+
+ static DateTime _parseStartDate(String content) {
+ final separatorIndex = _getSeparatorIndex(content);
+ final startDateText = content.substring(0, separatorIndex);
+ return DateParser.parseDateTime(startDateText);
+ }
+
+ static DateTime? _parseEndDate(String content) {
+ final separatorIndex = _getSeparatorIndex(content);
+ final endText = content.substring(separatorIndex + 1);
+ if (endText.startsWith('P')) {
+ return null;
+ }
+ return DateParser.parseDateTime(endText);
+ }
+
+ static IsoDuration? _parseDuration(String content) {
+ final separatorIndex = _getSeparatorIndex(content);
+ final endText = content.substring(separatorIndex + 1);
+ if (!endText.startsWith('P')) {
+ return null;
+ }
+ return IsoDuration.parse(endText);
+ }
+
+ static Period parse(String textValue) {
+ final startDate = _parseStartDate(textValue);
+ final duration = _parseDuration(textValue);
+ final endDate = _parseEndDate(textValue);
+ return Period(startDate, duration: duration, endDate: endDate);
+ }
+}
+
+class _DurationSection {
+ final int result;
+ final int index;
+ _DurationSection(this.result, this.index);
+}
+
+/// ISO 8601 compliant duration
+class IsoDuration {
+ final int years;
+ final int months;
+ final int weeks;
+ final int days;
+ final int hours;
+ final int minutes;
+ final int seconds;
+ final bool isNegativeDuration;
+
+ IsoDuration({
+ this.years = 0,
+ this.months = 0,
+ this.weeks = 0,
+ this.days = 0,
+ this.hours = 0,
+ this.minutes = 0,
+ this.seconds = 0,
+ this.isNegativeDuration = false,
+ });
+
+ static _DurationSection _parseSection(
+ String content, int startIndex, String designatur) {
+ final index = content.indexOf(designatur, startIndex);
+ if (index == -1) {
+ return _DurationSection(0, startIndex);
+ }
+ var text = content.substring(startIndex, index);
+ if (text.contains(',')) {
+ text = text.replaceAll(',', '.');
+ }
+ final parsed = int.tryParse(text);
+ if (parsed == null) {
+ throw FormatException('Invalid duration: $content (for part [$text])');
+ }
+ return _DurationSection(parsed, index + 1);
+ }
+
+ @override
+ int get hashCode =>
+ years +
+ months * 12 +
+ weeks * 53 +
+ days * 366 +
+ hours * 24 +
+ minutes * 60 +
+ seconds * 600;
+
+ @override
+ bool operator ==(Object other) {
+ return other is IsoDuration &&
+ other.years == years &&
+ other.months == months &&
+ other.weeks == weeks &&
+ other.days == days &&
+ other.hours == hours &&
+ other.minutes == minutes &&
+ other.seconds == seconds;
+ }
+
+ @override
+ String toString() {
+ final buffer = StringBuffer();
+ if (isNegativeDuration) {
+ buffer.write('-');
+ }
+ buffer.write('P');
+ if (years != 0) {
+ buffer..write(years)..write('Y');
+ }
+ if (months != 0) {
+ buffer..write(months)..write('M');
+ }
+ if (weeks != 0) {
+ buffer..write(weeks)..write('W');
+ }
+ if (days != 0) {
+ buffer..write(days)..write('D');
+ }
+ if (hours != 0 || minutes != 0 || seconds != 0 || buffer.length == 1) {
+ buffer.write('T');
+ buffer
+ ..write(hours)
+ ..write('H')
+ ..write(minutes)
+ ..write('M')
+ ..write(seconds)
+ ..write('S');
+ }
+ return buffer.toString();
+ }
+
+ /// Parses the given [textValue] into a duration.
+ ///
+ /// The formmat is defined as `P[n]Y[n]M[n]DT[n]H[n]M[n]S`
+ /// Example: `P3WT1H` means 3 weeks and 1 hour.
+ /// Compare https://en.wikipedia.org/wiki/ISO_8601#Durations
+ static IsoDuration parse(String textValue) {
+ /// Note ISO_8601 allows floating numbers, compare https://en.wikipedia.org/wiki/ISO_8601#Durations,
+ /// but even the validator https://icalendar.org/validator.html does not accept floating numbers.
+ /// So this implementation expects integers, too
+ if (!(textValue.startsWith('P') || textValue.startsWith('-P'))) {
+ throw FormatException(
+ 'duration content needs to start with P, $textValue is invalid');
+ }
+ final isNegativeDuration = textValue.startsWith('-');
+ if (isNegativeDuration) {
+ textValue = textValue.substring(1);
+ }
+ var years = 0, months = 0, weeks = 0, days = 0;
+ var startIndex = 1;
+ if (!textValue.startsWith('PT')) {
+ final yearsResult = _parseSection(textValue, startIndex, 'Y');
+ startIndex = yearsResult.index;
+ years = yearsResult.result;
+ final monthsResult = _parseSection(textValue, startIndex, 'M');
+ startIndex = monthsResult.index;
+ months = monthsResult.result;
+ final weeksResult = _parseSection(textValue, startIndex, 'W');
+ startIndex = weeksResult.index;
+ weeks = weeksResult.result;
+ final daysResult = _parseSection(textValue, startIndex, 'D');
+ startIndex = daysResult.index;
+ days = daysResult.result;
+ }
+ var hours = 0, minutes = 0, seconds = 0;
+ if (startIndex < textValue.length && textValue[startIndex] == 'T') {
+ startIndex++;
+ final hoursResult = _parseSection(textValue, startIndex, 'H');
+ hours = hoursResult.result;
+ startIndex = hoursResult.index;
+ final minutesResult = _parseSection(textValue, startIndex, 'M');
+ minutes = minutesResult.result;
+ startIndex = minutesResult.index;
+ final secondsResult = _parseSection(textValue, startIndex, 'S');
+ seconds = secondsResult.result;
+ }
+
+ return IsoDuration(
+ years: years,
+ months: months,
+ weeks: weeks,
+ days: days,
+ hours: hours,
+ minutes: minutes,
+ seconds: seconds,
+ isNegativeDuration: isNegativeDuration,
+ );
+ }
+}
+
+/// To specify the free or busy time type.
+///
+/// Compare [ParameterType.freeBusyTimeType], [FreeBusyType]
+enum FreeBusyStatus { free, busy, busyUnavailable, busyTentative, other }
+
+extension ExtensionFreeBusyValue on FreeBusyStatus {
+ String? get name {
+ switch (this) {
+ case FreeBusyStatus.free:
+ return 'FREE';
+ case FreeBusyStatus.busy:
+ return 'BUSY';
+ case FreeBusyStatus.busyUnavailable:
+ return 'BUSY-UNAVAILABLE';
+ case FreeBusyStatus.busyTentative:
+ return 'BUSY-TENTATIVE';
+ case FreeBusyStatus.other:
+ return null;
+ }
+ }
+}
+
+/// The type of a user
+enum CalendarUserType { individual, group, resource, room, unknown, other }
+
+extension ExtensionCommonCalendarUserTypeValue on CalendarUserType {
+ String? get name {
+ switch (this) {
+ case CalendarUserType.individual:
+ return 'INDIVIDUAL';
+ case CalendarUserType.group:
+ return 'GROUP';
+ case CalendarUserType.resource:
+ return 'RESOURCE';
+ case CalendarUserType.room:
+ return 'ROOM';
+ case CalendarUserType.unknown:
+ return 'UNKNOWN';
+ case CalendarUserType.other:
+ return null;
+ }
+ }
+}
+
+/// Specifies the relationship of the alarm trigger with respect to the start or end of the calendar component.
+///
+/// It is used for example in the `RELATED` parameter, [TriggerAlarm]
+enum AlarmTriggerRelationship {
+ /// the trigger is specified relative to the start of the calendar component
+ start,
+
+ /// the trigger is specified releative to the end of the calendar component
+ end
+}
+
+extension ExtensionAlarmTriggerRelationship on AlarmTriggerRelationship {
+ String? get name {
+ switch (this) {
+ case AlarmTriggerRelationship.start:
+ return 'START';
+ case AlarmTriggerRelationship.end:
+ return 'END';
+ }
+ }
+}
+
+/// To specify the type of hierarchical relationship associated with the calendar component specified by the property.
+enum Relationship {
+ /// Parent relationship - Default
+ parent,
+
+ /// Child relationship
+ child,
+
+ /// Sibling relationship
+ sibling,
+
+ /// other
+ other
+}
+
+extension ExtensionRelationship on Relationship {
+ String? get name {
+ switch (this) {
+ case Relationship.parent:
+ return 'PARENT';
+ case Relationship.child:
+ return 'CHILD';
+ case Relationship.sibling:
+ return 'SIBLING';
+ case Relationship.other:
+ return null;
+ }
+ }
+}
+
+/// To specify the participation role for the calendar user specified by the property.
+enum Role {
+ /// Indicates chair of the calendar entity
+ chair,
+
+ /// Indicates a participant whose participation is required
+ requiredParticipant,
+
+ /// Indicates a participant whose participation is optional
+ optionalParticipant,
+
+ /// Indicates a participant who is copied for information purposes only
+ nonParticpant,
+
+ /// Other
+ other
+}
+
+extension ExtensionRole on Role {
+ String? get name {
+ switch (this) {
+ case Role.chair:
+ return 'CHAIR';
+ case Role.requiredParticipant:
+ return 'REQ-PARTICIPANT';
+ case Role.optionalParticipant:
+ return 'OPT-PARTICIPANT';
+ case Role.nonParticpant:
+ return 'NON-PARTICIPANT';
+ case Role.other:
+ return null;
+ }
+ }
+}
+
+/// Provides the range of a change
+///
+/// The "RANGE" parameter is used to specify the effective range of
+/// recurrence instances from the instance specified by the
+/// "RECURRENCE-ID" property value. The value for the range parameter
+/// can only be "THISANDFUTURE" to indicate a range defined by the
+/// given recurrence instance and all subsequent instances.
+enum Range {
+ /// Specifies the effective range of recurrence instances that is specified by the property.
+ ///
+ /// The effective range is from the recurrence identifier
+ /// specified by the property. If this parameter is not specified on
+ /// an allowed property, then the default range is the single instance
+ /// specified by the recurrence identifier value of the property. The
+ /// parameter value can only be "THISANDFUTURE" to indicate a range
+ /// defined by the recurrence identifier and all subsequent instances.
+ /// The value "THISANDPRIOR" is deprecated by this revision of
+ /// iCalendar and MUST NOT be generated by applications.
+ thisAndFuture,
+}
+
+extension ExtensionRange on Range {
+ String get name => 'THISANDFUTURE';
+}
+
+enum ParticipantStatus {
+ /// Default status
+ needsAction,
+
+ /// Accepted
+ accepted,
+
+ /// Declined
+ declined,
+
+ /// Accepted tentatively
+ tentative,
+
+ /// Delegated (for a task)
+ delegated,
+
+ /// In Process (for a task)
+ inProcess,
+
+ /// Completed (for a task)
+ completed,
+
+ /// Other status
+ other
+}
+
+extension ExtensionParticpantStatus on ParticipantStatus {
+ String? get name {
+ switch (this) {
+ case ParticipantStatus.needsAction:
+ return 'NEEDS-ACTION';
+ case ParticipantStatus.accepted:
+ return 'ACCEPTED';
+ case ParticipantStatus.declined:
+ return 'DECLINED';
+ case ParticipantStatus.tentative:
+ return 'TENTATIVE';
+ case ParticipantStatus.delegated:
+ return 'DELEGATED';
+ case ParticipantStatus.inProcess:
+ return 'IN-PROCESS';
+ case ParticipantStatus.completed:
+ return 'COMPLETED';
+ case ParticipantStatus.other:
+ return null;
+ }
+ }
+}
+
+class ClassificationParser {
+ ClassificationParser._();
+ static Classification parse(String textValue) {
+ switch (textValue) {
+ case 'PUBLIC':
+ return Classification.public;
+ case 'PRIVATE':
+ return Classification.private;
+ case 'CONFIDENTIAL':
+ return Classification.confidential;
+ default:
+ return Classification.other;
+ }
+ }
+}
+
+/// The action of an alarm
+enum AlarmAction {
+ /// An audio sound should be played.
+ ///
+ /// When the action is "AUDIO", the alarm can also include one and
+ /// only one "ATTACH" property, which MUST point to a sound resource,
+ /// which is rendered when the alarm is triggered.
+ audio,
+
+ /// A notice should be displayed.
+ ///
+ /// When the action is "DISPLAY", the alarm MUST also include a
+ /// "DESCRIPTION" property, which contains the text to be displayed
+ /// when the alarm is triggered.
+ display,
+
+ /// An email should be sent.
+ ///
+ /// When the action is "EMAIL", the alarm MUST include a "DESCRIPTION"
+ /// property, which contains the text to be used as the message body,
+ /// a "SUMMARY" property, which contains the text to be used as the
+ /// message subject, and one or more "ATTENDEE" properties, which
+ /// contain the email address of attendees to receive the message. It
+ /// can also include one or more "ATTACH" properties, which are
+ /// intended to be sent as message attachments. When the alarm is
+ /// triggered, the email message is sent.
+ email,
+
+ /// A different, non-standard alarm action should be taken.
+ other
+}
+
+/// Contains all relevant binary information
+class Binary {
+ /// The data in textual form
+ final String value;
+
+ /// The media like `image/png`
+ final String? mediaType;
+
+ /// The encoding type like `BASE64`
+ final String? encoding;
+
+ Binary(
+ {required this.value, required this.mediaType, required this.encoding});
+}
+
+/// Provides access to a geolocation
+class GeoLocation {
+ final double latitude;
+ final double longitude;
+
+ GeoLocation(this.latitude, this.longitude);
+
+ @override
+ String toString() {
+ final buffer = StringBuffer()
+ ..write(latitude)
+ ..write(';')
+ ..write(longitude);
+ return buffer.toString();
+ }
+}
diff --git a/lib/src/util.dart b/lib/src/util.dart
new file mode 100644
index 0000000..ddfedf4
--- /dev/null
+++ b/lib/src/util.dart
@@ -0,0 +1,8 @@
+class Rune {
+ Rune._();
+ static const int runeDoubleQuote = 34;
+ static const int runeComma = 44;
+ static const int runeColon = 58;
+ static const int runeSemicolon = 59;
+ static const int runeBackslash = 92;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 0000000..8acb59b
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,55 @@
+name: enough_icalendar
+description: icalendar library in pure Dart. Fully compliant with RFC 5545..
+version: 0.1.0
+homepage: https://github.com/Enough-Software/enough_icalendar
+
+environment:
+ sdk: ">=2.12.0 <3.0.0"
+ flutter: ">=1.17.0"
+
+dependencies:
+ flutter:
+ sdk: flutter
+ collection: ^1.15.0
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+ lints: ^1.0.1
+
+# For information on the generic Dart part of this file, see the
+# following page: https://dart.dev/tools/pub/pubspec
+
+# The following section is specific to Flutter.
+flutter:
+
+ # To add assets to your package, add an assets section, like this:
+ # assets:
+ # - images/a_dot_burr.jpeg
+ # - images/a_dot_ham.jpeg
+ #
+ # For details regarding assets in packages, see
+ # https://flutter.dev/assets-and-images/#from-packages
+ #
+ # An image asset can refer to one or more resolution-specific "variants", see
+ # https://flutter.dev/assets-and-images/#resolution-aware.
+
+ # To add custom fonts to your package, add a fonts section here,
+ # in this "flutter" section. Each entry in this list should have a
+ # "family" key with the font family name, and a "fonts" key with a
+ # list giving the asset and other descriptors for the font. For
+ # example:
+ # fonts:
+ # - family: Schyler
+ # fonts:
+ # - asset: fonts/Schyler-Regular.ttf
+ # - asset: fonts/Schyler-Italic.ttf
+ # style: italic
+ # - family: Trajan Pro
+ # fonts:
+ # - asset: fonts/TrajanPro.ttf
+ # - asset: fonts/TrajanPro_Bold.ttf
+ # weight: 700
+ #
+ # For details regarding fonts in packages, see
+ # https://flutter.dev/custom-fonts/#from-packages
diff --git a/test/components_test.dart b/test/components_test.dart
new file mode 100644
index 0000000..58afe37
--- /dev/null
+++ b/test/components_test.dart
@@ -0,0 +1,814 @@
+import 'package:enough_icalendar/src/components.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:enough_icalendar/enough_icalendar.dart';
+
+void main() {
+ group('Fold Tests', () {
+ test('No folding', () {
+ final input =
+ '''BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:19970610T172345Z-AF23B2@example.com
+DTSTAMP:19970610T172345Z
+DTSTART:19970714T170000Z
+DTEND:19970715T040000Z
+SUMMARY:Bastille Day Party
+END:VEVENT
+END:VCALENDAR'''
+ .split('\n');
+ final output = Component.unfold(input);
+ expect(output, isNotEmpty);
+ expect(output.length, input.length);
+ for (var i = 0; i < output.length; i++) {
+ expect(output[i], input[i]);
+ }
+ });
+
+ test('Unfold line spread accross 3 lines', () {
+ final input =
+ '''DESCRIPTION:This is a lo
+ ng description
+ that exists on a long line.'''
+ .split('\n');
+ final output = Component.unfold(input);
+ expect(output, isNotEmpty);
+ expect(output.length, 1);
+ expect(output[0],
+ 'DESCRIPTION:This is a long description that exists on a long line.');
+ });
+
+ test('Last line folded', () {
+ final input =
+ '''BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:19970610T172345Z-AF23B2@example.com
+DTSTAMP:19970610T172345Z
+DTSTART:19970714T170000Z
+DTEND:19970715T040000Z
+SUMMARY:Bastille Day Party
+END:VEVENT
+END:VCA
+ LENDAR'''
+ .split('\n');
+ final output = Component.unfold(input);
+ expect(output, isNotEmpty);
+ expect(output.length, input.length - 1);
+ for (var i = 0; i < output.length - 1; i++) {
+ expect(output[i], input[i]);
+ }
+ expect(output.last, 'END:VCALENDAR');
+ });
+
+ test('First line folded', () {
+ final input =
+ '''BEGI
+ N:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:19970610T172345Z-AF23B2@example.com
+DTSTAMP:19970610T172345Z
+DTSTART:19970714T170000Z
+DTEND:19970715T040000Z
+SUMMARY:Bastille Day Party
+END:VEVENT
+END:VCALENDAR'''
+ .split('\n');
+ final output = Component.unfold(input);
+ expect(output, isNotEmpty);
+ expect(output.length, input.length - 1);
+ for (var i = 1; i < output.length; i++) {
+ expect(output[i], input[i + 1]);
+ }
+ expect(output.first, 'BEGIN:VCALENDAR');
+ });
+
+ test('Some lines folded', () {
+ final input =
+ '''BEGI
+ N:VCALENDAR
+VERSION:2.0
+PRODID:-
+ //hacksw/handcal//NON
+ SGML v1.0//EN
+BEGIN:VEVENT
+UID:19970610T172345Z-
+ AF23B2@example.com
+DTSTAMP:19970610T172345Z
+DTSTART:19970714T170000Z
+DTEND:19970715T040000Z
+SUMMARY:Bastille
+ Day Par
+ ty
+END:VEVE
+ NT
+END:VCALENDAR'''
+ .split('\n');
+ final output = Component.unfold(input);
+ final expected =
+ '''BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:19970610T172345Z-AF23B2@example.com
+DTSTAMP:19970610T172345Z
+DTSTART:19970714T170000Z
+DTEND:19970715T040000Z
+SUMMARY:Bastille Day Party
+END:VEVENT
+END:VCALENDAR'''
+ .split('\n');
+ expect(output, isNotEmpty);
+ expect(output.length, expected.length);
+ for (var i = 0; i < output.length; i++) {
+ expect(output[i], expected[i]);
+ }
+ });
+
+ test('Some lines folded 2', () {
+ final input =
+ '''BEGIN:VCALENDAR
+PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:19960704T120000Z
+UID:uid1@example.com
+ORGANIZER:mailto:jsmith@example.com
+DTSTART:19960918T143000Z
+DTEND:19960920T220000Z
+STATUS:CONFIRMED
+CATEGORIES:CONFERENCE
+SUMMARY:Networld+Interop Conference
+DESCRIPTION:Networld+Interop Conference
+ and Exhibit\\nAtlanta World Congress Center\\n
+ Atlanta\\, Georgia
+END:VEVENT
+END:VCALENDAR
+'''
+ .split('\n');
+ final output = Component.unfold(input);
+ final expected =
+ '''BEGIN:VCALENDAR
+PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:19960704T120000Z
+UID:uid1@example.com
+ORGANIZER:mailto:jsmith@example.com
+DTSTART:19960918T143000Z
+DTEND:19960920T220000Z
+STATUS:CONFIRMED
+CATEGORIES:CONFERENCE
+SUMMARY:Networld+Interop Conference
+DESCRIPTION:Networld+Interop Conference and Exhibit\\nAtlanta World Congress Center\\nAtlanta\\, Georgia
+END:VEVENT
+END:VCALENDAR
+'''
+ .split('\n');
+ expect(output, isNotEmpty);
+ //expect(output.length, expected.length);
+ for (var i = 0; i < output.length; i++) {
+ expect(output[i], expected[i]);
+ }
+ });
+
+ test('Some lines folded with LF linebreks in properties', () {
+ final input =
+ '''BEGIN:VCALENDAR
+METHOD:xyz
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VEVENT
+DTSTAMP:19970324T120000Z
+SEQUENCE:0
+UID:uid3@example.com
+ORGANIZER:mailto:jdoe@example.com
+ATTENDEE;RSVP=TRUE:mailto:jsmith@example.com
+DTSTART:19970324T123000Z
+DTEND:19970324T210000Z
+CATEGORIES:MEETING,PROJECT
+CLASS:PUBLIC
+SUMMARY:Calendaring Interoperability Planning Meeting
+DESCRIPTION:Discuss how we can test c&s interoperability\\n
+ using iCalendar and other IETF standards.
+LOCATION:LDB Lobby
+ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/
+ conf/bkgrnd.ps
+END:VEVENT
+END:VCALENDAR
+'''
+ .split('\n');
+ final output = Component.unfold(input);
+ final expected =
+ '''BEGIN:VCALENDAR
+METHOD:xyz
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VEVENT
+DTSTAMP:19970324T120000Z
+SEQUENCE:0
+UID:uid3@example.com
+ORGANIZER:mailto:jdoe@example.com
+ATTENDEE;RSVP=TRUE:mailto:jsmith@example.com
+DTSTART:19970324T123000Z
+DTEND:19970324T210000Z
+CATEGORIES:MEETING,PROJECT
+CLASS:PUBLIC
+SUMMARY:Calendaring Interoperability Planning Meeting
+DESCRIPTION:Discuss how we can test c&s interoperability\\nusing iCalendar and other IETF standards.
+LOCATION:LDB Lobby
+ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/conf/bkgrnd.ps
+END:VEVENT
+END:VCALENDAR
+'''
+ .split('\n');
+ expect(output, isNotEmpty);
+ //expect(output.length, expected.length);
+ for (var i = 0; i < output.length; i++) {
+ expect(output[i], expected[i]);
+ }
+ });
+ });
+
+ group('Calendar Tests', () {
+ test('Calendar simple - unix linebreaks', () {
+ final text =
+ '''BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:19970610T172345Z-AF23B2@example.com
+DTSTAMP:19970610T172345Z
+DTSTART:19970714T170000Z
+DTEND:19970715T040000Z
+SUMMARY:Bastille Day Party
+END:VEVENT
+END:VCALENDAR''';
+ final calendar = Component.parse(text);
+ expect(calendar, isInstanceOf());
+ expect(calendar.children, isNotEmpty);
+ expect(calendar.children.length, 1);
+ expect(calendar.version, '2.0');
+ expect(calendar.productId, '-//hacksw/handcal//NONSGML v1.0//EN');
+ final event = calendar.children.first;
+ expect(event, isInstanceOf());
+ expect((event as VEvent).summary, 'Bastille Day Party');
+ expect(event.uid, '19970610T172345Z-AF23B2@example.com');
+ expect(event.timeStamp, DateTime(1997, 06, 10, 17, 23, 45));
+ expect(event.start, DateTime(1997, 07, 14, 17, 00, 00));
+ expect(event.end, DateTime(1997, 07, 15, 04, 00, 00));
+ });
+
+ test('Private event', () {
+ final text =
+ '''BEGIN:VEVENT
+UID:19970901T130000Z-123401@example.com
+DTSTAMP:19970901T130000Z
+DTSTART:19970903T163000Z
+DTEND:19970903T190000Z
+SUMMARY:Annual Employee Review
+CLASS:PRIVATE
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+END:VEVENT''';
+ final event = Component.parse(text);
+ expect(event, isInstanceOf());
+ expect((event as VEvent).summary, 'Annual Employee Review');
+ expect(event.uid, '19970901T130000Z-123401@example.com');
+ expect(event.timeStamp, DateTime(1997, 09, 01, 13, 00, 00));
+ expect(event.start, DateTime(1997, 09, 03, 16, 30, 00));
+ expect(event.end, DateTime(1997, 09, 03, 19, 00, 00));
+ expect(event.classification, Classification.private);
+ expect(event.categories, ['BUSINESS', 'HUMAN RESOURCES']);
+ });
+
+ test('Transparent event', () {
+ final text =
+ '''BEGIN:VEVENT
+UID:19970901T130000Z-123402@example.com
+DTSTAMP:19970901T130000Z
+DTSTART:19970401T163000Z
+DTEND:19970402T010000Z
+SUMMARY:Laurel is in sensitivity awareness class.
+CLASS:PUBLIC
+CATEGORIES:BUSINESS,HUMAN RESOURCES
+TRANSP:TRANSPARENT
+END:VEVENT
+''';
+ final event = Component.parse(text);
+ expect(event, isInstanceOf());
+ expect((event as VEvent).summary,
+ 'Laurel is in sensitivity awareness class.');
+ expect(event.uid, '19970901T130000Z-123402@example.com');
+ expect(event.timeStamp, DateTime(1997, 09, 01, 13, 00, 00));
+ expect(event.start, DateTime(1997, 04, 01, 16, 30, 00));
+ expect(event.end, DateTime(1997, 04, 02, 1, 00, 00));
+ expect(event.classification, Classification.public);
+ expect(event.categories, ['BUSINESS', 'HUMAN RESOURCES']);
+ expect(event.timeTransparency, TimeTransparency.transparent);
+ });
+
+ test('Recurrent event - yearly', () {
+ final text =
+ '''BEGIN:VEVENT\r
+UID:19970901T130000Z-123403@example.com\r
+DTSTAMP:19970901T130000Z\r
+DTSTART;VALUE=DATE:19971102\r
+SUMMARY:Our Blissful Anniversary\r
+TRANSP:TRANSPARENT\r
+CLASS:CONFIDENTIAL\r
+CATEGORIES:ANNIVERSARY,PERSONAL,SPECIAL OCCASION\r
+RRULE:FREQ=YEARLY\r
+END:VEVENT\r
+''';
+ final event = Component.parse(text);
+ expect(event, isInstanceOf());
+ expect((event as VEvent).summary, 'Our Blissful Anniversary');
+ expect(event.uid, '19970901T130000Z-123403@example.com');
+ expect(event.timeStamp, DateTime(1997, 09, 01, 13, 00, 00));
+ expect(event.start, DateTime(1997, 11, 02));
+ expect(event.classification, Classification.confidential);
+ expect(event.categories, ['ANNIVERSARY', 'PERSONAL', 'SPECIAL OCCASION']);
+ expect(event.timeTransparency, TimeTransparency.transparent);
+ expect(event.recurrenceRule, isNotNull);
+ expect(event.recurrenceRule!.frequency, RecurrenceFrequency.yearly);
+ });
+
+ test('Day ending', () {
+ final text =
+ '''BEGIN:VEVENT
+UID:20070423T123432Z-541111@example.com
+DTSTAMP:20070423T123432Z
+DTSTART;VALUE=DATE:20070628
+DTEND;VALUE=DATE:20070709
+SUMMARY:Festival International de Jazz de Montreal
+TRANSP:TRANSPARENT
+END:VEVENT
+''';
+ final event = Component.parse(text);
+ expect(event, isInstanceOf());
+ expect((event as VEvent).summary,
+ 'Festival International de Jazz de Montreal');
+ expect(event.uid, '20070423T123432Z-541111@example.com');
+ expect(event.timeStamp, DateTime(2007, 04, 23, 12, 34, 32));
+ expect(event.start, DateTime(2007, 06, 28));
+ expect(event.end, DateTime(2007, 07, 09));
+ expect(event.timeTransparency, TimeTransparency.transparent);
+ expect(event.recurrenceRule, isNull);
+ });
+
+ test('three-day conference example with CRLF line breaks', () {
+ final text =
+ '''BEGIN:VCALENDAR\r
+PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN\r
+VERSION:2.0\r
+BEGIN:VEVENT\r
+DTSTAMP:19960704T120000Z\r
+UID:uid1@example.com\r
+ORGANIZER:mailto:jsmith@example.com\r
+DTSTART:19960918T143000Z\r
+DTEND:19960920T220000Z\r
+STATUS:CONFIRMED\r
+CATEGORIES:CONFERENCE\r
+SUMMARY:Networld+Interop Conference\r
+DESCRIPTION:Networld+Interop Conference \r
+ and Exhibit\nAtlanta World Congress Center\n\r
+ Atlanta\, Georgia\r
+END:VEVENT\r
+END:VCALENDAR\r
+''';
+ final calendar = Component.parse(text);
+ expect(calendar, isInstanceOf());
+ expect((calendar as VCalendar).productId,
+ '-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN');
+ expect(calendar.version, '2.0');
+ expect(calendar.isVersion2, isTrue);
+ expect(calendar.children, isNotEmpty);
+ expect(calendar.children.length, 1);
+ final event = calendar.children.first;
+ expect(event, isInstanceOf());
+ expect((event as VEvent).uid, 'uid1@example.com');
+ expect(event.timeStamp, DateTime(1996, 07, 04, 12, 00, 00));
+ expect(event.start, DateTime(1996, 09, 18, 14, 30, 00));
+ expect(event.end, DateTime(1996, 09, 20, 22, 00, 00));
+ expect(event.categories, ['CONFERENCE']);
+ expect(event.status, EventStatus.confirmed);
+ expect(event.organizer?.email, 'jsmith@example.com');
+ expect(event.summary, 'Networld+Interop Conference');
+ expect(event.description,
+ 'Networld+Interop Conference and Exhibit\nAtlanta World Congress Center\nAtlanta, Georgia');
+ });
+
+ test(
+ 'three-day conference example with LF line breaks and LF breaks in DESCRIPTION',
+ () {
+ final text =
+ '''BEGIN:VCALENDAR
+PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
+VERSION:2.0
+BEGIN:VEVENT
+DTSTAMP:19960704T120000Z
+UID:uid1@example.com
+ORGANIZER:mailto:jsmith@example.com
+DTSTART:19960918T143000Z
+DTEND:19960920T220000Z
+STATUS:CONFIRMED
+CATEGORIES:CONFERENCE
+SUMMARY:Networld+Interop Conference
+DESCRIPTION:Networld+Interop Conference
+ and Exhibit\nAtlanta World Congress Center\n
+ Atlanta\, Georgia
+END:VEVENT
+END:VCALENDAR
+''';
+ final calendar = Component.parse(text);
+ expect(calendar, isInstanceOf());
+ expect((calendar as VCalendar).productId,
+ '-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN');
+ expect(calendar.version, '2.0');
+ expect(calendar.isVersion2, isTrue);
+ expect(calendar.children, isNotEmpty);
+ expect(calendar.children.length, 1);
+ final event = calendar.children.first;
+ expect(event, isInstanceOf());
+ expect((event as VEvent).uid, 'uid1@example.com');
+ expect(event.timeStamp, DateTime(1996, 07, 04, 12, 00, 00));
+ expect(event.start, DateTime(1996, 09, 18, 14, 30, 00));
+ expect(event.end, DateTime(1996, 09, 20, 22, 00, 00));
+ expect(event.categories, ['CONFERENCE']);
+ expect(event.status, EventStatus.confirmed);
+ expect(event.organizer?.email, 'jsmith@example.com');
+ expect(event.summary, 'Networld+Interop Conference');
+ expect(event.description,
+ 'Networld+Interop Conference and Exhibit\nAtlanta World Congress Center\nAtlanta, Georgia');
+ });
+
+ test('group-scheduled meeting with VTIMEZONE', () {
+ final text =
+ '''BEGIN:VCALENDAR
+PRODID:-//RDU Software//NONSGML HandCal//EN
+VERSION:2.0
+BEGIN:VTIMEZONE
+TZID:America/New_York
+BEGIN:STANDARD
+DTSTART:19981025T020000
+TZOFFSETFROM:-0400
+TZOFFSETTO:-0500
+TZNAME:EST
+END:STANDARD
+BEGIN:DAYLIGHT
+DTSTART:19990404T020000
+TZOFFSETFROM:-0500
+TZOFFSETTO:-0400
+TZNAME:EDT
+END:DAYLIGHT
+END:VTIMEZONE
+BEGIN:VEVENT
+DTSTAMP:19980309T231000Z
+UID:guid-1.example.com
+ORGANIZER:mailto:mrbig@example.com
+ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT;CUTYPE=GROUP:
+ mailto:employee-A@example.com
+DESCRIPTION:Project XYZ Review Meeting
+CATEGORIES:MEETING
+CLASS:PUBLIC
+CREATED:19980309T130000Z
+SUMMARY:XYZ Project Review
+DTSTART;TZID=America/New_York:19980312T083000
+DTEND;TZID=America/New_York:19980312T093000
+LOCATION:1CP Conference Room 4350
+END:VEVENT
+END:VCALENDAR
+''';
+ final calendar = Component.parse(text);
+ expect(calendar, isInstanceOf());
+ expect((calendar as VCalendar).productId,
+ '-//RDU Software//NONSGML HandCal//EN');
+ expect(calendar.version, '2.0');
+ expect(calendar.isVersion2, isTrue);
+ expect(calendar.children, isNotEmpty);
+ expect(calendar.children.length, 2);
+ final event = calendar.children[1];
+ expect(event, isInstanceOf());
+ expect((event as VEvent).uid, 'guid-1.example.com');
+ expect(event.timeStamp, DateTime(1998, 03, 09, 23, 10, 00));
+ expect(event.created, DateTime(1998, 03, 09, 13, 00, 00));
+ expect(event.start, DateTime(1998, 03, 12, 08, 30, 00));
+ expect(event.end, DateTime(1998, 03, 12, 09, 30, 00));
+ expect(event.location, '1CP Conference Room 4350');
+ expect(event.classification, Classification.public);
+ expect(event.categories, ['MEETING']);
+ expect(event.organizer?.email, 'mrbig@example.com');
+ expect(event.summary, 'XYZ Project Review');
+ expect(event.attendees, isNotEmpty);
+ expect(event.attendees.length, 1);
+ final attendee = event.attendees.first;
+ expect(attendee.rsvp, isTrue);
+ expect(attendee.role, Role.requiredParticipant);
+ expect(attendee.userType, CalendarUserType.group);
+ expect(attendee.email, 'employee-A@example.com');
+ final timezone = calendar.children.first;
+ expect(timezone, isInstanceOf());
+ expect((timezone as VTimezone).timezoneId, 'America/New_York');
+ expect(timezone.children, isNotEmpty);
+ expect(timezone.children.length, 2);
+ var phase = timezone.children.first;
+ expect(phase, isInstanceOf());
+ expect(phase.name, 'STANDARD');
+ expect(phase.componentType, ComponentType.timezonePhaseStandard);
+ expect((phase as VTimezonePhase).timezoneName, 'EST');
+ expect(phase.start, DateTime(1998, 10, 25, 02, 00, 00));
+ expect(phase.from, UtcOffset.value(offsetHour: -4, offsetMinute: 0));
+ expect(phase.to, UtcOffset.value(offsetHour: -5, offsetMinute: 0));
+ phase = timezone.children[1];
+ expect(phase, isInstanceOf());
+ expect(phase.name, 'DAYLIGHT');
+ expect(phase.componentType, ComponentType.timezonePhaseDaylight);
+ expect((phase as VTimezonePhase).timezoneName, 'EDT');
+ expect(phase.start, DateTime(1999, 04, 04, 02, 00, 00));
+ expect(phase.from, UtcOffset.value(offsetHour: -5, offsetMinute: 0));
+ expect(phase.to, UtcOffset.value(offsetHour: -4, offsetMinute: 0));
+ });
+
+ test('with attachments and sequence', () {
+ final text =
+ '''BEGIN:VCALENDAR
+METHOD:xyz
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VEVENT
+DTSTAMP:19970324T120000Z
+SEQUENCE:0
+UID:uid3@example.com
+ORGANIZER:mailto:jdoe@example.com
+ATTENDEE;RSVP=TRUE:mailto:jsmith@example.com
+DTSTART:19970324T123000Z
+DTEND:19970324T210000Z
+CATEGORIES:MEETING,PROJECT
+CLASS:PUBLIC
+SUMMARY:Calendaring Interoperability Planning Meeting
+DESCRIPTION:Discuss how we can test c&s interoperability\\n
+ using iCalendar and other IETF standards.
+LOCATION:LDB Lobby
+ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/
+ conf/bkgrnd.ps
+END:VEVENT
+END:VCALENDAR
+''';
+ final calendar = Component.parse(text);
+ expect(calendar, isInstanceOf());
+ expect((calendar as VCalendar).productId,
+ '-//ABC Corporation//NONSGML My Product//EN');
+ expect(calendar.version, '2.0');
+ expect(calendar.isVersion2, isTrue);
+ expect(calendar.method, 'xyz');
+ expect(calendar.children, isNotEmpty);
+ expect(calendar.children.length, 1);
+ final event = calendar.children.first;
+ expect(event, isInstanceOf());
+ expect((event as VEvent).uid, 'uid3@example.com');
+ expect(event.timeStamp, DateTime(1997, 03, 24, 12, 00, 00));
+ expect(event.start, DateTime(1997, 03, 24, 12, 30, 00));
+ expect(event.end, DateTime(1997, 03, 24, 21, 00, 00));
+ expect(event.organizer?.email, 'jdoe@example.com');
+ expect(event.attendees.length, 1);
+ expect(event.attendees[0].rsvp, isTrue);
+ expect(event.attendees[0].email, 'jsmith@example.com');
+ expect(event.categories, ['MEETING', 'PROJECT']);
+ expect(event.classification, Classification.public);
+ expect(event.summary, 'Calendaring Interoperability Planning Meeting');
+ expect(event.description,
+ 'Discuss how we can test c&s interoperability\\nusing iCalendar and other IETF standards.');
+ expect(event.location, 'LDB Lobby');
+ // ATTACH;FMTTYPE=application/postscript:ftp://example.com/pub/conf/bkgrnd.ps
+ expect(event.attachments, isNotEmpty);
+ expect(event.attachments[0].mediaType, 'application/postscript');
+ expect(event.attachments[0].uri,
+ Uri.parse('ftp://example.com/pub/conf/bkgrnd.ps'));
+ expect(event.sequence, 0);
+ });
+
+ test('Todo with audio alarm', () {
+ final text =
+ '''BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//ABC Corporation//NONSGML My Product//EN
+BEGIN:VTODO
+DTSTAMP:19980130T134500Z
+SEQUENCE:2
+UID:uid4@example.com
+ORGANIZER:mailto:unclesam@example.com
+ATTENDEE;PARTSTAT=ACCEPTED:mailto:jqpublic@example.com
+DUE:19980415T000000
+STATUS:NEEDS-ACTION
+SUMMARY:Submit Income Taxes
+BEGIN:VALARM
+ACTION:AUDIO
+TRIGGER;VALUE=DATE-TIME:19980403T120000Z
+ATTACH;FMTTYPE=audio/basic:http://example.com/pub/audio-
+ files/ssbanner.aud
+REPEAT:4
+DURATION:PT1H
+END:VALARM
+END:VTODO
+END:VCALENDAR
+''';
+ final calendar = Component.parse(text);
+ expect(calendar, isInstanceOf());
+ expect((calendar as VCalendar).productId,
+ '-//ABC Corporation//NONSGML My Product//EN');
+ expect(calendar.version, '2.0');
+ expect(calendar.isVersion2, isTrue);
+ expect(calendar.children, isNotEmpty);
+ expect(calendar.children.length, 1);
+ final todo = calendar.children.first;
+ expect(todo, isInstanceOf());
+ expect((todo as VTodo).uid, 'uid4@example.com');
+ expect(todo.timeStamp, DateTime(1998, 01, 30, 13, 45, 00));
+ expect(todo.sequence, 2);
+ expect(todo.organizer?.email, 'unclesam@example.com');
+ expect(todo.attendees.length, 1);
+ expect(todo.attendees[0].rsvp, isFalse);
+ expect(todo.attendees[0].participantStatus, ParticipantStatus.accepted);
+ expect(todo.attendees[0].email, 'jqpublic@example.com');
+ expect(todo.summary, 'Submit Income Taxes');
+ expect(todo.status, TodoStatus.needsAction);
+ expect(todo.due, DateTime(1998, 04, 15, 00, 00, 00));
+ expect(todo.children, isNotEmpty);
+ expect(todo.children.length, 1);
+ final alarm = todo.children.first;
+ expect(alarm, isInstanceOf());
+ expect((alarm as VAlarm).componentType, ComponentType.alarm);
+ expect(alarm.action, AlarmAction.audio);
+ expect(alarm.triggerDate, DateTime(1998, 04, 03, 12, 00, 00));
+ expect(alarm.repeat, 4);
+ expect(alarm.duration, IsoDuration(hours: 1));
+ expect(alarm.attachments, isNotEmpty);
+ expect(alarm.attachments.length, 1);
+ expect(alarm.attachments[0].mediaType, 'audio/basic');
+ expect(alarm.attachments[0].uri,
+ Uri.parse('http://example.com/pub/audio-files/ssbanner.aud'));
+ });
+
+ test('journal example', () {
+ final text =
+ '''BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//RDU Software//NONSGML HandCal//EN
+BEGIN:VFREEBUSY
+UID:19970901T115957Z-76A912@example.com
+DTSTAMP:19970901T120000Z
+ORGANIZER:mailto:jsmith@example.com
+DTSTART:19980313T141711Z
+DTEND:19980410T141711Z
+FREEBUSY:19980314T233000Z/19980315T003000Z
+FREEBUSY:19980316T153000Z/19980316T163000Z
+FREEBUSY:19980318T030000Z/19980318T040000Z
+URL:http://www.example.com/calendar/busytime/jsmith.ifb
+END:VFREEBUSY
+END:VCALENDAR
+''';
+ final calendar = Component.parse(text);
+ expect(calendar, isInstanceOf());
+ expect((calendar as VCalendar).productId,
+ '-//RDU Software//NONSGML HandCal//EN');
+ expect(calendar.version, '2.0');
+ expect(calendar.isVersion2, isTrue);
+ expect(calendar.children, isNotEmpty);
+ expect(calendar.children.length, 1);
+ final freebusy = calendar.children.first;
+ expect(freebusy, isInstanceOf());
+ expect((freebusy as VFreeBusy).organizer?.email, 'jsmith@example.com');
+ expect(freebusy.timeStamp, DateTime(1997, 09, 01, 12, 00, 00));
+ expect(freebusy.uid, '19970901T115957Z-76A912@example.com');
+ expect(freebusy.organizer?.email, 'jsmith@example.com');
+ expect(freebusy.start, DateTime(1998, 03, 13, 14, 17, 11));
+ expect(freebusy.end, DateTime(1998, 04, 10, 14, 17, 11));
+ expect(freebusy.url,
+ Uri.parse('http://www.example.com/calendar/busytime/jsmith.ifb'));
+ expect(freebusy.freeBusyProperties, isNotEmpty);
+ expect(freebusy.freeBusyProperties.length, 3);
+ expect(freebusy.freeBusyProperties[0].periods, isNotEmpty);
+ expect(freebusy.freeBusyProperties[0].periods.length, 1);
+ expect(freebusy.freeBusyProperties[0].periods[0].startDate,
+ DateTime(1998, 03, 14, 23, 30, 00));
+ expect(freebusy.freeBusyProperties[0].periods[0].endDate,
+ DateTime(1998, 03, 15, 00, 30, 00));
+ expect(freebusy.freeBusyProperties[0].freeBusyType, FreeBusyStatus.busy);
+ expect(freebusy.freeBusyProperties[1].periods.length, 1);
+ expect(freebusy.freeBusyProperties[1].periods[0].startDate,
+ DateTime(1998, 03, 16, 15, 30, 00));
+ expect(freebusy.freeBusyProperties[1].periods[0].endDate,
+ DateTime(1998, 03, 16, 16, 30, 00));
+ expect(freebusy.freeBusyProperties[2].periods.length, 1);
+ expect(freebusy.freeBusyProperties[2].periods[0].startDate,
+ DateTime(1998, 03, 18, 03, 00, 00));
+ expect(freebusy.freeBusyProperties[2].periods[0].endDate,
+ DateTime(1998, 03, 18, 04, 00, 00));
+ });
+
+ test('free busy example', () {
+ final text =
+ '''BEGIN:VCALENDAR\r
+VERSION:2.0\r
+PRODID:-//ABC Corporation//NONSGML My Product//EN\r
+BEGIN:VJOURNAL\r
+DTSTAMP:19970324T120000Z\r
+UID:uid5@example.com\r
+ORGANIZER:mailto:jsmith@example.com\r
+STATUS:DRAFT\r
+CLASS:PUBLIC\r
+CATEGORIES:Project Report,XYZ,Weekly Meeting\r
+DESCRIPTION:Project xyz Review Meeting Minutes\n\r
+ Agenda\n1. Review of project version 1.0 requirements.\n2. \r
+ Definition \r
+ of project processes.\n3. Review of project schedule.\n\r
+ Participants: John Smith\, Jane Doe\, Jim Dandy\n-It was \r
+ decided that the requirements need to be signed off by \r
+ product marketing.\n-Project processes were accepted.\n\r
+ -Project schedule needs to account for scheduled holidays \r
+ and employee vacation time. Check with HR for specific \r
+ dates.\n-New schedule will be distributed by Friday.\n-\r
+ Next weeks meeting is cancelled. No meeting until 3/23.\r
+END:VJOURNAL\r
+END:VCALENDAR\r
+''';
+ final calendar = Component.parse(text);
+ expect(calendar, isInstanceOf());
+ expect((calendar as VCalendar).productId,
+ '-//ABC Corporation//NONSGML My Product//EN');
+ expect(calendar.version, '2.0');
+ expect(calendar.isVersion2, isTrue);
+ expect(calendar.children, isNotEmpty);
+ expect(calendar.children.length, 1);
+ final journal = calendar.children.first;
+ expect(journal, isInstanceOf());
+ expect((journal as VJournal).uid, 'uid5@example.com');
+ expect(journal.timeStamp, DateTime(1997, 03, 24, 12, 00, 00));
+ expect(journal.organizer?.email, 'jsmith@example.com');
+ expect(journal.classification, Classification.public);
+ expect(journal.status, JournalStatus.draft);
+ expect(journal.categories, ['Project Report', 'XYZ', 'Weekly Meeting']);
+ expect(journal.description,
+ '''Project xyz Review Meeting Minutes\nAgenda
+1. Review of project version 1.0 requirements.
+2. Definition of project processes.
+3. Review of project schedule.
+Participants: John Smith\, Jane Doe\, Jim Dandy\n-It was decided that the requirements need to be signed off by product marketing.
+-Project processes were accepted.
+-Project schedule needs to account for scheduled holidays and employee vacation time. Check with HR for specific dates.
+-New schedule will be distributed by Friday.
+-Next weeks meeting is cancelled. No meeting until 3/23.''');
+ });
+
+ test('Real world 1', () {
+ final text =
+ '''BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:http://ticket.io/
+METHOD:PUBLISH
+X-WR-TIMEZONE:Europe/Berlin
+BEGIN:VEVENT
+UID:ticketioa7a690b342c3a9fdbc20206572744d64
+CLASS:PUBLIC
+SUMMARY:Kostenloser Antigen-Schnelltest Bremen
+LOCATION:, Außer der Schleifmühle 4, 28203 Bremen
+DTSTART;TZID=Europe/Berlin:20210706T090000
+DTEND;TZID=Europe/Berlin:20210706T210000
+DTSTAMP:20210706T103042
+DESCRIPTION:
+ORGANIZER;CN="Covidzentrum Bremen":MAILTO:sofortsupport@ticket.io
+END:VEVENT
+END:VCALENDAR''';
+ final calendar = Component.parse(text);
+ expect(calendar, isInstanceOf());
+ expect((calendar as VCalendar).version, '2.0');
+ expect(calendar.isVersion2, isTrue);
+ expect(calendar.calendarScale, 'GREGORIAN');
+ expect(calendar.isGregorian, isTrue);
+ expect(calendar.productId, 'http://ticket.io/');
+ expect(calendar.timezoneId, 'Europe/Berlin');
+ expect(calendar.children, isNotEmpty);
+ expect(calendar.children.length, 1);
+ final event = calendar.children.first;
+ expect(event, isInstanceOf());
+ expect(
+ (event as VEvent).summary, 'Kostenloser Antigen-Schnelltest Bremen');
+ expect(event.uid, 'ticketioa7a690b342c3a9fdbc20206572744d64');
+ expect(event.classification, Classification.public);
+ expect(event.timeStamp, DateTime(2021, 07, 06, 10, 30, 42));
+ expect(event.start, DateTime(2021, 07, 06, 09, 00, 00));
+ expect(event.end, DateTime(2021, 07, 06, 21, 00, 00));
+ expect(event.description, '');
+ expect(event.location, ', Außer der Schleifmühle 4, 28203 Bremen');
+ expect(event.organizer, isNotNull);
+ expect(event.organizer!.uri, Uri.parse('MAILTO:sofortsupport@ticket.io'));
+ expect(event.organizer!.email, 'sofortsupport@ticket.io');
+ expect(event.organizer!.commonName, 'Covidzentrum Bremen');
+ });
+ });
+}
diff --git a/test/properties_test.dart b/test/properties_test.dart
new file mode 100644
index 0000000..c7258f5
--- /dev/null
+++ b/test/properties_test.dart
@@ -0,0 +1,193 @@
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:enough_icalendar/enough_icalendar.dart';
+
+void main() {
+ group('Direct Property Instantiation', () {
+ test('OrganizerProperty', () {
+ final prop = OrganizerProperty(
+ 'ORGANIZER;CN="Covidzentrum Bremen":MAILTO:sofortsupport@ticket.io');
+ expect(prop.name, 'ORGANIZER');
+ expect(prop.textValue, 'MAILTO:sofortsupport@ticket.io');
+ expect(prop.value, Uri.parse('MAILTO:sofortsupport@ticket.io'));
+ expect(prop.parameters, isNotEmpty);
+ expect(prop.parameters.length, 1);
+ expect(prop.parameters['CN'], isNotNull);
+ expect(prop[ParameterType.commonName], isNotNull);
+ expect(prop.parameters['CN'], prop[ParameterType.commonName]);
+ expect(prop.parameters['CN']!.textValue, '"Covidzentrum Bremen"');
+ expect(prop.parameters['CN']!.value, 'Covidzentrum Bremen');
+ expect(prop.commonName, 'Covidzentrum Bremen');
+ });
+
+ test('GeoProperty', () {
+ final prop = GeoProperty('GEO:37.386013;-122.082932');
+ expect(prop.name, 'GEO');
+ expect(prop.textValue, '37.386013;-122.082932');
+ expect(prop.location.latitude, 37.386013);
+ expect(prop.location.longitude, -122.082932);
+ });
+
+ test('Duration PT1H0M0S', () {
+ final prop = DurationProperty('DURATION:PT1H0M0S');
+ expect(prop.name, 'DURATION');
+ expect(prop.textValue, 'PT1H0M0S');
+ expect(prop.duration, isNotNull);
+ expect(prop.duration.years, 0);
+ expect(prop.duration.months, 0);
+ expect(prop.duration.weeks, 0);
+ expect(prop.duration.days, 0);
+ expect(prop.duration.hours, 1);
+ expect(prop.duration.minutes, 0);
+ expect(prop.duration.days, 0);
+ });
+ test('DURATION:PT15M', () {
+ final prop = DurationProperty('DURATION:PT15M');
+ expect(prop.name, 'DURATION');
+ expect(prop.textValue, 'PT15M');
+ expect(prop.duration, isNotNull);
+ expect(prop.duration.years, 0);
+ expect(prop.duration.months, 0);
+ expect(prop.duration.weeks, 0);
+ expect(prop.duration.days, 0);
+ expect(prop.duration.hours, 0);
+ expect(prop.duration.minutes, 15);
+ expect(prop.duration.days, 0);
+ });
+
+ test('DURATION:P1Y6M2W', () {
+ final prop = DurationProperty('DURATION:P1Y6M2W');
+ expect(prop.name, 'DURATION');
+ expect(prop.textValue, 'P1Y6M2W');
+ expect(prop.duration, isNotNull);
+ expect(prop.duration.years, 1);
+ expect(prop.duration.months, 6);
+ expect(prop.duration.weeks, 2);
+ expect(prop.duration.days, 0);
+ expect(prop.duration.hours, 0);
+ expect(prop.duration.minutes, 0);
+ expect(prop.duration.days, 0);
+ });
+
+ test('ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto:jsmith@example.com',
+ () {
+ final prop = AttendeeProperty(
+ 'ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto:jsmith@example.com');
+ expect(prop.name, 'ATTENDEE');
+ expect(prop.textValue, 'mailto:jsmith@example.com');
+ expect(prop.attendee, Uri.parse('mailto:jsmith@example.com'));
+ expect(prop.role, Role.requiredParticipant);
+ expect(prop.rsvp, isTrue);
+ });
+ });
+
+ group('Indirect Property Instantiation', () {
+ test('GeoProperty', () {
+ final prop = Property.parseProperty('GEO:37.386013;-122.082932');
+ expect(prop, isInstanceOf());
+ expect(prop.name, 'GEO');
+ expect(prop.textValue, '37.386013;-122.082932');
+ expect((prop as GeoProperty).location.latitude, 37.386013);
+ expect(prop.location.longitude, -122.082932);
+ });
+
+ test('RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z', () {
+ final prop = Property.parseProperty(
+ 'RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z');
+ expect(prop, isInstanceOf());
+
+ expect(prop.name, 'RRULE');
+ expect((prop as RecurrenceRuleProperty).rule.frequency,
+ RecurrenceFrequency.yearly);
+ expect(prop.rule.byWeekDay, isNotEmpty);
+ expect(prop.rule.byWeekDay!.length, 1);
+ expect(prop.rule.byWeekDay![0].weekday, DateTime.sunday);
+ expect(prop.rule.byWeekDay![0].week, 1);
+ expect(prop.rule.byMonth, isNotEmpty);
+ expect(prop.rule.byMonth!.length, 1);
+ expect(prop.rule.byMonth![0], 4);
+ expect(prop.rule.until, DateTime(1998, 04, 04, 07));
+ });
+
+ test('RRULE:FREQ=UNKNOWN;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z', () {
+ try {
+ Property.parseProperty(
+ 'RRULE:FREQ=UNKNOWN;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z');
+ } on FormatException {
+ // expected
+ }
+ });
+
+ test('RRULE:BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z', () {
+ try {
+ Property.parseProperty(
+ 'RRULE:BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z');
+ fail('Invalid RECUR rule without frequency should fail');
+ } on FormatException {
+ // expected
+ }
+ });
+
+ test('ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto:jsmith@example.com',
+ () {
+ final prop = Property.parseProperty(
+ 'ATTENDEE;RSVP=TRUE;ROLE=REQ-PARTICIPANT:mailto:jsmith@example.com');
+ expect(prop, isInstanceOf());
+ expect(prop.name, 'ATTENDEE');
+ expect((prop as AttendeeProperty).rsvp, isTrue);
+ expect(prop.role, Role.requiredParticipant);
+ expect(prop.textValue, 'mailto:jsmith@example.com');
+ expect(prop.attendee, Uri.parse('mailto:jsmith@example.com'));
+ });
+ });
+
+ test('FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:19970308T160000Z/PT8H30M', () {
+ final prop = Property.parseProperty(
+ 'FREEBUSY;FBTYPE=BUSY-UNAVAILABLE:19970308T160000Z/PT8H30M');
+ expect(prop.name, 'FREEBUSY');
+ expect(prop.textValue, '19970308T160000Z/PT8H30M');
+ expect((prop as FreeBusyProperty).freeBusyType,
+ FreeBusyStatus.busyUnavailable);
+ expect(prop.periods, isNotNull);
+ expect(prop.periods, isNotEmpty);
+ expect(prop.periods.length, 1);
+ expect(prop.periods.first.startDate, DateTime(1997, 03, 08, 16, 00, 00));
+ expect(prop.periods.first.duration, IsoDuration(hours: 8, minutes: 30));
+ });
+
+ test('FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H', () {
+ final prop = Property.parseProperty(
+ 'FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H');
+ expect(prop.name, 'FREEBUSY');
+ expect(prop.textValue, '19970308T160000Z/PT3H,19970308T200000Z/PT1H');
+ expect((prop as FreeBusyProperty).freeBusyType, FreeBusyStatus.free);
+ expect(prop.periods, isNotEmpty);
+ expect(prop.periods.length, 2);
+ expect(prop.periods[0].startDate, DateTime(1997, 03, 08, 16, 00, 00));
+ expect(prop.periods[0].duration, IsoDuration(hours: 3));
+ expect(prop.periods[1].startDate, DateTime(1997, 03, 08, 20, 00, 00));
+ expect(prop.periods[1].duration, IsoDuration(hours: 1));
+ });
+
+ test(
+ 'FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H,19970308T230000Z/19970309T000000Z',
+ () {
+ final prop = Property.parseProperty(
+ 'FREEBUSY;FBTYPE=FREE:19970308T160000Z/PT3H,19970308T200000Z/PT1H,19970308T230000Z/19970309T000000Z');
+ expect(prop.name, 'FREEBUSY');
+ expect(prop.textValue,
+ '19970308T160000Z/PT3H,19970308T200000Z/PT1H,19970308T230000Z/19970309T000000Z');
+ expect((prop as FreeBusyProperty).freeBusyType, FreeBusyStatus.free);
+ expect(prop.periods, isNotEmpty);
+ expect(prop.periods.length, 3);
+ expect(prop.periods[0].startDate, DateTime(1997, 03, 08, 16, 00, 00));
+ expect(prop.periods[0].duration, IsoDuration(hours: 3));
+ expect(prop.periods[0].endDate, isNull);
+ expect(prop.periods[1].startDate, DateTime(1997, 03, 08, 20, 00, 00));
+ expect(prop.periods[1].duration, IsoDuration(hours: 1));
+ expect(prop.periods[0].endDate, isNull);
+ expect(prop.periods[2].startDate, DateTime(1997, 03, 08, 23, 00, 00));
+ expect(prop.periods[2].duration, isNull);
+ expect(prop.periods[2].endDate, DateTime(1997, 03, 09, 00, 00, 00));
+ });
+}
diff --git a/test/rrule_test.dart b/test/rrule_test.dart
new file mode 100644
index 0000000..ca0eda6
--- /dev/null
+++ b/test/rrule_test.dart
@@ -0,0 +1,496 @@
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:enough_icalendar/enough_icalendar.dart';
+
+void main() {
+ group('Valid RRULE', () {
+ test('RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.yearly);
+ expect(ruleProp.rule.byWeekDay, isNotEmpty);
+ expect(ruleProp.rule.byWeekDay!.length, 1);
+ expect(ruleProp.rule.byWeekDay![0].weekday, DateTime.sunday);
+ expect(ruleProp.rule.byWeekDay![0].week, 1);
+ expect(ruleProp.rule.byMonth, isNotEmpty);
+ expect(ruleProp.rule.byMonth!.length, 1);
+ expect(ruleProp.rule.byMonth![0], 4);
+ expect(ruleProp.rule.until, DateTime(1998, 04, 04, 07));
+ });
+
+ test('RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.yearly);
+ expect(ruleProp.rule.byWeekDay, isNotEmpty);
+ expect(ruleProp.rule.byWeekDay!.length, 1);
+ expect(ruleProp.rule.byWeekDay![0].weekday, DateTime.sunday);
+ expect(ruleProp.rule.byWeekDay![0].week, 1);
+ expect(ruleProp.rule.byMonth, isNotEmpty);
+ expect(ruleProp.rule.byMonth!.length, 1);
+ expect(ruleProp.rule.byMonth![0], 4);
+ expect(ruleProp.rule.until, DateTime(1998, 04, 04, 07));
+ });
+
+ test('RRULE:FREQ=DAILY;COUNT=10', () {
+ final ruleProp = RecurrenceRuleProperty('RRULE:FREQ=DAILY;COUNT=10');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.daily);
+ expect(ruleProp.rule.count, 10);
+ });
+
+ test('RRULE:FREQ=DAILY;UNTIL=19971224T000000Z', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=DAILY;UNTIL=19971224T000000Z');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.daily);
+ expect(ruleProp.rule.until, DateTime(1997, 12, 24, 00, 00, 00));
+ });
+
+ test('RRULE:FREQ=DAILY;INTERVAL=2', () {
+ final ruleProp = RecurrenceRuleProperty('RRULE:FREQ=DAILY;INTERVAL=2');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.daily);
+ expect(ruleProp.rule.interval, 2);
+ });
+
+ test('RRULE:FREQ=DAILY;INTERVAL=10;COUNT=5', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=DAILY;INTERVAL=10;COUNT=5');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.daily);
+ expect(ruleProp.rule.interval, 10);
+ expect(ruleProp.rule.count, 5);
+ });
+
+ test(
+ 'RRULE:FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA',
+ () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=YEARLY;UNTIL=20000131T140000Z;BYMONTH=1;BYDAY=SU,MO,TU,WE,TH,FR,SA');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.yearly);
+ expect(ruleProp.rule.until, DateTime(2000, 01, 31, 14, 00, 00));
+ expect(ruleProp.rule.byMonth, [1]);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.sunday),
+ ByDayRule(DateTime.monday),
+ ByDayRule(DateTime.tuesday),
+ ByDayRule(DateTime.wednesday),
+ ByDayRule(DateTime.thursday),
+ ByDayRule(DateTime.friday),
+ ByDayRule(DateTime.saturday),
+ ]);
+ });
+
+ test('RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=DAILY;UNTIL=20000131T140000Z;BYMONTH=1');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.daily);
+ expect(ruleProp.rule.until, DateTime(2000, 01, 31, 14, 00, 00));
+ expect(ruleProp.rule.byMonth, [1]);
+ });
+
+ test('RRULE:FREQ=WEEKLY;COUNT=10', () {
+ final ruleProp = RecurrenceRuleProperty('RRULE:FREQ=WEEKLY;COUNT=10');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.weekly);
+ expect(ruleProp.rule.count, 10);
+ });
+
+ test('RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=WEEKLY;UNTIL=19971224T000000Z');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.weekly);
+ expect(ruleProp.rule.until, DateTime(1997, 12, 24, 00, 00, 00));
+ });
+
+ test('RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=WEEKLY;INTERVAL=2;WKST=SU');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.weekly);
+ expect(ruleProp.rule.interval, 2);
+ expect(ruleProp.rule.startOfWorkWeek, DateTime.sunday);
+ });
+ test('RRULE:FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=WEEKLY;UNTIL=19971007T000000Z;WKST=SU;BYDAY=TU,TH');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.weekly);
+ expect(ruleProp.rule.until, DateTime(1997, 10, 07, 00, 00, 00));
+ expect(ruleProp.rule.startOfWorkWeek, DateTime.sunday);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.tuesday),
+ ByDayRule(DateTime.thursday),
+ ]);
+ });
+ test('RRULE:FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=TU,TH', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=WEEKLY;COUNT=10;WKST=SU;BYDAY=TU,TH');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.weekly);
+ expect(ruleProp.rule.count, 10);
+ expect(ruleProp.rule.startOfWorkWeek, DateTime.sunday);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.tuesday),
+ ByDayRule(DateTime.thursday),
+ ]);
+ });
+ test(
+ 'RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR',
+ () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=WEEKLY;INTERVAL=2;UNTIL=19971224T000000Z;WKST=SU;BYDAY=MO,WE,FR');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.weekly);
+ expect(ruleProp.rule.interval, 2);
+ expect(ruleProp.rule.until, DateTime(1997, 12, 24, 00, 00, 00));
+ expect(ruleProp.rule.startOfWorkWeek, DateTime.sunday);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.monday),
+ ByDayRule(DateTime.wednesday),
+ ByDayRule(DateTime.friday),
+ ]);
+ });
+ test('RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=8;WKST=SU;BYDAY=TU,TH');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.weekly);
+ expect(ruleProp.rule.interval, 2);
+ expect(ruleProp.rule.count, 8);
+ expect(ruleProp.rule.startOfWorkWeek, DateTime.sunday);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.tuesday),
+ ByDayRule(DateTime.thursday),
+ ]);
+ });
+ test('RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=1FR', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=MONTHLY;COUNT=10;BYDAY=1FR');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.count, 10);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.friday, week: 1),
+ ]);
+ });
+ test('RRULE:FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=MONTHLY;UNTIL=19971224T000000Z;BYDAY=1FR');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.until, DateTime(1997, 12, 24, 00, 00, 00));
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.friday, week: 1),
+ ]);
+ });
+
+ test('RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=MONTHLY;INTERVAL=2;COUNT=10;BYDAY=1SU,-1SU');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.interval, 2);
+ expect(ruleProp.rule.count, 10);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.sunday, week: 1),
+ ByDayRule(DateTime.sunday, week: -1),
+ ]);
+ });
+ test('RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=-2MO', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=MONTHLY;COUNT=6;BYDAY=-2MO');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.count, 6);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.monday, week: -2),
+ ]);
+ });
+ test('RRULE:FREQ=MONTHLY;BYMONTHDAY=-3', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=MONTHLY;BYMONTHDAY=-3');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.byMonthDay, [-3]);
+ });
+
+ test('RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=2,15');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.count, 10);
+ expect(ruleProp.rule.byMonthDay, [2, 15]);
+ });
+
+ test('RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=MONTHLY;COUNT=10;BYMONTHDAY=1,-1');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.count, 10);
+ expect(ruleProp.rule.byMonthDay, [1, -1]);
+ });
+ test('RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15',
+ () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=MONTHLY;INTERVAL=18;COUNT=10;BYMONTHDAY=10,11,12,13,14,15');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.count, 10);
+ expect(ruleProp.rule.interval, 18);
+ expect(ruleProp.rule.byMonthDay, [10, 11, 12, 13, 14, 15]);
+ });
+
+ test('RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=MONTHLY;INTERVAL=2;BYDAY=TU');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.interval, 2);
+ expect(ruleProp.rule.byWeekDay, [ByDayRule(DateTime.tuesday)]);
+ });
+
+ test('RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=6,7', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=YEARLY;COUNT=10;BYMONTH=6,7');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.yearly);
+ expect(ruleProp.rule.count, 10);
+ expect(ruleProp.rule.byMonth, [DateTime.june, DateTime.july]);
+ });
+
+ test('RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=YEARLY;INTERVAL=2;COUNT=10;BYMONTH=1,2,3');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.yearly);
+ expect(ruleProp.rule.interval, 2);
+ expect(ruleProp.rule.count, 10);
+ expect(ruleProp.rule.byMonth,
+ [DateTime.january, DateTime.february, DateTime.march]);
+ });
+
+ test('RRULE:FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=YEARLY;INTERVAL=3;COUNT=10;BYYEARDAY=1,100,200');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.yearly);
+ expect(ruleProp.rule.interval, 3);
+ expect(ruleProp.rule.count, 10);
+ expect(ruleProp.rule.byYearDay, [1, 100, 200]);
+ });
+
+ test('RRULE:FREQ=YEARLY;BYDAY=20MO', () {
+ final ruleProp = RecurrenceRuleProperty('RRULE:FREQ=YEARLY;BYDAY=20MO');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.yearly);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.monday, week: 20),
+ ]);
+ });
+
+ test('RRULE:FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=YEARLY;BYWEEKNO=20;BYDAY=MO');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.yearly);
+ expect(ruleProp.rule.byWeek, [20]);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.monday),
+ ]);
+ });
+
+ test('RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=TH', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=TH');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.yearly);
+ expect(ruleProp.rule.byWeekDay, [ByDayRule(DateTime.thursday)]);
+ expect(ruleProp.rule.byMonth, [DateTime.march]);
+ });
+
+ test('RRULE:FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=YEARLY;BYDAY=TH;BYMONTH=6,7,8');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.yearly);
+ expect(ruleProp.rule.byWeekDay, [ByDayRule(DateTime.thursday)]);
+ expect(ruleProp.rule.byMonth,
+ [DateTime.june, DateTime.july, DateTime.august]);
+ });
+
+ test('RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=MONTHLY;BYDAY=FR;BYMONTHDAY=13');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.byMonthDay, [13]);
+ expect(ruleProp.rule.byWeekDay, [ByDayRule(DateTime.friday)]);
+ });
+
+ test('RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=MONTHLY;BYDAY=SA;BYMONTHDAY=7,8,9,10,11,12,13');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.byWeekDay, [ByDayRule(DateTime.saturday)]);
+ expect(ruleProp.rule.byMonthDay, [7, 8, 9, 10, 11, 12, 13]);
+ });
+
+ test(
+ 'RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8',
+ () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=YEARLY;INTERVAL=4;BYMONTH=11;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.yearly);
+ expect(ruleProp.rule.interval, 4);
+ expect(ruleProp.rule.byMonth, [11]);
+ expect(ruleProp.rule.byWeekDay, [ByDayRule(DateTime.tuesday)]);
+ expect(ruleProp.rule.byMonthDay, [2, 3, 4, 5, 6, 7, 8]);
+ });
+
+ test('RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.count, 3);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.tuesday),
+ ByDayRule(DateTime.wednesday),
+ ByDayRule(DateTime.thursday),
+ ]);
+ expect(ruleProp.rule.bySetPos, [3]);
+ });
+
+ test('RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-2');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.monday),
+ ByDayRule(DateTime.tuesday),
+ ByDayRule(DateTime.wednesday),
+ ByDayRule(DateTime.thursday),
+ ByDayRule(DateTime.friday),
+ ]);
+ expect(ruleProp.rule.bySetPos, [-2]);
+ });
+ test('RRULE:FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T170000Z', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=HOURLY;INTERVAL=3;UNTIL=19970902T170000Z');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.hourly);
+ expect(ruleProp.rule.interval, 3);
+ expect(ruleProp.rule.until, DateTime(1997, 09, 02, 17, 00, 00));
+ });
+
+ test('RRULE:FREQ=MINUTELY;INTERVAL=15;COUNT=6', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=MINUTELY;INTERVAL=15;COUNT=6');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.minutely);
+ expect(ruleProp.rule.interval, 15);
+ expect(ruleProp.rule.count, 6);
+ });
+ test('RRULE:FREQ=MINUTELY;INTERVAL=90;COUNT=4', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=MINUTELY;INTERVAL=90;COUNT=4');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.minutely);
+ expect(ruleProp.rule.count, 4);
+ expect(ruleProp.rule.interval, 90);
+ });
+ test('RRULE:FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=DAILY;BYHOUR=9,10,11,12,13,14,15,16;BYMINUTE=0,20,40');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.daily);
+ expect(ruleProp.rule.byHour, [9, 10, 11, 12, 13, 14, 15, 16]);
+ expect(ruleProp.rule.byMinute, [0, 20, 40]);
+ });
+ test('RRULE:FREQ=MINUTELY;INTERVAL=20;BYHOUR=9,10,11,12,13,14,15,16', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=MINUTELY;INTERVAL=20;BYHOUR=9,10,11,12,13,14,15,16');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.minutely);
+ expect(ruleProp.rule.interval, 20);
+ expect(ruleProp.rule.byHour, [9, 10, 11, 12, 13, 14, 15, 16]);
+ });
+ test('RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=MO');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.weekly);
+ expect(ruleProp.rule.interval, 2);
+ expect(ruleProp.rule.count, 4);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.tuesday),
+ ByDayRule(DateTime.sunday),
+ ]);
+ expect(ruleProp.rule.startOfWorkWeek, DateTime.monday);
+ });
+ test('RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU', () {
+ final ruleProp = RecurrenceRuleProperty(
+ 'RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=4;BYDAY=TU,SU;WKST=SU');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.weekly);
+ expect(ruleProp.rule.interval, 2);
+ expect(ruleProp.rule.count, 4);
+ expect(ruleProp.rule.byWeekDay, [
+ ByDayRule(DateTime.tuesday),
+ ByDayRule(DateTime.sunday),
+ ]);
+ expect(ruleProp.rule.startOfWorkWeek, DateTime.sunday);
+ });
+ test('RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5', () {
+ final ruleProp =
+ RecurrenceRuleProperty('RRULE:FREQ=MONTHLY;BYMONTHDAY=15,30;COUNT=5');
+ expect(ruleProp.name, 'RRULE');
+ expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ expect(ruleProp.rule.count, 5);
+ expect(ruleProp.rule.byMonthDay, [15, 30]);
+ });
+
+ // test('', () {
+ // final ruleProp = RecurrenceRuleProperty('');
+ // expect(ruleProp.name, 'RRULE');
+ // expect(ruleProp.rule.frequency, RecurrenceFrequency.monthly);
+ // expect(ruleProp.rule.count, 10);
+ // expect(ruleProp.rule.interval, 18);
+ // expect(ruleProp.rule.byMonthDay, [10, 11, 12, 13, 14, 15]);
+ // });
+ });
+ group('Invalid RRULE', () {
+ test(
+ 'invalid RRULE:FREQ=UNKNOWN;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z',
+ () {
+ try {
+ RecurrenceRuleProperty(
+ 'RRULE:FREQ=UNKNOWN;BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z');
+ } on FormatException {
+ // expected
+ }
+ });
+
+ test('invalid RRULE:BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z', () {
+ try {
+ RecurrenceRuleProperty(
+ 'RRULE:BYDAY=1SU;BYMONTH=4;UNTIL=19980404T070000Z');
+ fail('Invalid RECUR rule without frequency should fail');
+ } on FormatException {
+ // expected
+ }
+ });
+ });
+}