jscalendar 0.1.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fdf3b15b894e49f349876787d56ce76102b83822e622c079dfc7966804eab2bf
4
- data.tar.gz: 23259294ec16d3a2420c1af9ad97ce72aaa0df6502a7328b19d27bb7c427f158
3
+ metadata.gz: 9d29bdac386dada64770464be1ac5930d27f8ce7775abe993548d9ae102847af
4
+ data.tar.gz: e4236e4b51a48ad96eae416bcf0cbb707051bee34d73d69d73d27fb0d5e5dbf0
5
5
  SHA512:
6
- metadata.gz: 5ff2aeaa9fb416f63ede56b7d018f6db8e671bb35c71badec0a76a142bf75c9aac6ac8d9ca4e850b832168105d65e06fa970196a06848da135045994092d5065
7
- data.tar.gz: 1cb88675b31c5d4d7fb2609e399dc667e57212d44893d96e6114a5037eca727ccc6d4c418f0803f7bc4d3f681cd0c5e36586a4db44706ebfa5e3d1b10d58e7f2
6
+ metadata.gz: 70f9259f472edccf51467a34944c35d38b479d97148b070f8ff3dec1424dc8814909bf42976774d7c9bb6a4d6b03a9a6261915b60c1c10fad9da43561b3ab66d
7
+ data.tar.gz: 96d52c03f2787990e3ac9cdca33ed454926f40f5f7af7fd534f37ec506f2df95eacf01f1f7ac56ae43866223164502f88c3b073f6ff17d25a4fbf2d1f177ee81
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 - 2026-10-12
4
+
5
+ - Match localization language tags without case sensitivity.
6
+
7
+ ## 0.2.0 - 2026-10-12
8
+
9
+ - Expand Gregorian recurrence rules without the `rrule` dependency.
10
+ - Apply JSCalendar's yearly recurrence defaults and preserve fractional seconds in distant occurrence windows.
11
+
3
12
  ## 0.1.0 - 2026-10-12
4
13
 
5
14
  Initial release.
data/README.md CHANGED
@@ -1,7 +1,44 @@
1
- # JSCalendar
1
+ <h1 align="center">JSCalendar</h1>
2
+
3
+ <p align="center">JSON calendars with typed models, time zones, and recurrence.</p>
4
+
5
+ <p align="center">
6
+ <a href="https://rubygems.org/gems/jscalendar"><img src="https://img.shields.io/gem/v/jscalendar" alt="Gem version"></a>
7
+ <a href="https://github.com/ydah/jscalendar/actions/workflows/main.yml"><img src="https://github.com/ydah/jscalendar/actions/workflows/main.yml/badge.svg?branch=main" alt="CI"></a>
8
+ <a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/Ruby-%3E%3D%203.3-CC342D?logo=ruby&amp;logoColor=white" alt="Ruby 3.3 or later"></a>
9
+ <a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License"></a>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="#installation">Installation</a> &middot;
14
+ <a href="#quick-start">Quick start</a> &middot;
15
+ <a href="#scope">Scope</a> &middot;
16
+ <a href="#development">Development</a> &middot;
17
+ <a href="#license">License</a>
18
+ </p>
2
19
 
