From 47fcca7ed2bfdc6c29f05f1373b640d3735d5eb2 Mon Sep 17 00:00:00 2001 From: Robert Virkus Date: Mon, 19 Jul 2021 18:08:38 +0200 Subject: [PATCH] improve iTIP support --- example/enough_icalendar_example.dart | 321 +++++++++++++++++++++++++- lib/src/components.dart | 65 +++++- lib/src/parameters.dart | 115 +++++++++ lib/src/properties.dart | 107 ++++++++- lib/src/types.dart | 73 ++++-- test/itip_test.dart | 93 +++----- 6 files changed, 672 insertions(+), 102 deletions(-) diff --git a/example/enough_icalendar_example.dart b/example/enough_icalendar_example.dart index 27b9080..c55bf31 100644 --- a/example/enough_icalendar_example.dart +++ b/example/enough_icalendar_example.dart @@ -1,6 +1,18 @@ import 'package:enough_icalendar/enough_icalendar.dart'; void main() { + parse(); + final invite = generate(); + changeParticipantStatus(invite); + final counterProposal = counter(invite); + acceptCounter(counterProposal); + declineCounter(counterProposal); + cancelEventForAll(invite); + cancelForAttendee(invite, 'b@example.com'); + delegate(invite); +} + +void parse() { final text = '''BEGIN:VCALENDAR VERSION:2.0 PRODID:-//hacksw/handcal//NONSGML v1.0//EN @@ -14,9 +26,13 @@ SUMMARY:Bastille Day Party GEO:48.85299;2.36885 END:VEVENT END:VCALENDAR'''; + // As text can contain different elements, + // VComponent.parse returns the generic VComponent base class. + // In this case I am sure it's a VCalendar, so I can cast it directly: final icalendar = VComponent.parse(text) as VCalendar; print(icalendar.productId); - final event = icalendar.children.first as VEvent; + // I'm sure that this calendar contains an event: + final event = icalendar.event!; 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 @@ -25,3 +41,306 @@ END:VCALENDAR'''; print(event.geoLocation?.latitude); // 48.85299 print(event.geoLocation?.longitude); // 2.36885 } + +VCalendar generate() { + // You can generate invites using this convenience method. + // Take full control by creating VCalendar, VEvent, VTodo, etc yourself. + final invite = VCalendar.createEvent( + organizerEmail: 'a@example.com', + attendeeEmails: ['a@example.com', 'b@example.com', 'c@example.com'], + rsvp: true, + start: DateTime(2021, 07, 21, 10, 00), + end: DateTime(2021, 07, 21, 11, 00), + location: 'Big meeting room', + url: Uri.parse('https://enough.de'), + summary: 'Discussion', + description: + 'Let us discuss how to proceed with the enough_icalendar development. It seems that basic functionality is now covered. What\'s next?', + productId: 'enough_icalendar/v1', + ); + print('\nGenerated invite:'); + print(invite); + return invite; + // prints this: + // + // BEGIN:VCALENDAR + // PRODID:enough_icalendar/v1 + // VERSION:2.0 + // METHOD:REQUEST + // BEGIN:VEVENT + // DTSTAMP:20210719T090527 + // UID:RQPhszGcPqYFR4fRUT@example.com + // DTSTART:20210721T100000 + // DTEND:20210721T110000 + // ORGANIZER:mailto:a@example.com + // SUMMARY:Discussion + // DESCRIPTION:Let us discuss how to proceed with the enough_icalendar deve + // lopment. It seems that basic functionality is now covered. What's next? + // LOCATION:Big meeting room + // URL:https://enough.de + // ATTENDEE;RSVP=TRUE:mailto:a@example.com + // ATTENDEE;RSVP=TRUE:mailto:b@example.com + // ATTENDEE;RSVP=TRUE:mailto:c@example.com + // END:VEVENT + // END:VCALENDAR +} + +void changeParticipantStatus(VCalendar invite) { + final reply = invite.replyWithParticipantStatus(ParticipantStatus.accepted, + attendeeEmail: 'b@example.com'); + print('\nAccepted by attendee b@example.com:'); + print(reply); + // prints this: + // + // BEGIN:VCALENDAR + // PRODID:enough_icalendar + // VERSION:2.0 + // METHOD:REPLY + // BEGIN:VEVENT + // ORGANIZER:mailto:a@example.com + // UID:RQPhszGcPqYFR4fRUT@example.com + // ATTENDEE;PARTSTAT=ACCEPTED:mailto:b@example.com + // DTSTAMP:20210719T093653 + // REQUEST-STATUS:2.0;Success + // END:VEVENT +} + +void delegate(VCalendar invite) { + final delegationResult = invite.delegate( + fromEmail: 'c@example.com', + toEmail: 'e@example.com', + ); + print('\nRequest for delegatee:'); + print(delegationResult.requestForDelegatee); + // prints this: + // + // BEGIN:VCALENDAR + // PRODID:enough_icalendar/v1 + // VERSION:2.0 + // METHOD:REQUEST + // BEGIN:VEVENT + // DTSTAMP:20210719T173821 + // UID:RQPhszGcPqYFR4fRUT@example.com + // DTSTART:20210721T100000 + // DTEND:20210721T110000 + // ORGANIZER:mailto:a@example.com + // SUMMARY:Discussion + // DESCRIPTION:Let us discuss how to proceed with the enough_icalendar deve + // lopment. It seems that basic functionality is now covered. What's next? + // LOCATION:Big meeting room + // URL:https://enough.de + // ATTENDEE;RSVP=TRUE:mailto:a@example.com + // ATTENDEE;RSVP=TRUE:mailto:b@example.com + // ATTENDEE;PARTSTAT=DELEGATED;DELEGATED-TO="mailto:e@example.com":mailto:c + // @example.com + // ATTENDEE;DELEGATED-FROM="mailto:c@example.com";RSVP=TRUE:mailto:e@exampl + // e.com + // END:VEVENT + // END:VCALENDAR + + print('\nReply for organizer:'); + print(delegationResult.replyForOrganizer); + // prints this: + // + // BEGIN:VCALENDAR + // PRODID:enough_icalendar + // VERSION:2.0 + // METHOD:REPLY + // BEGIN:VEVENT + // ORGANIZER:mailto:a@example.com + // UID:RQPhszGcPqYFR4fRUT@example.com + // ATTENDEE;PARTSTAT=DELEGATED;DELEGATED-TO="mailto:e@example.com":mailto:c + // @example.com + // DTSTAMP:20210719T173821 + // REQUEST-STATUS:2.0;Success + // END:VEVENT + // END:VCALENDAR +} + +VCalendar counter(VCalendar invite) { + final counterProposal = invite.counter( + comment: 'This time fits better, also we need some more time.', + start: DateTime(2021, 07, 23, 10, 00), + end: DateTime(2021, 07, 23, 12, 00), + location: 'Carnegie Hall', + ); + print('\nCounter proposal:'); + print(counterProposal); + return counterProposal; + // prints this: + // + // BEGIN:VCALENDAR + // PRODID:enough_icalendar/v1 + // VERSION:2.0 + // METHOD:COUNTER + // BEGIN:VEVENT + // UID:RQPhszGcPqYFR4fRUT@example.com + // ORGANIZER:mailto:a@example.com + // SUMMARY:Discussion + // DESCRIPTION:Let us discuss how to proceed with the enough_icalendar deve + // lopment. It seems that basic functionality is now covered. What's next? + // URL:https://enough.de + // ATTENDEE;RSVP=TRUE:mailto:a@example.com + // ATTENDEE;RSVP=TRUE:mailto:b@example.com + // ATTENDEE;RSVP=TRUE:mailto:c@example.com + // DTSTAMP:20210719T142550 + // COMMENT:This time fits better, also we need some more time. + // LOCATION:Carnegie Hall + // DTSTART:20210723T100000 + // DTEND:20210723T120000 + // END:VEVENT + // END:VCALENDAR +} + +void acceptCounter(VCalendar counterProposal) { + // An organizer can accept a counter proposal. + // The updated invite is then sent to all attendees. + final accepted = counterProposal.acceptCounter( + comment: 'Accepted this proposed change of date and time'); + // The accepted proposal will have a higher sequence and the status automatically be set to EventStatus.confirmed. + print('\nAccepted counter proposal:'); + print(accepted); + // prints this: + // + // BEGIN:VCALENDAR + // PRODID:enough_icalendar/v1 + // VERSION:2.0 + // METHOD:REQUEST + // BEGIN:VEVENT + // UID:RQPhszGcPqYFR4fRUT@example.com + // ORGANIZER:mailto:a@example.com + // SUMMARY:Discussion + // DESCRIPTION:Let us discuss how to proceed with the enough_icalendar deve + // lopment. It seems that basic functionality is now covered. What's next? + // URL:https://enough.de + // ATTENDEE;RSVP=TRUE:mailto:a@example.com + // ATTENDEE;RSVP=TRUE:mailto:b@example.com + // ATTENDEE;RSVP=TRUE:mailto:c@example.com + // LOCATION:Carnegie Hall + // DTSTART:20210723T100000 + // DTEND:20210723T120000 + // SEQUENCE:1 + // DTSTAMP:20210719T143344 + // STATUS:CONFIRMED + // COMMENT:Accepted this proposed change of date and time + // END:VEVENT + // END:VCALENDAR +} + +void declineCounter(VCalendar counterProposal) { + // An organizer can decline a counter proposal. + // The declined notice is then sent to the proposing attendee. + final declined = counterProposal.declineCounter( + attendeeEmail: 'b@example.com', + comment: 'Sorry, but we have to stick to the original schedule'); + print('\Declined counter proposal:'); + print(declined); + // prints this: + // + // BEGIN:VCALENDAR + // PRODID:enough_icalendar/v1 + // VERSION:2.0 + // METHOD:DECLINECOUNTER + // BEGIN:VEVENT + // UID:vmScK-AyJr0NX2nCsW@example.com + // ORGANIZER:mailto:a@example.com + // SUMMARY:Discussion + // DESCRIPTION:Let us discuss how to proceed with the enough_icalendar deve + // lopment. It seems that basic functionality is now covered. What's next? + // URL:https://enough.de + // ATTENDEE;RSVP=TRUE:mailto:b@example.com + // DTSTAMP:20210719T143715 + // LOCATION:Carnegie Hall + // DTSTART:20210723T100000 + // DTEND:20210723T120000 + // COMMENT:Sorry, but we have to stick to the original schedule + // END:VEVENT + // END:VCALENDAR +} + +void cancelEventForAll(VCalendar invite) { + // An organizer can cancel the event completely: + final cancelled = + invite.cancelEvent(comment: 'Sorry, let\'s skip this completely'); + print('\nCancelled event:'); + print(cancelled); + // prints this: + // + // METHOD:CANCEL + // BEGIN:VEVENT + // UID:vmScK-AyJr0NX2nCsW@example.com + // DTSTART:20210721T100000 + // DTEND:20210721T110000 + // ORGANIZER:mailto:a@example.com + // SUMMARY:Discussion + // DESCRIPTION:Let us discuss how to proceed with the enough_icalendar deve + // lopment. It seems that basic functionality is now covered. What's next? + // LOCATION:Big meeting room + // URL:https://enough.de + // ATTENDEE;RSVP=TRUE:mailto:a@example.com + // ATTENDEE;RSVP=TRUE:mailto:b@example.com + // ATTENDEE;RSVP=TRUE:mailto:c@example.com + // SEQUENCE:1 + // DTSTAMP:20210719T145004 + // STATUS:CANCELLED + // COMMENT:Sorry, let's skip this completely + // END:VEVENT + // END:VCALENDAR +} + +void cancelForAttendee(VCalendar invite, String cancelledAttendeeEmail) { + // An organizer can cancel an event for specific attendees: + final cancelChanges = invite.cancelEventForAttendees( + cancelledAttendeeEmails: [cancelledAttendeeEmail], + comment: 'You\'re off the hook, enjoy!', + ); + print('\nChanges for cancelled attendees:'); + print(cancelChanges.requestForCancelledAttendees); + // prints the following: + // + // BEGIN:VCALENDAR + // PRODID:enough_icalendar/v1 + // VERSION:2.0 + // METHOD:CANCEL + // BEGIN:VEVENT + // UID:vmScK-AyJr0NX2nCsW@example.com + // DTSTART:20210721T100000 + // DTEND:20210721T110000 + // ORGANIZER:mailto:a@example.com + // SUMMARY:Discussion + // DESCRIPTION:Let us discuss how to proceed with the enough_icalendar deve + // lopment. It seems that basic functionality is now covered. What's next? + // LOCATION:Big meeting room + // URL:https://enough.de + // ATTENDEE;RSVP=TRUE:mailto:b@example.com + // SEQUENCE:1 + // DTSTAMP:20210719T162910 + // COMMENT:You're off the hook, enjoy! + // END:VEVENT + // END:VCALENDAR + + print('\nChanges for the group:'); + print(cancelChanges.requestUpdateForGroup); + + // prints the following: + // + // BEGIN:VCALENDAR + // PRODID:enough_icalendar/v1 + // VERSION:2.0 + // BEGIN:VEVENT + // DTSTAMP:20210719T162910 + // UID:vmScK-AyJr0NX2nCsW@example.com + // DTSTART:20210721T100000 + // DTEND:20210721T110000 + // ORGANIZER:mailto:a@example.com + // SUMMARY:Discussion + // DESCRIPTION:Let us discuss how to proceed with the enough_icalendar deve + // lopment. It seems that basic functionality is now covered. What's next? + // LOCATION:Big meeting room + // URL:https://enough.de + // ATTENDEE;RSVP=TRUE:mailto:a@example.com + // ATTENDEE;RSVP=FALSE;ROLE=NON-PARTICIPANT:mailto:b@example.com + // ATTENDEE;RSVP=TRUE:mailto:c@example.com + // END:VEVENT + // END:VCALENDAR +} diff --git a/lib/src/components.dart b/lib/src/components.dart index 0490108..3529a2a 100644 --- a/lib/src/components.dart +++ b/lib/src/components.dart @@ -470,7 +470,7 @@ class VCalendar extends VComponent { /// Organizers of an calendar event can cancel an event for attendees. /// You must either specify [cancelledAttendees] or [cancelledAttendeeEmails]. /// Compare [cancelEvent] in case you want to cancel the whole event - VCalendar cancelEventForAttendees( + AttendeeCancelResult cancelEventForAttendees( {List? cancelledAttendees, List? cancelledAttendeeEmails, String? comment}) { @@ -478,13 +478,27 @@ class VCalendar extends VComponent { 'You must specify either cancelledAttendees or cancelledAttendeeEmails'); assert(!(cancelledAttendeeEmails != null && cancelledAttendees != null), 'You must specify either cancelledAttendees or cancelledAttendeeEmails, but not both'); - return update( + final attendeeChange = update( method: Method.cancel, comment: comment, attendeeFilter: (attendee) => cancelledAttendeeEmails != null ? cancelledAttendeeEmails.contains(attendee.email) : cancelledAttendees!.any((a) => a.uri == attendee.uri), ); + final groupChange = copy() as VCalendar; + final event = groupChange.event; + if (event != null) { + final attendees = cancelledAttendeeEmails != null + ? event.attendees.where( + (attendee) => cancelledAttendeeEmails.contains(attendee.email)) + : event.attendees.where((attendee) => cancelledAttendees! + .any((cancelled) => cancelled.uri == attendee.uri)); + for (final attendee in attendees) { + attendee.rsvp = false; + attendee.role = Role.nonParticpant; + } + } + return AttendeeCancelResult(attendeeChange, groupChange); } /// Prepares an update of this calendar. @@ -563,6 +577,8 @@ class VCalendar extends VComponent { /// Any other changes have to be done directly on the children of the returned VCalendar. /// Any attendee can propose a counter, for example with different time, location or attendees. /// The [method] is set to [Method.counter], the [VEvent.sequence] stays the same, but the [VEvent.timeStamp] is updated. + /// + /// Compare [declineCounter] for organizers to decline a counter proposal. VCalendar counter({ String? comment, DateTime? start, @@ -603,8 +619,10 @@ class VCalendar extends VComponent { /// /// An organizer can decline the counter proposal and optionally provide the reasoning in the [comment]. /// When either the [attendee] or [attendeeEmail] is specified, only that attendee will be kept. - /// When the current [method] must be [Method.counter]. - /// The [sequence] stays the same. + /// This calendar's [method] must be [Method.counter]. + /// The [VEvent.sequence] stays the same. + /// + /// Compare [counter] for attendees to create a counter proposal. VCalendar declineCounter( {AttendeeProperty? attendee, String? attendeeEmail, String? comment}) { assert(method == Method.counter, @@ -629,11 +647,31 @@ class VCalendar extends VComponent { return copied; } + /// Accepts a counter proposal. + /// + /// An organizer can accept the counter proposal and optionally provide the reasoning in the [comment]. + /// The current [method] must be [Method.counter]. + /// The [VEvent.sequence] stays the same. + /// + /// Compare [counter] for attendees to create a counter proposal. + /// Compare [declineCounter] for organizers to decline a counter proposal. + VCalendar acceptCounter({String? comment, String? description}) { + assert(method == Method.counter, + 'The current method is not Method.counter but instead $method. Only counter proposals can be accepted with acceptCounter.'); + + return update( + method: Method.request, + eventStatus: EventStatus.confirmed, + comment: comment, + description: description, + ); + } + /// Delegates this calendar from the user with [fromEmail] to the user [toEmail] / [to]. /// /// The optional parameters [rsvp] and [toStatus] are ignored when [to] is specified. /// Optionally explain the reason in the [comment]. - VCalendar delegate({ + AttendeeDelegatedResult delegate({ required String fromEmail, String? toEmail, AttendeeProperty? to, @@ -643,10 +681,10 @@ class VCalendar extends VComponent { }) { assert(!(toEmail == null && to == null), 'Either to or toEmail must be specified.'); - final copied = copy() as VCalendar; - copied.method = Method.request; + final forDelegatee = copy() as VCalendar; + forDelegatee.method = Method.request; final event = - copied.children.firstWhereOrNull((ev) => ev is VEvent) as VEvent?; + forDelegatee.children.firstWhereOrNull((ev) => ev is VEvent) as VEvent?; if (event != null) { if (comment != null) { event.comment = comment; @@ -669,7 +707,12 @@ class VCalendar extends VComponent { event.removeAttendeeWithUri(to.uri); event.addAttendee(to); } - return copied; + final forOrganizer = replyWithParticipantStatus( + ParticipantStatus.delegated, + attendeeEmail: fromEmail, + delegatedToEmail: to!.email, + ); + return AttendeeDelegatedResult(forDelegatee, forOrganizer); } @override @@ -695,6 +738,7 @@ class VCalendar extends VComponent { String? timezoneId, DateTime? timeStamp, String? uid, + Method method = Method.request, }) { assert(organizer != null || organizerEmail != null, 'Either organizer or organizerEmail needs to be specified.'); @@ -706,7 +750,8 @@ class VCalendar extends VComponent { ..calendarScale = calendarScale ..productId = productId ..version = '2.0' - ..timezoneId = timezoneId; + ..timezoneId = timezoneId + ..method = method; final event = VEvent(parent: calendar); calendar.children.add(event); organizer ??= OrganizerProperty.create(email: organizerEmail); diff --git a/lib/src/parameters.dart b/lib/src/parameters.dart index 9669fdd..f2074f2 100644 --- a/lib/src/parameters.dart +++ b/lib/src/parameters.dart @@ -105,6 +105,13 @@ class UriParameter extends Parameter { } return Uri.parse(textValue); } + + static UriParameter? create(ParameterType type, Uri? value) { + if (value == null) { + return null; + } + return UriParameter.value(type, value); + } } /// Parameter or value that contains one or several URIs like `MEMBER` @@ -115,6 +122,10 @@ class UriListParameter extends Parameter> { UriListParameter(ParameterType type, String name, String textValue) : super(type, name, textValue, ValueType.typeUriList, parse(textValue)); + UriListParameter.value(ParameterType type, List value) + : super( + type, type.name!, renderUris(value), ValueType.typeUriList, value); + static List parse(final String textValue) { final runes = textValue.runes.toList(); final result = []; @@ -153,6 +164,17 @@ class UriListParameter extends Parameter> { } return result; } + + static String renderUris(List uris) { + return uris.map((uri) => '"$uri"').join(','); + } + + static UriListParameter? create(ParameterType type, List? value) { + if (value == null) { + return null; + } + return UriListParameter.value(type, value); + } } /// Parameter containing text @@ -171,6 +193,13 @@ class TextParameter extends Parameter { } return textValue; } + + static TextParameter? create(ParameterType type, String? value) { + if (value == null) { + return null; + } + return TextParameter.value(type, value); + } } /// Parameter containing boolean values @@ -186,6 +215,13 @@ class BooleanParameter extends Parameter { static bool parse(String textValue) { return (textValue == 'TRUE' || textValue == 'YES'); } + + static BooleanParameter? create(ParameterType type, bool? value) { + if (value == null) { + return null; + } + return BooleanParameter.value(type, value); + } } /// Parameter defining the type of calendar user @@ -216,6 +252,13 @@ class CalendarUserTypeParameter extends Parameter { return CalendarUserType.other; } } + + static CalendarUserTypeParameter? create(CalendarUserType? value) { + if (value == null) { + return null; + } + return CalendarUserTypeParameter.value(value); + } } /// Parameter defining the status of a free busy property @@ -224,6 +267,13 @@ class FreeBusyTimeTypeParameter extends Parameter { FreeBusyTimeTypeParameter(ParameterType type, String name, String textValue) : super(type, name, textValue, ValueType.typeFreeBusy, parse(textValue)); + FreeBusyTimeTypeParameter.value(FreeBusyTimeType value) + : super( + ParameterType.freeBusyTimeType, + ParameterType.freeBusyTimeType.name!, + value.name!, + ValueType.typeFreeBusy, + value); static FreeBusyTimeType parse(String textValue) { switch (textValue) { @@ -239,6 +289,13 @@ class FreeBusyTimeTypeParameter extends Parameter { return FreeBusyTimeType.other; } } + + static FreeBusyTimeTypeParameter? create(FreeBusyTimeType? value) { + if (value == null) { + return null; + } + return FreeBusyTimeTypeParameter.value(value); + } } /// Parameter definining the participant status @@ -278,6 +335,13 @@ class ParticipantStatusParameter extends Parameter { return ParticipantStatus.other; } } + + static ParticipantStatusParameter? create(ParticipantStatus? value) { + if (value == null) { + return null; + } + return ParticipantStatusParameter.value(value); + } } /// Parameter defining the range of a change @@ -299,6 +363,9 @@ class RangeParameter extends Parameter { RangeParameter(ParameterType type, String name, String textValue) : super(type, name, textValue, ValueType.typeRange, parse(textValue)); + RangeParameter.value(Range range) + : super(ParameterType.range, ParameterType.range.name!, range.name, + ValueType.typeRange, range); static Range parse(String textValue) { switch (textValue) { @@ -308,6 +375,13 @@ class RangeParameter extends Parameter { throw FormatException('Invalid range: [$textValue]'); } } + + static RangeParameter? create(Range? value) { + if (value == null) { + return null; + } + return RangeParameter.value(value); + } } /// Specifies the relationship of the alarm trigger with respect to the start or end of the calendar component. @@ -338,6 +412,14 @@ class AlarmTriggerRelationshipParameter } throw FormatException('Invalid RELATED content [$textValue]'); } + + static AlarmTriggerRelationshipParameter? create( + AlarmTriggerRelationship? value) { + if (value == null) { + return null; + } + return AlarmTriggerRelationshipParameter.value(value); + } } /// Defines the relationship of the parameter's property @@ -348,6 +430,14 @@ class RelationshipParameter extends Parameter { : super(type, name, textValue, ValueType.typeRelationship, parse(textValue)); + RelationshipParameter.value(Relationship relationship) + : super( + ParameterType.relationshipType, + ParameterType.relationshipType.name!, + relationship.name!, + ValueType.typeRelationship, + relationship); + static Relationship parse(String textValue) { switch (textValue) { case 'PARENT': @@ -360,6 +450,13 @@ class RelationshipParameter extends Parameter { return Relationship.other; } } + + static RelationshipParameter? create(Relationship? value) { + if (value == null) { + return null; + } + return RelationshipParameter.value(value); + } } /// Defines the role of a given user @@ -391,6 +488,13 @@ class ParticipantRoleParameter extends Parameter { return Role.other; } } + + static ParticipantRoleParameter? create(Role? value) { + if (value == null) { + return null; + } + return ParticipantRoleParameter.value(value); + } } /// Defines the value type of the corresponding property. @@ -402,6 +506,10 @@ class ValueParameter extends Parameter { ValueParameter(ParameterType type, String name, String textValue) : super(type, name, textValue, ValueType.typeValue, parse(textValue)); + ValueParameter.value(ValueType type) + : super(ParameterType.value, ParameterType.value.name!, type.name!, + ValueType.typeValue, type); + static ValueType parse(String content) { switch (content) { case 'BINARY': @@ -436,6 +544,13 @@ class ValueParameter extends Parameter { return ValueType.other; } } + + static ValueParameter? create(ValueType? value) { + if (value == null) { + return null; + } + return ValueParameter.value(value); + } } /// Common parameter types diff --git a/lib/src/properties.dart b/lib/src/properties.dart index 49b910e..ff1a02b 100644 --- a/lib/src/properties.dart +++ b/lib/src/properties.dart @@ -42,6 +42,14 @@ class Property { void setParameter(Parameter value) => parameters[value.name] = value; + void setOrRemoveParameter(ParameterType type, Parameter? value) { + if (value == null) { + parameters.remove(type.name); + } else { + parameters[type.name!] = value; + } + } + T? getParameterValue(ParameterType param) => parameters[param.name]?.value as T?; @@ -409,13 +417,30 @@ class UserProperty extends UriProperty { static const String propertyNameContact = 'CONTACT'; UserProperty(String definition) : super(definition); + /// Gets the common name associated with this calendar user String? get commonName => getParameterValue(ParameterType.commonName); + /// Sets the common name associated with this calendar user + set commonName(String? value) => setOrRemoveParameter( + ParameterType.commonName, + TextParameter.create(ParameterType.commonName, value)); + + /// Gets the directory link, for example an LDAP URI Uri? get directory => getParameterValue(ParameterType.directory); + /// Sets the directory + set directory(Uri? value) => setOrRemoveParameter(ParameterType.directory, + UriParameter.create(ParameterType.directory, value)); + + /// Gets the alternative representation, e.g. a link to a VCARD Uri? get alternateRepresentation => getParameterValue(ParameterType.alternateRepresentation); + /// Sets the alternative representation + set alternateRepresentation(Uri? value) => setOrRemoveParameter( + ParameterType.alternateRepresentation, + UriParameter.create(ParameterType.alternateRepresentation, value)); + /// Retrieves the type of the user. /// /// Defaults to [CalendarUserType.unknown]. @@ -423,6 +448,11 @@ class UserProperty extends UriProperty { getParameterValue(ParameterType.calendarUserType) ?? CalendarUserType.unknown; + /// Set the type of the user + set userType(CalendarUserType? value) => setOrRemoveParameter( + ParameterType.calendarUserType, CalendarUserTypeParameter.create(value)); + + /// Retrieve the email from this value String? get email => uri.isScheme('MAILTO') ? uri.path : null; } @@ -438,20 +468,37 @@ class AttendeeProperty extends UserProperty { /// Stands for "Répondez s'il vous plaît", meaning "Please respond". bool get rsvp => getParameterValue(ParameterType.rsvp) ?? false; + /// Sets the rsvp request value + set rsvp(bool? value) => setOrRemoveParameter( + ParameterType.rsvp, BooleanParameter.create(ParameterType.rsvp, value)); + /// Gets the role of this participant, defaults to [Role.requiredParticipant] Role get role => getParameterValue(ParameterType.participantRole) ?? Role.requiredParticipant; + /// Sets the role of this participant + set role(Role? value) => setOrRemoveParameter( + ParameterType.participantRole, ParticipantRoleParameter.create(value)); + /// Gets the participant status of this attendee /// /// The possible values depend on the type of the component. ParticipantStatus? get participantStatus => getParameterValue(ParameterType.participantStatus); + /// Sets the participant status + set participantStatus(ParticipantStatus? value) => setOrRemoveParameter( + ParameterType.participantStatus, + ParticipantStatusParameter.create(value)); + /// Retrieves the URI of the the user that this attendee has delegated the event or task to Uri? get delegatedTo => getParameterValue(ParameterType.delegateTo); + /// Sets the delegatedTo URI + set delegatedTo(Uri? value) => setOrRemoveParameter(ParameterType.delegateTo, + UriParameter.create(ParameterType.delegateTo, value)); + /// Retrieves the email of the the user that this attendee has delegated the event or task to String? get delegatedToEmail { final uri = delegatedTo; @@ -461,9 +508,18 @@ class AttendeeProperty extends UserProperty { return uri.path; } + /// Sets the delegatedToEmail, will generate a delegatedToUri + set delegatedToEmail(String? value) => + delegatedTo = value == null ? null : Uri.parse('mailto:$value'); + /// Retrieves the URI of the the user that this attendee has delegated the event or task from Uri? get delegatedFrom => getParameterValue(ParameterType.delegateFrom); + /// Sets the delegatedFrom URI + set delegatedFrom(Uri? value) => setOrRemoveParameter( + ParameterType.delegateFrom, + UriParameter.create(ParameterType.delegateTo, value)); + /// Retrieves the email of the the user that this attendee has delegated the event or task from String? get delegatedFromEmail { final uri = delegatedFrom; @@ -473,6 +529,10 @@ class AttendeeProperty extends UserProperty { return uri.path; } + /// Sets the delegatedFromEmail, will generate a delegatedFromUri + set delegatedFromEmail(String? value) => + delegatedFrom = value == null ? null : Uri.parse('mailto:$value'); + AttendeeProperty(String definition) : super(definition); /// Creates an attendee with the specified [attendeeUri] or [attendeeEmail]. @@ -548,8 +608,13 @@ class OrganizerProperty extends UserProperty { static const String propertyName = 'ORGANIZER'; Uri get organizer => uri; + /// Gets the sender of this organizer Uri? get sentBy => getParameterValue(ParameterType.sentBy); + /// Sets the sender of this organizer + set sentBy(Uri? value) => setOrRemoveParameter( + ParameterType.sentBy, UriParameter.create(ParameterType.sentBy, value)); + OrganizerProperty(String definition) : super(definition); static OrganizerProperty? create({ @@ -625,11 +690,19 @@ class AttachmentProperty extends Property { /// Retrieves the mime type / media type / format type like `image/png` as specified in the `FMTTYPE` parameter. String? get mediaType => getParameterValue(ParameterType.formatType); + /// Sets the media type + set mediaType(String? value) => setOrRemoveParameter(ParameterType.formatType, + TextParameter.create(ParameterType.formatType, value)); + /// Retrieves the encoding such as `BASE64`, only relevant when the content is binary /// /// Compare [isBinary] String? get encoding => getParameterValue(ParameterType.encoding); + /// Sets the encoding + set encoding(String? value) => setOrRemoveParameter(ParameterType.encoding, + TextParameter.create(ParameterType.encoding, value)); + /// Checks if this contains binary data /// /// Compare [binary] @@ -714,10 +787,9 @@ class ClassificationProperty extends Property { class TextProperty extends Property { /// `COMMENT` static const String propertyNameComment = 'COMMENT'; - static const - /// `DESCRIPTION` - String propertyNameDescription = 'DESCRIPTION'; + /// `DESCRIPTION` + static const String propertyNameDescription = 'DESCRIPTION'; /// `PRODID` static const String propertyNameProductIdentifier = 'PRODID'; @@ -746,10 +818,22 @@ class TextProperty extends Property { /// `RELATED-TO` static const String propertyNameRelatedTo = 'RELATED-TO'; + /// Retrieve the language String? get language => this[ParameterType.language]?.textValue; + + /// Sets the language + set language(String? value) => setOrRemoveParameter(ParameterType.language, + TextParameter.create(ParameterType.language, value)); + + /// Gets a link to an alternative representation Uri? get alternateRepresentation => (this[ParameterType.alternateRepresentation] as UriParameter?)?.uri; + /// Sets a link to an alternative representation + set alternateRepresentation(Uri? value) => setOrRemoveParameter( + ParameterType.alternateRepresentation, + UriParameter.create(ParameterType.alternateRepresentation, value)); + String get text => value as String; TextProperty(String definition) : super(definition, ValueType.text); @@ -866,6 +950,11 @@ class DateTimeProperty extends Property { /// Retrieves the timezone ID like `America/New_York` or `Europe/Berlin` from the `TZID` parameter. String? get timezoneId => this[ParameterType.timezoneId]?.textValue; + /// Set the timezone ID + set timezoneId(String? value) => setOrRemoveParameter( + ParameterType.timezoneId, + TextParameter.create(ParameterType.timezoneId, value)); + DateTimeProperty(String definition) : super(definition, ValueType.dateTime); static DateTimeProperty? create(String name, DateTime? value, @@ -930,10 +1019,15 @@ class FreeBusyProperty extends Property { /// `FREEBUSY` static const String propertyName = 'FREEBUSY'; + /// Gets the type, defaults to [FreeBusyTimeType.busy] FreeBusyTimeType get freeBusyType => getParameterValue(ParameterType.freeBusyTimeType) ?? FreeBusyTimeType.busy; + /// Sets the type + set freeBusyType(FreeBusyTimeType? value) => setOrRemoveParameter( + ParameterType.freeBusyTimeType, FreeBusyTimeTypeParameter.create(value)); + List get periods => value as List; FreeBusyProperty(String definition) : super(definition, ValueType.periodList); @@ -1103,11 +1197,18 @@ class TriggerProperty extends Property { IsoDuration? get duration => value is IsoDuration ? value : null; DateTime? get dateTime => value is DateTime ? value : null; + + /// Does the trigger relate to the start or the end of the enclosing VEvent? AlarmTriggerRelationship get triggerRelation => getParameterValue( ParameterType.alarmTriggerRelationship) ?? AlarmTriggerRelationship.start; + /// Sets the trigger relation + set triggerRelation(AlarmTriggerRelationship? value) => setOrRemoveParameter( + ParameterType.alarmTriggerRelationship, + AlarmTriggerRelationshipParameter.create(value)); + TriggerProperty(String definition) : super(definition, ValueType.duration); static TriggerProperty? createWithDateTime(DateTime? value, diff --git a/lib/src/types.dart b/lib/src/types.dart index f778d6a..318aabc 100644 --- a/lib/src/types.dart +++ b/lib/src/types.dart @@ -1,3 +1,5 @@ +import 'package:enough_icalendar/enough_icalendar.dart'; + /// To explicitly specify the value type format for a property value. enum ValueType { binary, @@ -1583,30 +1585,57 @@ extension ExtensionTimeTransparency on TimeTransparency { } } -/// Possible responses to an [VCalendar] or [VComponent] object. +// /// Possible responses to an [VCalendar] or [VComponent] object. +// /// +// /// The available responses depend on the used method of the component and the type of the recipient user, ie is the current user the organizator or not +// enum ResponseOption { +// /// The participant can change the status +// /// +// /// Compare [ParticipantStatus], [VCalendar.replyWithParticipantStatus] +// changeParticipantStatus, + +// /// The participant can propose a counter event/task, e.g. with a different time slot +// /// +// /// Compare [VCalendar.counter] +// counter, + +// /// The organizer can decline a counter proposal +// /// +// /// Compare [VCalendar.counterDecline] +// declineCounter, + +// /// The organizer can acccess a counter proposal +// /// +// /// Compare [VCalendar.counterAccept] +// acceptCounter, + +// /// Update the existing component +// update, +// } + +/// Wraps changes that are to be sent to both the attendee(s) as well as the group. /// -/// The available responses depend on the used method of the component and the type of the recipient user, ie is the current user the organizator or not -enum ResponseOption { - /// The participant can change the status - /// - /// Compare [ParticipantStatus], [VCalendar.replyWithParticipantStatus] - changeParticipantStatus, - - /// The participant can propose a counter event/task, e.g. with a different time slot - /// - /// Compare [VCalendar.counter] - counter, +/// Example for this is to cancel an event for one or several attendees. +/// Compare [VCalendar.cancelEventForAttendees] +class AttendeeCancelResult { + /// The request / info for attendees for which the participation was cancelled: + final VCalendar requestForCancelledAttendees; + // The request / update into for the remaining attendees: + final VCalendar requestUpdateForGroup; + + const AttendeeCancelResult( + this.requestForCancelledAttendees, this.requestUpdateForGroup); +} - /// The organizer can decline a counter proposal - /// - /// Compare [VCalendar.counterDecline] - declineCounter, +/// Wraps delegation requests +/// +/// Compare [VCalendar.delegate] +class AttendeeDelegatedResult { + /// The request for the attendee to which the particaption should be delegated: + final VCalendar requestForDelegatee; - /// The organizer can acccess a counter proposal - /// - /// Compare [VCalendar.counterAccept] - acceptCounter, + /// The reply with the information for the organizer + final VCalendar replyForOrganizer; - /// Update the existing component - update, + AttendeeDelegatedResult(this.requestForDelegatee, this.replyForOrganizer); } diff --git a/test/itip_test.dart b/test/itip_test.dart index 3680368..7673d10 100644 --- a/test/itip_test.dart +++ b/test/itip_test.dart @@ -174,19 +174,17 @@ END:VCALENDAR // "A" accepts the changes from "B". To accept a counter proposal, the // "Organizer" sends a new event "REQUEST" with an incremented sequence // number. - final accepted = iCalendar.update(method: Method.request); - expect(accepted.children, isNotEmpty); - event = accepted.children.first; - expect(event, isInstanceOf()); - (event as VEvent).status = EventStatus.confirmed; - event.description = - 'Discuss the Merits of the election results - changed to meet B\'s schedule'; - + final accepted = iCalendar.acceptCounter( + description: + 'Discuss the Merits of the election results - changed to meet B\'s schedule'); + event = accepted.event!; expect(accepted.method, Method.request); expect(event.sequence, 1); expect(event.status, EventStatus.confirmed); expect(event.attendees, isNotEmpty); expect(event.attendees.length, 3); + expect(event.description, + 'Discuss the Merits of the election results - changed to meet B\'s schedule'); // Instead, "A" rejects "B's" counter proposal. final declined = iCalendar.declineCounter( @@ -229,12 +227,12 @@ END:VCALENDAR final original = VComponent.parse(input) as VCalendar; // "C" delegates the event to "E": // "C" responds to the "Organizer" "A": - final delegationReply = original.replyWithParticipantStatus( - ParticipantStatus.delegated, - attendeeEmail: 'c@example.com', - delegatedToEmail: 'e@example.com', + final delegationResult = original.delegate( + fromEmail: 'c@example.com', + toEmail: 'e@example.com', ); + final delegationReply = delegationResult.replyForOrganizer; expect(delegationReply.method, Method.reply); var event = delegationReply.children.first; expect(event, isInstanceOf()); @@ -249,10 +247,7 @@ END:VCALENDAR // other properties are covered by components_test.dart // "Attendee" "C" delegates presence at the meeting to "E". - final delegated = original.delegate( - fromEmail: 'c@example.com', - toEmail: 'e@example.com', - ); + final delegated = delegationResult.requestForDelegatee; expect(delegated.method, Method.request); event = delegated.children.first; expect(event, isInstanceOf()); @@ -439,24 +434,11 @@ END:VCALENDAR // example below, the "STATUS" property is omitted. This is used when // the meeting itself is cancelled and not when the intent is to remove // an "Attendee" from the event. -// var input = '''BEGIN:VCALENDAR -// PRODID:-//Example/ExampleCalendarClient//EN -// METHOD:CANCEL -// VERSION:2.0 -// BEGIN:VEVENT -// ORGANIZER:mailto:a@example.com -// ATTENDEE:mailto:b@example.com -// COMMENT: -// UID:calsrv.example.com-873970198738777@example.com -// DTSTAMP:19970613T193000Z -// SEQUENCE:1 -// END:VEVENT -// END:VCALENDAR -// '''; final input = '''BEGIN:VCALENDAR PRODID:-//Example/ExampleCalendarClient//EN VERSION:2.0 +METHOD:REQUEST BEGIN:VEVENT ORGANIZER:mailto:a@example.com ATTENDEE;CUTYPE=INDIVIDUAL:mailto:a@example.com @@ -469,10 +451,11 @@ END:VEVENT END:VCALENDAR '''; final original = VComponent.parse(input) as VCalendar; - final cancelled = original.cancelEventForAttendees( + final cancelledChanges = original.cancelEventForAttendees( cancelledAttendeeEmails: ['b@example.com', 'd@example.com'], comment: 'You\'re off the hook for this meeting', ); + final cancelled = cancelledChanges.requestForCancelledAttendees; expect(cancelled.method, Method.cancel); var event = cancelled.children.first; expect(event, isInstanceOf()); @@ -484,42 +467,20 @@ END:VCALENDAR expect(event.attendees[1].email, 'd@example.com'); // other properties are covered by components_test.dart - // The updated master copy of the event is shown below. The "Organizer" + // The "Organizer" // MAY resend the updated event to the remaining "Attendees". Note that - // "B" has been removed. -//TODO should cancelEventForAttendees also create the updated event for the remaining participants? -// input = '''BEGIN:VCALENDAR -// PRODID:-//Example/ExampleCalendarClient//EN -// METHOD:REQUEST -// VERSION:2.0 -// BEGIN:VEVENT -// ORGANIZER:mailto:a@example.com -// ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com -// ATTENDEE;CUTYPE=INDIVIDUAL:mailto:c@example.com -// ATTENDEE;CUTYPE=INDIVIDUAL:mailto:d@example.com -// ATTENDEE;CUTYPE=ROOM:mailto:cr_big@example.com -// ATTENDEE;ROLE=NON-PARTICIPANT; -// RSVP=FALSE:mailto:e@example.com -// DTSTAMP:19970611T190000Z -// DTSTART:19970701T200000Z -// DTEND:19970701T203000Z -// SUMMARY:Phone Conference -// UID:calsrv.example.com-873970198738777@example.com -// SEQUENCE:2 -// STATUS:CONFIRMED -// END:VEVENT -// END:VCALENDAR -// '''; -// iCalendar = VComponent.parse(input); -// expect(iCalendar, isInstanceOf()); -// expect((iCalendar as VCalendar).method, Method.request); -// event = iCalendar.children.first; -// expect(event, isInstanceOf()); -// expect((event as VEvent).sequence, 2); -// expect(event.attendees.length, 5); -// expect(event.attendees[4].role, Role.nonParticpant); -// expect(event.attendees[4].rsvp, isFalse); -// expect(event.attendees[4].email, 'e@example.com'); + // "B" and "D" have been removed. + final updatedInvite = cancelledChanges.requestUpdateForGroup; + expect(updatedInvite.method, Method.request); + event = updatedInvite.event!; + expect(event.sequence, isNull); + expect(event.attendees.length, 4); + expect(event.attendees[1].role, Role.nonParticpant); + expect(event.attendees[1].rsvp, isFalse); + expect(event.attendees[1].email, 'b@example.com'); + expect(event.attendees[3].role, Role.nonParticpant); + expect(event.attendees[3].rsvp, isFalse); + expect(event.attendees[3].email, 'd@example.com'); }); test('Replacing the Organizer', () {