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 + } + }); + }); +}