jscalendar 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +4 -4
- data/SECURITY.md +2 -0
- data/lib/jscalendar/recurrence.rb +190 -38
- data/lib/jscalendar/temporal.rb +1 -1
- data/lib/jscalendar/version.rb +1 -1
- data/lib/jscalendar.rb +0 -2
- metadata +2 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5c8a6fa3585b6b547bed270e19143ea159f653cfbd1273b3388e57baee9dcc70
|
|
4
|
+
data.tar.gz: 79ebbf4f3a0a3c3bb7a8f3e8e7f35ce598661f8be8f137d437c1331b752bcc6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ae20894387d667263708e65ac59345bb581e98113253388ba98fba9cba0a1609fe7779d67967443e71c1ff9ade4164319d3f96025140058948cebd9c9ef1054
|
|
7
|
+
data.tar.gz: b3fc5875d728e14afae29d00c1a65b2be18f4943a01f846edd2552955c43dfa82350f36d15e6a46ff17fc3f2bca1f026260b8c63c2a1a47c87ce5b8a7a5d0eb4
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-10-12
|
|
4
|
+
|
|
5
|
+
- Expand Gregorian recurrence rules without the `rrule` dependency.
|
|
6
|
+
- Apply JSCalendar's yearly recurrence defaults and preserve fractional seconds in distant occurrence windows.
|
|
7
|
+
|
|
3
8
|
## 0.1.0 - 2026-10-12
|
|
4
9
|
|
|
5
10
|
Initial release.
|
data/README.md
CHANGED
|
@@ -22,7 +22,7 @@ instances.each { |instance| puts [instance.recurrence_id, instance.start_time, i
|
|
|
22
22
|
instances.truncated?
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Install with `gem install jscalendar`. Ruby 3.3 or newer is required. CI checks maintained CRuby versions, Ruby head, JRuby and TruffleRuby on Linux and macOS, and json 2 and 3. Runtime dependencies are `json
|
|
25
|
+
Install with `gem install jscalendar`. Ruby 3.3 or newer is required. CI checks maintained CRuby versions, Ruby head, JRuby and TruffleRuby on Linux and macOS, and json 2 and 3. Runtime dependencies are `json` and `tzinfo`. Gregorian recurrence generation is implemented by this library. Install `tzinfo-data` on systems without a zoneinfo database, including Windows. No other Gem from this format family is required.
|
|
26
26
|
|
|
27
27
|
## Scope
|
|
28
28
|
|
|
@@ -87,9 +87,7 @@ Pass these keyword options to `parse` or model constructors. `occurrences` addit
|
|
|
87
87
|
bundle install
|
|
88
88
|
bundle exec rake test
|
|
89
89
|
COVERAGE=1 bundle exec rake test
|
|
90
|
-
bundle exec rubocop
|
|
91
90
|
bundle exec rbs -I sig validate
|
|
92
|
-
bundle exec ruby script/interop.rb
|
|
93
91
|
bundle exec ruby script/benchmark.rb
|
|
94
92
|
```
|
|
95
93
|
|
|
@@ -97,4 +95,6 @@ RFC source documents and 52 generated examples are fetched into ignored `tmp/` w
|
|
|
97
95
|
|
|
98
96
|
`script/update_registry.rb` checks property and enum identifiers against pinned IANA registries, CLDR calendar names and CSS Level 3 color names. Downloaded sources remain in ignored `tmp/registry/`; only identifiers needed for validation are present in the authored metadata. Updating a checksum requires reviewing the corresponding metadata differences. Source terms are available from [IANA](https://www.iana.org/help/licensing-terms), [Unicode](https://www.unicode.org/license.txt) and [W3C](https://www.w3.org/copyright/document-license/).
|
|
99
97
|
|
|
100
|
-
|
|
98
|
+
Every Ruby test job checks all 42 RFC 5545 recurrence examples against the results printed in the downloaded RFC. Finite examples also check for unexpected extra occurrences; infinite examples are checked through the last printed date. RFC 8984's ten examples all round trip. See [specification decisions](docs/specification.md) for errata and extraction corrections.
|
|
99
|
+
|
|
100
|
+
Additional dependencies must offer the MIT license, including development and comparison tools. The Ruby runtime and its standard libraries are exceptions to this policy. Reference documents and registry facts are test/specification inputs, not dependency implementations. See [implementation provenance](docs/provenance.md) for the scope and limits of the source audit.
|
data/SECURITY.md
CHANGED
|
@@ -7,3 +7,5 @@ Calendar data can include private locations and contact details. Applications co
|
|
|
7
7
|
Parsing never fetches calendar URIs, executes content, schedules invitations or sends alerts.
|
|
8
8
|
Input size, depth, collection sizes and recurrence work are bounded. Resource limits are hard failures even with `strict: false`.
|
|
9
9
|
Recurrence expansion requires an end time, caps its output and interrupts work after a configurable time budget.
|
|
10
|
+
|
|
11
|
+
Dependency changes must satisfy the MIT-only additional-dependency policy. The Ruby runtime and standard libraries are excepted. Consult the actual license files as well as package metadata; do not infer implementation provenance from packaging exclusions.
|
|
@@ -13,8 +13,8 @@ module Jscalendar
|
|
|
13
13
|
errors = Validator.new.validate(rule, type: "RecurrenceRule")
|
|
14
14
|
raise ValidationError, errors unless errors.empty?
|
|
15
15
|
raise UnsupportedError, "only Gregorian recurrence expansion is supported" unless rule.fetch("rscale", "gregorian") == "gregorian"
|
|
16
|
-
raise UnsupportedError, "
|
|
17
|
-
raise UnsupportedError, "
|
|
16
|
+
raise UnsupportedError, "recurrence expansion supports only skip=omit" unless rule.fetch("skip", "omit") == "omit"
|
|
17
|
+
raise UnsupportedError, "recurrence expansion does not support leap seconds" if rule.fetch("bySecond", []).include?(60)
|
|
18
18
|
|
|
19
19
|
parts = ["FREQ=#{rule.fetch("frequency").upcase}"]
|
|
20
20
|
PARTS.each { |name, keyword| parts << "#{keyword}=#{Array(rule[name]).join(",").upcase}" if rule.key?(name) }
|
|
@@ -25,48 +25,200 @@ module Jscalendar
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def rule_times(rule, initial, from, to, limit:, include_initial: true)
|
|
28
|
-
|
|
28
|
+
to_rrule(rule) # Validate before constructing candidates.
|
|
29
|
+
GregorianRecurrence.new(rule, initial).between(from, to, limit: limit, include_initial: include_initial)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Authored from the candidate/filter rules in RFC 8984 section 4.3.3.1
|
|
34
|
+
# and RFC 5545 section 3.3.10. Dates are wall-clock UTC until zone conversion.
|
|
35
|
+
class GregorianRecurrence
|
|
36
|
+
DAYS = %w[su mo tu we th fr sa].freeze
|
|
37
|
+
SECONDS = { "weekly" => 604_800, "daily" => 86_400, "hourly" => 3600, "minutely" => 60, "secondly" => 1 }.freeze
|
|
38
|
+
CLOCK = [["byHour", 3600, 24], ["byMinute", 60, 60], ["bySecond", 1, 60]].freeze
|
|
39
|
+
|
|
40
|
+
def initialize(rule, initial)
|
|
41
|
+
@rule = Jscalendar.deep_copy(rule)
|
|
42
|
+
@initial = initial
|
|
43
|
+
@frequency = @rule.fetch("frequency")
|
|
44
|
+
@interval = @rule.fetch("interval", 1)
|
|
45
|
+
@week_start = DAYS.index(@rule.fetch("firstDayOfWeek", "mo"))
|
|
46
|
+
@until = LocalDateTime.new(@rule["until"]).to_time if @rule["until"]
|
|
47
|
+
add_defaults
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def between(from, to, limit:, include_initial:)
|
|
51
|
+
count = @rule["count"]
|
|
52
|
+
return [] if count == 0
|
|
29
53
|
|
|
30
|
-
rule = Jscalendar.deep_copy(rule)
|
|
31
|
-
count = rule.delete("count")
|
|
32
|
-
if rule["frequency"] == "yearly" && rule.key?("byWeekNo") && !(rule.key?("byYearDay") || rule.key?("byMonthDay") || rule.key?("byDay"))
|
|
33
|
-
# RFC 8984 section 4.3.3 supplies this default; rrule 0.8 does not.
|
|
34
|
-
rule["byDay"] = [{ "@type" => "NDay", "day" => %w[su mo tu we th fr sa][initial.wday] }]
|
|
35
|
-
end
|
|
36
|
-
string = to_rrule(rule)
|
|
37
|
-
engine = RRule::Rule.new(string, dtstart: initial, tzid: "UTC", max_year: to.year + 1)
|
|
38
54
|
result = []
|
|
39
|
-
generated = 0
|
|
40
|
-
if include_initial
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
generated = include_initial ? 1 : 0
|
|
56
|
+
result << @initial if include_initial && @initial >= from && @initial < to
|
|
57
|
+
return result if (count && generated >= count) || result.size >= limit
|
|
58
|
+
|
|
59
|
+
index = count ? 0 : first_period(from)
|
|
60
|
+
loop do
|
|
61
|
+
start = period(index)
|
|
62
|
+
break if start >= to || (@until && start > @until)
|
|
63
|
+
|
|
64
|
+
each_candidate(start) do |candidate|
|
|
65
|
+
next if candidate < @initial || (include_initial && candidate == @initial)
|
|
66
|
+
return result if candidate >= to || (@until && candidate > @until)
|
|
67
|
+
|
|
68
|
+
generated += 1
|
|
69
|
+
result << candidate if candidate >= from
|
|
70
|
+
return result if (count && generated >= count) || result.size >= limit
|
|
71
|
+
end
|
|
72
|
+
index += 1
|
|
57
73
|
end
|
|
58
74
|
result
|
|
59
75
|
end
|
|
60
76
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def add_defaults
|
|
80
|
+
width = SECONDS.fetch(@frequency, 86_400)
|
|
81
|
+
CLOCK.each do |name, seconds, _size|
|
|
82
|
+
@rule[name] ||= [(@initial.hour * 3600 + @initial.min * 60 + @initial.sec) / seconds % (name == "byHour" ? 24 : 60)] if width > seconds
|
|
83
|
+
end
|
|
84
|
+
weekday = [{ "day" => DAYS[@initial.wday] }]
|
|
85
|
+
@rule["byDay"] ||= weekday if @frequency == "weekly"
|
|
86
|
+
if @frequency == "monthly" && !@rule.key?("byDay")
|
|
87
|
+
@rule["byMonthDay"] ||= [@initial.day]
|
|
88
|
+
end
|
|
89
|
+
return unless @frequency == "yearly" && !@rule.key?("byYearDay")
|
|
90
|
+
|
|
91
|
+
if !@rule.key?("byMonth") && !@rule.key?("byWeekNo") && (@rule.key?("byMonthDay") || !@rule.key?("byDay"))
|
|
92
|
+
@rule["byMonth"] = [@initial.month.to_s]
|
|
93
|
+
end
|
|
94
|
+
return if @rule.key?("byMonthDay") || @rule.key?("byDay")
|
|
95
|
+
|
|
96
|
+
@rule[@rule.key?("byWeekNo") ? "byDay" : "byMonthDay"] = @rule.key?("byWeekNo") ? weekday : [@initial.day]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def date(year, month = 1, day = 1)
|
|
100
|
+
Date.new(year, month, day, Date::GREGORIAN)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def midnight(value)
|
|
104
|
+
Time.utc(value.year, value.month, value.day)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def period(index)
|
|
108
|
+
steps = index * @interval
|
|
109
|
+
case @frequency
|
|
110
|
+
when "yearly" then Time.utc(@initial.year + steps)
|
|
111
|
+
when "monthly" then midnight(date(@initial.year, @initial.month) >> steps)
|
|
112
|
+
when "weekly"
|
|
113
|
+
first = date(@initial.year, @initial.month, @initial.day)
|
|
114
|
+
midnight(first - ((first.wday - @week_start) % 7)) + steps * 604_800
|
|
115
|
+
else
|
|
116
|
+
width = SECONDS.fetch(@frequency)
|
|
117
|
+
Time.at(@initial.to_i.div(width) * width + steps * width).utc
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Jump whole periods only when COUNT does not require counting earlier matches.
|
|
122
|
+
def first_period(from)
|
|
123
|
+
delta = case @frequency
|
|
124
|
+
when "yearly" then from.year - @initial.year
|
|
125
|
+
when "monthly" then (from.year - @initial.year) * 12 + from.month - @initial.month
|
|
126
|
+
else ((from.to_r - period(0).to_r) / SECONDS.fetch(@frequency)).floor
|
|
127
|
+
end
|
|
128
|
+
[delta.div(@interval), 0].max
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def dates_in(start)
|
|
132
|
+
first = date(start.year, start.month, start.day)
|
|
133
|
+
last = case @frequency
|
|
134
|
+
when "yearly" then date(start.year + 1) - 1
|
|
135
|
+
when "monthly" then (first >> 1) - 1
|
|
136
|
+
when "weekly" then first + 6
|
|
137
|
+
else first
|
|
138
|
+
end
|
|
139
|
+
(first..last).select { |value| date_matches?(value) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def signed_member?(values, number, length)
|
|
143
|
+
values.include?(number) || values.include?(number - length - 1)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def week_one(year)
|
|
147
|
+
january_fourth = date(year, 1, 4)
|
|
148
|
+
january_fourth - ((january_fourth.wday - @week_start) % 7)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def week_matches?(value)
|
|
152
|
+
year = value.year
|
|
153
|
+
first = week_one(year)
|
|
154
|
+
if value < first
|
|
155
|
+
year -= 1
|
|
156
|
+
first = week_one(year)
|
|
157
|
+
elsif value >= week_one(year + 1)
|
|
158
|
+
year += 1
|
|
159
|
+
first = week_one(year)
|
|
160
|
+
end
|
|
161
|
+
number = ((value - first).to_i / 7) + 1
|
|
162
|
+
signed_member?(@rule["byWeekNo"], number, (week_one(year + 1) - first).to_i / 7)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def date_matches?(value)
|
|
166
|
+
return false if @rule.key?("byMonth") && !@rule["byMonth"].include?(value.month.to_s)
|
|
167
|
+
return false if @rule.key?("byWeekNo") && !week_matches?(value)
|
|
168
|
+
return false if @rule.key?("byYearDay") && !signed_member?(@rule["byYearDay"], value.yday, date(value.year, 12, 31).yday)
|
|
169
|
+
return false if @rule.key?("byMonthDay") && !signed_member?(@rule["byMonthDay"], value.day, date(value.year, value.month, -1).day)
|
|
170
|
+
|
|
171
|
+
!@rule.key?("byDay") || @rule["byDay"].any? { |day| day_matches?(value, day) }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def day_matches?(value, day)
|
|
175
|
+
return false unless DAYS[value.wday] == day["day"]
|
|
176
|
+
return true unless day.key?("nthOfPeriod")
|
|
177
|
+
|
|
178
|
+
# RFC 8984 uses the frequency period even when BYMONTH is present.
|
|
179
|
+
if @frequency == "monthly"
|
|
180
|
+
first = date(value.year, value.month)
|
|
181
|
+
last = date(value.year, value.month, -1)
|
|
182
|
+
elsif @frequency == "yearly"
|
|
183
|
+
first = date(value.year)
|
|
184
|
+
last = date(value.year, 12, 31)
|
|
185
|
+
else
|
|
186
|
+
return [1, -1].include?(day["nthOfPeriod"])
|
|
187
|
+
end
|
|
188
|
+
signed_member?([day["nthOfPeriod"]], (value - first).to_i / 7 + 1,
|
|
189
|
+
(value - first).to_i / 7 + 1 + (last - value).to_i / 7)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def clock_values(start)
|
|
193
|
+
width = SECONDS.fetch(@frequency, 86_400)
|
|
194
|
+
CLOCK.map do |name, seconds, size|
|
|
195
|
+
values = @rule.fetch(name, (0...size).to_a).uniq.sort
|
|
196
|
+
values &= [(start.hour * 3600 + start.min * 60 + start.sec) / seconds % size] if width <= seconds
|
|
197
|
+
values
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def each_candidate(start)
|
|
202
|
+
dates = dates_in(start)
|
|
203
|
+
hours, minutes, seconds = clock_values(start)
|
|
204
|
+
per_day = hours.size * minutes.size * seconds.size
|
|
205
|
+
size = dates.size * per_day
|
|
206
|
+
return if size.zero?
|
|
207
|
+
|
|
208
|
+
# Index the Cartesian product instead of allocating up to 31 million
|
|
209
|
+
# Time objects for a yearly period with all clock values and BYSETPOS.
|
|
210
|
+
positions = if @rule.key?("bySetPosition")
|
|
211
|
+
@rule["bySetPosition"].map { |n| n.positive? ? n - 1 : size + n }.select { |n| n >= 0 && n < size }.uniq.sort
|
|
212
|
+
else
|
|
213
|
+
0...size
|
|
214
|
+
end
|
|
215
|
+
positions.each do |position|
|
|
216
|
+
day_index, remainder = position.divmod(per_day)
|
|
217
|
+
hour_index, remainder = remainder.divmod(minutes.size * seconds.size)
|
|
218
|
+
minute_index, second_index = remainder.divmod(seconds.size)
|
|
219
|
+
value = dates[day_index]
|
|
220
|
+
yield Time.utc(value.year, value.month, value.day, hours[hour_index], minutes[minute_index], seconds[second_index]) + @initial.subsec
|
|
221
|
+
end
|
|
70
222
|
end
|
|
71
223
|
end
|
|
72
224
|
|
data/lib/jscalendar/temporal.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Jscalendar
|
|
|
4
4
|
class UnsupportedError < Error; end
|
|
5
5
|
|
|
6
6
|
def self.decimal_seconds(number)
|
|
7
|
-
value = number.is_a?(Float) ?
|
|
7
|
+
value = number.is_a?(Float) ? Rational(number.to_s) : number.to_r
|
|
8
8
|
denominator = value.denominator
|
|
9
9
|
powers = [2, 5].map do |factor|
|
|
10
10
|
count = 0
|
data/lib/jscalendar/version.rb
CHANGED
data/lib/jscalendar.rb
CHANGED
|
@@ -5,11 +5,9 @@ require "yaml"
|
|
|
5
5
|
require "date"
|
|
6
6
|
require "time"
|
|
7
7
|
require "uri"
|
|
8
|
-
require "bigdecimal"
|
|
9
8
|
require "securerandom"
|
|
10
9
|
require "timeout"
|
|
11
10
|
require "tzinfo"
|
|
12
|
-
require "rrule"
|
|
13
11
|
require_relative "jscalendar/version"
|
|
14
12
|
require_relative "jscalendar/errors"
|
|
15
13
|
require_relative "jscalendar/types"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jscalendar
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
@@ -29,20 +29,6 @@ dependencies:
|
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '4'
|
|
32
|
-
- !ruby/object:Gem::Dependency
|
|
33
|
-
name: rrule
|
|
34
|
-
requirement: !ruby/object:Gem::Requirement
|
|
35
|
-
requirements:
|
|
36
|
-
- - "~>"
|
|
37
|
-
- !ruby/object:Gem::Version
|
|
38
|
-
version: 0.8.0
|
|
39
|
-
type: :runtime
|
|
40
|
-
prerelease: false
|
|
41
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
-
requirements:
|
|
43
|
-
- - "~>"
|
|
44
|
-
- !ruby/object:Gem::Version
|
|
45
|
-
version: 0.8.0
|
|
46
32
|
- !ruby/object:Gem::Dependency
|
|
47
33
|
name: tzinfo
|
|
48
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -111,7 +97,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
111
97
|
- !ruby/object:Gem::Version
|
|
112
98
|
version: '0'
|
|
113
99
|
requirements: []
|
|
114
|
-
rubygems_version:
|
|
100
|
+
rubygems_version: 3.6.9
|
|
115
101
|
specification_version: 4
|
|
116
102
|
summary: Read, validate, and expand JSCalendar events and tasks
|
|
117
103
|
test_files: []
|