3
20
  Read, validate, build and expand calendar events and tasks using [RFC 8984](https://www.rfc-editor.org/rfc/rfc8984).
4
21
 
22
+ ## Features
23
+
24
+ - Build typed events, tasks, and groups while preserving extension fields.
25
+ - Expand Gregorian recurrence with time zones, exclusions, and overrides.
26
+ - Apply validated patches and localizations with configurable resource limits.
27
+
28
+ ## Installation
29
+
30
+ Add the gem to your Gemfile:
31
+
32
+ ```ruby
33
+ gem "jscalendar"
34
+ ```
35
+
36
+ Then run `bundle install`.
37
+
38
+ 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.
39
+
40
+ ## Quick start
41
+
5
42
  ```ruby
6
43
  require "jscalendar"
7
44
 
@@ -22,8 +59,6 @@ instances.each { |instance| puts [instance.recurrence_id, instance.start_time, i
22
59
  instances.truncated?
23
60
  ```
24
61
 
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`, `tzinfo` and `rrule`. Install `tzinfo-data` on systems without a zoneinfo database, including Windows. No other Gem from this format family is required.
26
-
27
62
  ## Scope
28
63
 
29
64
  | Feature | Support |
@@ -55,6 +90,7 @@ patched = patch.apply(calendar.to_h)
55
90
  Strict parsing raises `ValidationError` with `errors` and a JSON Pointer in `pointer`. Lenient parsing preserves invalid fields with diagnostics. Serialization always validates. Unknown root types and malformed JSON raise `ParseError`; resource bounds raise `LimitError`, including in lenient mode. Duplicate keys are rejected with both supported json major versions.
56
91
 
57
92
  Recurrence overrides ignore the protected property prefixes specified by RFC 8984 §4.3.5. An `excluded: true` override can contain no other change. Localizations ignore disallowed paths. Patches never partially modify their target.
93
+ Localization language tags match without case sensitivity.
58
94
 
59
95
  ## Time and recurrence
60
96
 
@@ -81,15 +117,13 @@ Time-zone results follow the installed TZInfo data. Its explicit future transiti
81
117
 
82
118
  Pass these keyword options to `parse` or model constructors. `occurrences` additionally requires `to`, defaults to `limit: 1000`, and enforces `timeout: 2.0` seconds over the entire expansion. An exclusion set larger than the candidate budget fails instead of returning an incorrect partial subtraction. Impossible recurrence rules also time out. URIs are data and are never fetched.
83
119
 
84
- ## Development and references
120
+ ## Development
85
121
 
86
122
  ```sh
87
123
  bundle install
88
124
  bundle exec rake test
89
125
  COVERAGE=1 bundle exec rake test
90
- bundle exec rubocop
91
126
  bundle exec rbs -I sig validate
92
- bundle exec ruby script/interop.rb
93
127
  bundle exec ruby script/benchmark.rb
94
128
  ```
95
129
 
@@ -97,4 +131,14 @@ RFC source documents and 52 generated examples are fetched into ignored `tmp/` w
97
131
 
98
132
  `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
133
 
100
- The interoperability job compares all 42 RFC 5545 recurrence examples with Stalwart's `calcard` 0.3.2. It records one exact upstream UTC UNTIL discrepancy and compares dates before 2050, within the shared time-zone transition horizon. RFC 8984's ten examples all round trip. See [specification decisions](docs/specification.md) for errata and extraction corrections.
134
+ 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.
135
+
136
+ ## Contributing
137
+
138
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/ydah/jscalendar/issues). See [Development](#development) for the checks to run before submitting a change. Report security vulnerabilities privately as described in [SECURITY.md](SECURITY.md).
139
+
140
+ ## License
141
+
142
+ Released under the [MIT License](LICENSE.txt).
143
+
144
+ 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.
@@ -21,7 +21,9 @@ module Jscalendar
21
21
  end
22
22
 
23
23
  def localize(language)
24
- patch = @data.fetch("localizations", {})[language]
24
+ localizations = @data.fetch("localizations", {})
25
+ key = localizations.keys.find { |tag| tag.casecmp?(language) }
26
+ patch = localizations[key]
25
27
  return self.class.new(to_h, time_zones: @time_zones, **@limits) unless patch
26
28
 
27
29
  data = PatchObject.new(PatchObject.localization(patch), **@limits).apply(to_h, validate: false)
@@ -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, "rrule supports only skip=omit" unless rule.fetch("skip", "omit") == "omit"
17
- raise UnsupportedError, "rrule does not support leap seconds" if rule.fetch("bySecond", []).include?(60)
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
- return [] if rule.fetch("count", 1).zero?
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
- generated = 1
42
- result << initial if initial >= from && initial < to
43
- return result if count == 1 || result.size >= limit
44
- end
45
- # The engine strips fractions. Shift its lower bound before restoring them.
46
- engine.each(floor_date: (count ? initial : from) - initial.subsec) do |date|
47
- date = date.to_time.getutc + initial.subsec
48
- next if include_initial && date == initial
49
- break if date >= to
50
- break if rule["until"] && date > LocalDateTime.new(rule["until"]).to_time
51
- break if count && generated >= count
52
- next unless clock_matches?(rule, date)
53
-
54
- generated += 1
55
- result << date if date >= from
56
- break if result.size >= limit
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
- # rrule 0.8 overwrites clock filters for hourly and faster frequencies.
62
- def clock_matches?(rule, date)
63
- checks = case rule["frequency"]
64
- when "hourly" then { "byHour" => date.hour }
65
- when "minutely" then { "byHour" => date.hour, "byMinute" => date.min }
66
- when "secondly" then { "byHour" => date.hour, "byMinute" => date.min, "bySecond" => date.sec }
67
- else {}
68
- end
69
- checks.all? { |name, number| !rule.key?(name) || rule[name].include?(number) }
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
 
@@ -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) ? BigDecimal(number.to_s).to_r : number.to_r
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jscalendar
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
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.1.0
4
+ version: 0.2.1
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: 4.0.16
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: []