diff --git a/lib/src/semantic/calendar_enums.dart b/lib/src/semantic/calendar_enums.dart index 2c4f337..20ac0d0 100644 --- a/lib/src/semantic/calendar_enums.dart +++ b/lib/src/semantic/calendar_enums.dart @@ -596,6 +596,42 @@ extension TodoStatusExtensions on TodoStatus { }; } +/// Trigger relation types. +enum TriggerRelated { start, end } + +/// Names for [TriggerRelated] enum values +class TriggerRelatedNames { + /// The private constructor to prevent instantiation. + TriggerRelatedNames._(); + + /// The name for trigger relation to START. + static const start = 'START'; + + /// The name for trigger relation to END. + static const end = 'END'; + + /// Tries to parse the given string into a TriggerRelated enum value. + static TriggerRelated? tryParse(String s) { + switch (s.toUpperCase()) { + case start: + return TriggerRelated.start; + case end: + return TriggerRelated.end; + default: + return null; + } + } +} + +/// Extensions for [TriggerRelated] enum. +extension TriggerRelatedExtensions on TriggerRelated { + /// Converts the enum value to its corresponding string name. + String toName() => switch (this) { + TriggerRelated.start => TriggerRelatedNames.start, + TriggerRelated.end => TriggerRelatedNames.end, + }; +} + /// Value types. enum ValueType { binary, diff --git a/lib/src/semantic/calendar_types.dart b/lib/src/semantic/calendar_types.dart index 36a512f..9e38983 100644 --- a/lib/src/semantic/calendar_types.dart +++ b/lib/src/semantic/calendar_types.dart @@ -836,11 +836,21 @@ class Trigger { /// The date-time value, if specified. final CalDateTime? dateTime; + /// The RELATED parameter as enum value, if explicitly set. + final TriggerRelated? related; + + /// The RELATED parameter as raw string, if explicitly set. + final String? relatedName; + /// Creates a Trigger with a DURATION. - const Trigger.duration(CalDuration this.duration) : dateTime = null; + const Trigger.duration( + CalDuration this.duration, { + this.related, + this.relatedName, + }) : dateTime = null; /// Creates a Trigger with a UTC DATE-TIME. - Trigger.dateTime(CalDateTime this.dateTime) + Trigger.dateTime(CalDateTime this.dateTime, {this.related, this.relatedName}) : duration = null, assert(dateTime.isDateTime, 'time part must be present'), assert(dateTime.time!.isUtc); @@ -857,12 +867,14 @@ class Trigger { return other is Trigger && other.duration == duration && - other.dateTime == dateTime; + other.dateTime == dateTime && + other.related == related && + other.relatedName == relatedName; } @override int get hashCode { - return Object.hash(duration, dateTime); + return Object.hash(duration, dateTime, related, relatedName); } @override diff --git a/lib/src/semantic/property_parsers.dart b/lib/src/semantic/property_parsers.dart index fd6fcf0..c6e2668 100644 --- a/lib/src/semantic/property_parsers.dart +++ b/lib/src/semantic/property_parsers.dart @@ -339,14 +339,42 @@ CalTime parseCalTimeValue(String value, {String? tzid, int lineNumber = 0}) { /// Parses a [CalendarProperty] into a trigger value. Trigger parseTrigger(CalendarProperty property) { + final relatedName = _tryParseSingleParameterValue( + property, + parameterName: 'RELATED', + ); + final related = relatedName != null + ? TriggerRelatedNames.tryParse(relatedName) ?? + (throw ParseException( + 'Invalid RELATED value "$relatedName" for property "${property.name}"', + lineNumber: property.lineNumber, + )) + : null; + final valueType = - property.parameters['VALUE']?.first ?? ValueTypeNames.duration; + _tryParseSingleParameterValue(property, parameterName: 'VALUE') ?? + ValueTypeNames.duration; if (valueType == ValueTypeNames.duration) { final duration = parseCalDuration(property); - return Trigger.duration(duration); + return Trigger.duration( + duration, + related: related, + relatedName: relatedName, + ); } else if (valueType == ValueTypeNames.dateTime) { + if (relatedName != null) { + throw ParseException( + 'RELATED parameter is only valid for DURATION trigger values', + lineNumber: property.lineNumber, + ); + } + final dateTime = parseCalDateTimeUtc(property); - return Trigger.dateTime(dateTime); + return Trigger.dateTime( + dateTime, + related: related, + relatedName: relatedName, + ); } throw ParseException( 'Invalid value type "$valueType" for property "${property.name}"', @@ -376,14 +404,21 @@ UtcOffset parseUtcOffset(CalendarProperty property) { } String? _tryParseTzidParameter(CalendarProperty property) { - final tzidList = property.parameters['TZID']; - if (tzidList != null && tzidList.length > 1) { + return _tryParseSingleParameterValue(property, parameterName: 'TZID'); +} + +String? _tryParseSingleParameterValue( + CalendarProperty property, { + required String parameterName, +}) { + final values = property.parameters[parameterName]; + if (values != null && values.length > 1) { throw ParseException( - 'Multiple TZID parameters found for property "${property.name}"', + 'Multiple $parameterName parameters found for property "${property.name}"', lineNumber: property.lineNumber, ); } - return tzidList?.firstOrNull; + return values?.firstOrNull; } String _parseTextString(String value) { diff --git a/test/semantic/calendar_types_test.dart b/test/semantic/calendar_types_test.dart index 4f24aab..7f5192a 100644 --- a/test/semantic/calendar_types_test.dart +++ b/test/semantic/calendar_types_test.dart @@ -419,6 +419,17 @@ void main() { expect(trigger.toString(), equals('20250101T100000Z')); }); + + test('stores RELATED parameter metadata', () { + final trigger = Trigger.duration( + CalDuration(minutes: 15), + related: TriggerRelated.end, + relatedName: 'END', + ); + + expect(trigger.related, TriggerRelated.end); + expect(trigger.relatedName, 'END'); + }); }); group('UtcOffset', () { diff --git a/test/semantic/end_to_end_test.dart b/test/semantic/end_to_end_test.dart index 26d043e..13cc337 100644 --- a/test/semantic/end_to_end_test.dart +++ b/test/semantic/end_to_end_test.dart @@ -785,6 +785,8 @@ void main() { ); expect(alarm.trigger.duration, CalDuration(sign: Sign.negative, days: 2)); + expect(alarm.trigger.related, TriggerRelated.end); + expect(alarm.trigger.relatedName, 'END'); expect(alarm.action, AlarmAction.email); expect(alarm.attendees, isNotEmpty); expect(alarm.attendees.first.address, 'mailto:john_doe@example.com'); diff --git a/test/semantic/property_parser_test.dart b/test/semantic/property_parser_test.dart index c9f116a..85c9a71 100644 --- a/test/semantic/property_parser_test.dart +++ b/test/semantic/property_parser_test.dart @@ -529,6 +529,90 @@ void main() { expect(trigger.dateTime, CalDateTime.utc(1998, 1, 1, 12, 0, 0)); }); + test('Parse TRIGGER with RELATED=END', () { + final property = DocumentParser.parseProperty( + 'TRIGGER;RELATED=END:-PT15M', + ); + final trigger = parseTrigger(property); + expect(trigger.duration, CalDuration(sign: Sign.negative, minutes: 15)); + expect(trigger.related, TriggerRelated.end); + expect(trigger.relatedName, 'END'); + }); + + test('Parse TRIGGER with invalid RELATED value throws', () { + final property = DocumentParser.parseProperty( + 'TRIGGER;RELATED=INVALID:-PT15M', + ); + expect( + () => parseTrigger(property), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Invalid RELATED value "INVALID" for property "TRIGGER"', + ), + ), + ); + }); + + test('Parse TRIGGER with RELATED and DATE-TIME throws', () { + final property = DocumentParser.parseProperty( + 'TRIGGER;RELATED=END;VALUE=DATE-TIME:19980101T120000Z', + ); + expect( + () => parseTrigger(property), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'RELATED parameter is only valid for DURATION trigger values', + ), + ), + ); + }); + + test('Parse TRIGGER with multiple VALUE parameters throws', () { + final property = CalendarProperty( + name: 'TRIGGER', + value: '-PT15M', + parameters: { + 'VALUE': ['DURATION', 'DATE-TIME'], + }, + lineNumber: 1, + ); + expect( + () => parseTrigger(property), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Multiple VALUE parameters found for property "TRIGGER"', + ), + ), + ); + }); + + test('Parse TRIGGER with multiple RELATED parameters throws', () { + final property = CalendarProperty( + name: 'TRIGGER', + value: '-PT15M', + parameters: { + 'RELATED': ['START', 'END'], + }, + lineNumber: 1, + ); + expect( + () => parseTrigger(property), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Multiple RELATED parameters found for property "TRIGGER"', + ), + ), + ); + }); + test('Parse TRIGGER with invalid VALUE type throws', () { final property = DocumentParser.parseProperty( 'TRIGGER;VALUE=TEXT:invalid',