Line data Source code
1 : // Copyright 2026 The Authors
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // https://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : /// Duration ala ISO 8601.
16 : ///
17 : /// This is a purposefully simple implementation and is not intended
18 : /// to provide DateTime-style functionality. The main issues is that
19 : /// having [months] and [years] makes the duration contextual and we
20 : /// can't then boil it down to seconds (e.g. _how many seconds in a
21 : /// month?_).
22 : ///
23 : /// From my reading of Appendix A of RFC 3339, negative values are
24 : /// not allowed.
25 : ///
26 : /// See:
27 : ///
28 : /// - [Appendix A of RFC 3339](https://www.rfc-editor.org/info/rfc3339)
29 : /// - [Wikipedia](https://en.wikipedia.org/wiki/ISO_8601#Durations)
30 : class Iso8601Duration {
31 3 : static final RegExp iso8601Duration = RegExp(
32 : r'^P'
33 : r'((?<years>\d+)Y)?'
34 : r'((?<months>\d+)M)?'
35 : r'((?<days>\d+)D)?'
36 : r'(T'
37 : r'((?<hours>\d+)H)?'
38 : r'((?<minutes>\d+)M)?'
39 : r'((?<seconds>\d+)S)?)?$',
40 : );
41 :
42 : final int years;
43 : final int months;
44 : final int days;
45 : final int hours;
46 : final int minutes;
47 : final int seconds;
48 :
49 1 : Iso8601Duration._({
50 : this.years = 0,
51 : this.months = 0,
52 : this.days = 0,
53 : this.hours = 0,
54 : this.minutes = 0,
55 : this.seconds = 0,
56 : });
57 :
58 : /// Whether this [Iso8601Duration] properties are an exact match with [other].
59 : ///
60 : /// Nothing fancy is done here - no converting to seconds or normalising, it's
61 : /// just a comparison of properties.
62 1 : @override
63 : bool operator ==(Object other) {
64 1 : if (other is Iso8601Duration &&
65 3 : seconds == other.seconds &&
66 3 : minutes == other.minutes &&
67 3 : hours == other.hours &&
68 3 : days == other.days &&
69 3 : months == other.months &&
70 3 : years == other.years) {
71 : return true;
72 : }
73 : return false;
74 : }
75 :
76 1 : @override
77 : String toString() {
78 7 : return 'P${years}Y${months}M${days}DT${hours}H${minutes}M${seconds}S';
79 : }
80 :
81 : /// Returns a [Duration] representation of this [Iso8601Duration].
82 : ///
83 : /// Importantly, this returns `null` if the [Iso8601Duration] has the
84 : /// [years] and/or the [months] fields set to a value other than zero.
85 1 : Duration? get duration {
86 4 : if (years != 0 && months != 0) {
87 : return null;
88 : }
89 :
90 1 : return Duration(
91 1 : seconds: seconds,
92 1 : minutes: minutes,
93 1 : hours: hours,
94 1 : days: days,
95 : );
96 : }
97 :
98 : /// Parse an ISO 8601 duration [input] string into a [Iso8601Duration].
99 : ///
100 : /// [maxInputLength] is the maximum number of [input] characters
101 : ///
102 : /// Examples:
103 : ///
104 : /// - `P1S` - One second
105 : /// - `P1M` - One month
106 : /// - `P1MT1M` - One month and one minute
107 : /// - `P1Y2M3DT4H5M6S` - One year, two months, three days, four hours, five minutes, six seconds
108 : ///
109 : /// Returns (null, false) if the [input] is not a valid duration.
110 1 : static Iso8601Duration? tryParse(String input, {int maxInputLength = 24}) {
111 : // Guard the RegEx from dodgy strings
112 2 : if (input.substring(0, 1) != 'P' ||
113 2 : input.length > maxInputLength ||
114 1 : [
115 : 'Y',
116 : 'M',
117 : 'D',
118 : 'H',
119 : 'S',
120 : 'T',
121 4 : ].contains(input.substring(input.length - 2))) {
122 : return null;
123 : }
124 :
125 2 : var match = iso8601Duration.firstMatch(input);
126 : if (match == null) {
127 : return null;
128 : }
129 :
130 2 : int? seconds = int.tryParse(match.namedGroup('seconds') ?? '0');
131 2 : int? minutes = int.tryParse(match.namedGroup('minutes') ?? '0');
132 2 : int? hours = int.tryParse(match.namedGroup('hours') ?? '0');
133 2 : int? days = int.tryParse(match.namedGroup('days') ?? '0');
134 2 : int? months = int.tryParse(match.namedGroup('months') ?? '0');
135 2 : int? years = int.tryParse(match.namedGroup('years') ?? '0');
136 :
137 3 : if ([seconds, minutes, hours, days, months, years].any((e) => e == null)) {
138 : return null;
139 : }
140 :
141 1 : return Iso8601Duration._(
142 : years: years!,
143 : months: months!,
144 : days: days!,
145 : hours: hours!,
146 : minutes: minutes!,
147 : seconds: seconds!,
148 : );
149 : }
150 :
151 1 : @override
152 : int get hashCode =>
153 8 : Object.hashAll([years, months, days, hours, minutes, seconds]);
154 :
155 2 : static bool isValid(String value) => tryParse(value) != null;
156 : }
|