jscalendar 0.1.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.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscalendar
4
+ class UnsupportedError < Error; end
5
+
6
+ def self.decimal_seconds(number)
7
+ value = number.is_a?(Float) ? BigDecimal(number.to_s).to_r : number.to_r
8
+ denominator = value.denominator
9
+ powers = [2, 5].map do |factor|
10
+ count = 0
11
+ while (denominator % factor).zero?
12
+ denominator /= factor
13
+ count += 1
14
+ end
15
+ count
16
+ end
17
+ raise ArgumentError, "seconds must have a finite decimal representation" unless denominator == 1
18
+
19
+ digits = powers.max
20
+ return value.numerator.to_s if digits.zero?
21
+
22
+ text = (value.abs * (10**digits)).to_i.to_s.rjust(digits + 1, "0")
23
+ text.insert(-digits - 1, ".")
24
+ "#{"-" if value.negative?}#{text.sub(/0+\z/, "")}"
25
+ end
26
+
27
+ class LocalDateTime
28
+ include Comparable
29
+
30
+ def initialize(value)
31
+ value = value.to_s
32
+ raise ValidationError, "invalid LocalDateTime" unless Types.utc_date_time?("#{value}Z")
33
+
34
+ @value = value.dup.freeze
35
+ end
36
+
37
+ def self.from_time(time)
38
+ fraction = if time.subsec.zero?
39
+ ""
40
+ else
41
+ Jscalendar.decimal_seconds(time.subsec).delete_prefix("0")
42
+ end
43
+ new(time.strftime("%Y-%m-%dT%H:%M:%S") + fraction)
44
+ end
45
+
46
+ def to_s
47
+ @value
48
+ end
49
+
50
+ def to_time
51
+ raise UnsupportedError, "Ruby Time cannot represent leap seconds" if @value[17, 2] == "60"
52
+
53
+ fraction = Rational("0#{@value[19..]}")
54
+ time = Time.utc(*@value[0, 19].scan(/\d+/).map(&:to_i)) + fraction
55
+ raise UnsupportedError, "Ruby Time cannot preserve this fractional precision" unless time.subsec == fraction
56
+
57
+ time
58
+ end
59
+
60
+ def <=>(other)
61
+ other = LocalDateTime.new(other).to_s
62
+ [@value[0, 19], Rational("0#{@value[19..]}")] <=> [other[0, 19], Rational("0#{other[19..]}")]
63
+ end
64
+
65
+ def ==(other)
66
+ other.respond_to?(:to_s) && @value == other.to_s
67
+ end
68
+ end
69
+
70
+ class Duration
71
+ attr_reader :days, :seconds
72
+
73
+ def initialize(value)
74
+ @value = value.to_s.dup.freeze
75
+ sign = is_a?(SignedDuration) ? "[+-]?" : ""
76
+ match = /\A(#{sign})P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d*[1-9])?)S)?)?\z/.match(@value)
77
+ unless match && match.captures[1..].any? && !@value.end_with?("T") && !(match[4] && match[6] && !match[5])
78
+ raise ValidationError, "invalid #{self.class.name.split("::").last}"
79
+ end
80
+
81
+ factor = match[1] == "-" ? -1 : 1
82
+ @days = ((match[2].to_i * 7) + match[3].to_i) * factor
83
+ @seconds = ((match[4].to_i * 3600) + (match[5].to_i * 60) + Rational(match[6] || "0")) * factor
84
+ end
85
+
86
+ def self.from_seconds(seconds)
87
+ raise ArgumentError, "seconds must be a finite number" unless seconds.is_a?(Numeric) && seconds.finite?
88
+ raise ArgumentError, "negative Duration" if self == Duration && seconds.negative?
89
+
90
+ number = Jscalendar.decimal_seconds(seconds.abs)
91
+ new("#{"-" if seconds.negative?}PT#{number}S")
92
+ end
93
+
94
+ def to_s
95
+ @value
96
+ end
97
+
98
+ # Nominal seconds; calendar days can differ from 24 hours across a DST change.
99
+ def to_seconds
100
+ (@days * 86_400) + @seconds
101
+ end
102
+
103
+ def add_to(local, time_zone: nil, time_zones: {})
104
+ local = LocalDateTime.new(local)
105
+ resolver = time_zone && TimeZoneResolver.new(time_zone, time_zones)
106
+ if @days.negative? || @seconds.negative?
107
+ wall = resolver ? resolver.to_local(resolver.to_utc(local) + @seconds).to_time : local.to_time + @seconds
108
+ shifted = LocalDateTime.from_time(wall + (@days * 86_400))
109
+ resolver ? resolver.to_local(resolver.to_utc(shifted)) : shifted
110
+ else
111
+ shifted = LocalDateTime.from_time(local.to_time + (@days * 86_400))
112
+ resolver ? resolver.to_local(resolver.to_utc(shifted) + @seconds) : LocalDateTime.from_time(shifted.to_time + @seconds)
113
+ end
114
+ end
115
+ end
116
+
117
+ class SignedDuration < Duration; end
118
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscalendar
4
+ class TimeZoneResolver
5
+ def initialize(identifier, definitions = {})
6
+ @identifier = identifier
7
+ @custom = definitions[identifier]
8
+ @iana = TZInfo::Timezone.get(identifier) unless @custom
9
+ @transitions = {}
10
+ rescue TZInfo::InvalidTimezoneIdentifier
11
+ raise ValidationError, "unknown time zone #{identifier}"
12
+ end
13
+
14
+ def to_utc(local)
15
+ wall = LocalDateTime.new(local).to_time
16
+ return wall - custom_offset(wall) if @custom
17
+
18
+ periods = @iana.periods_for_local(wall)
19
+ return wall - periods.map(&:observed_utc_offset).max unless periods.empty?
20
+
21
+ change = @iana.transitions_up_to(wall + (3 * 86_400), wall - (3 * 86_400)).find do |transition|
22
+ instant = transition.at.to_time
23
+ wall >= instant + transition.previous_offset.observed_utc_offset && wall < instant + transition.offset.observed_utc_offset
24
+ end
25
+ raise ConversionError, "unable to resolve time zone discontinuity" unless change
26
+
27
+ wall - change.previous_offset.observed_utc_offset
28
+ end
29
+
30
+ def to_local(utc)
31
+ return LocalDateTime.from_time(@iana.utc_to_local(utc.getutc)) unless @custom
32
+
33
+ changes = transitions(utc.year + 1)
34
+ offset = changes.first[1]
35
+ changes.each do |instant, _before, after|
36
+ break if instant > utc
37
+
38
+ offset = after
39
+ end
40
+ LocalDateTime.from_time(utc.getutc + offset)
41
+ end
42
+
43
+ def wall_bounds(from, to)
44
+ points = [to_local(from).to_time, to_local(to).to_time]
45
+ changes = if @custom
46
+ transitions(to.year + 1).select { |instant, _before, _after| instant.between?(from, to) }
47
+ else
48
+ @iana.transitions_up_to(to + 1, from).map do |transition|
49
+ [transition.at.to_time, transition.previous_offset.observed_utc_offset, transition.offset.observed_utc_offset]
50
+ end
51
+ end
52
+ changes.each { |instant, before, after| points.push(instant + before, instant + after) }
53
+ points.minmax
54
+ end
55
+
56
+ private
57
+
58
+ def custom_offset(wall)
59
+ changes = transitions(wall.year + 1)
60
+ offset = changes.first[1]
61
+ changes.each do |instant, before, after|
62
+ low, high = [instant + before, instant + after].minmax
63
+ return before if wall >= low && wall < high
64
+ break if wall < high
65
+
66
+ offset = after
67
+ end
68
+ offset
69
+ end
70
+
71
+ def transitions(year)
72
+ @transitions[year] ||= Timeout.timeout(2, LimitError, "custom time zone expansion exceeded time limit") do
73
+ rules = @custom.fetch("standard", []) + @custom.fetch("daylight", [])
74
+ changes = rules.flat_map do |rule|
75
+ before = offset_seconds(rule.fetch("offsetFrom"))
76
+ after = offset_seconds(rule.fetch("offsetTo"))
77
+ initial = LocalDateTime.new(rule.fetch("start")).to_time
78
+ dates = [initial] + rule.fetch("recurrenceOverrides", {}).keys.map { |date| LocalDateTime.new(date).to_time }
79
+ rule.fetch("recurrenceRules", []).each do |recurrence|
80
+ recurrence = Jscalendar.deep_copy(recurrence)
81
+ # VTIMEZONE UNTIL denotes UTC, while the recurrence engine uses wall time.
82
+ recurrence["until"] = LocalDateTime.from_time(LocalDateTime.new(recurrence["until"]).to_time + before).to_s if recurrence["until"]
83
+ dates.concat(Recurrence.rule_times(recurrence, initial, initial, Time.utc(year + 1), limit: 10_001))
84
+ raise LimitError, "custom time zone has too many transitions" if dates.size > 10_000
85
+ end
86
+ dates.uniq.map { |date| [date - before, before, after] }
87
+ end
88
+ raise ValidationError, "custom time zone needs a transition rule" if changes.empty?
89
+
90
+ changes.sort_by(&:first)
91
+ end
92
+ end
93
+
94
+ def offset_seconds(value)
95
+ sign = value.start_with?("-") ? -1 : 1
96
+ ((value[1, 2].to_i * 3600) + (value[3, 2].to_i * 60) + value[5, 2].to_i) * sign
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jscalendar
4
+ DEFAULT_LIMITS = { max_input_size: 16 * 1024 * 1024, max_depth: 32, max_map_size: 10_000,
5
+ max_data_uri_size: 1024 * 1024, max_patches: 1000, max_occurrences: 10_000, max_rules: 100, max_occurrence_bytes: 32 * 1024 * 1024 }.freeze
6
+ MAX_INTEGER = (2**53) - 1
7
+
8
+ class << self
9
+ def generate_json(value, options = nil)
10
+ value = precise_json_floats(value) if RUBY_ENGINE == "truffleruby"
11
+ JSON.generate(value, options)
12
+ end
13
+
14
+ # TruffleRuby Float#to_s can round away significant digits used by json's generator.
15
+ def precise_json_floats(value)
16
+ case value
17
+ when Hash then value.transform_values { |item| precise_json_floats(item) }
18
+ when Array then value.map { |item| precise_json_floats(item) }
19
+ when Float
20
+ raise JSON::GeneratorError, "#{value} not allowed in JSON" unless value.finite?
21
+
22
+ number = format("%.17g", value)
23
+ number += ".0" unless number.match?(/[.eE]/)
24
+ JSON::Fragment.new(number)
25
+ else value
26
+ end
27
+ end
28
+ private :precise_json_floats
29
+
30
+ # Copies JSON-compatible values without sharing mutable caller-owned containers.
31
+ def deep_copy(value, max_depth: DEFAULT_LIMITS[:max_depth], depth: 0, ancestors: [], pointer: "")
32
+ case value
33
+ when Hash, Array
34
+ raise LimitError.new("JSON nesting exceeds max_depth", pointer: pointer) if depth >= max_depth
35
+ raise ValidationError.new("cyclic JSON data", pointer: pointer) if ancestors.include?(value.object_id)
36
+
37
+ nested = ancestors + [value.object_id]
38
+ if value.is_a?(Array)
39
+ value.each_with_index.map do |item, index|
40
+ deep_copy(item, max_depth: max_depth, depth: depth + 1, ancestors: nested, pointer: self.pointer(pointer, index))
41
+ end
42
+ else
43
+ value.each_with_object({}) do |(key, item), result|
44
+ raise ValidationError.new("JSON object keys must be strings", pointer: pointer) unless key.is_a?(String) || key.is_a?(Symbol)
45
+
46
+ key = key.to_s
47
+ unless key.valid_encoding? && (key.encoding == Encoding::UTF_8 || key.ascii_only?)
48
+ raise ValidationError.new("invalid JSON object key encoding", pointer: pointer)
49
+ end
50
+ raise ValidationError.new("duplicate JSON object key #{key}", pointer: self.pointer(pointer, key)) if result.key?(key)
51
+
52
+ result[key.dup] = deep_copy(item, max_depth: max_depth, depth: depth + 1, ancestors: nested,
53
+ pointer: self.pointer(pointer, key))
54
+ end
55
+ end
56
+ when LocalDateTime, Duration then value.to_s
57
+ when String then value.dup
58
+ when Integer, Float, TrueClass, FalseClass, NilClass then value
59
+ when Model, IdMap then value.to_h
60
+ else raise ValidationError.new("value is not JSON-compatible", pointer: pointer)
61
+ end
62
+ end
63
+
64
+ def deep_freeze(value)
65
+ if value.is_a?(Hash)
66
+ value.each do |key, item|
67
+ deep_freeze(key)
68
+ deep_freeze(item)
69
+ end
70
+ end
71
+ value.each { |item| deep_freeze(item) } if value.is_a?(Array)
72
+ value.freeze
73
+ end
74
+
75
+ def pointer(parent, key)
76
+ "#{parent}/#{key.to_s.gsub("~", "~0").gsub("/", "~1")}"
77
+ end
78
+ end
79
+
80
+ SCHEMA = deep_freeze(YAML.safe_load_file(File.expand_path("../../data/types.yml", __dir__)))
81
+ ENUMS = deep_freeze(YAML.safe_load_file(File.expand_path("../../data/enums.yml", __dir__)))
82
+
83
+ module Types
84
+ module_function
85
+
86
+ def id?(value)
87
+ value.is_a?(String) && value.bytesize.between?(1, 255) && value.match?(/\A[A-Za-z0-9_-]+\z/)
88
+ end
89
+
90
+ def integer?(value, minimum: -MAX_INTEGER, maximum: MAX_INTEGER)
91
+ value.is_a?(Integer) && value.between?(minimum, maximum)
92
+ end
93
+
94
+ def vendor?(value)
95
+ return false unless value.is_a?(String) && value.valid_encoding?
96
+
97
+ prefix, name = value.split(":", 2)
98
+ return false unless name && !prefix.empty? && !name.empty? && !name.match?(%r{[\x00-\x08\x0A-\x1F\x7F"/~]})
99
+
100
+ prefix.split(".", -1).all? do |label|
101
+ label.match?(/\A[A-Za-z0-9\u0080-\u{10ffff}](?:[A-Za-z0-9\u0080-\u{10ffff}-]*[A-Za-z0-9\u0080-\u{10ffff}])?\z/)
102
+ end
103
+ rescue ArgumentError, Encoding::CompatibilityError
104
+ false
105
+ end
106
+
107
+ def uri?(value)
108
+ value.is_a?(String) && value.ascii_only? && !value.match?(/%(?![0-9a-fA-F]{2})/) && !URI.parse(value).scheme.nil?
109
+ rescue URI::InvalidURIError
110
+ false
111
+ end
112
+
113
+ def utc_date_time?(value)
114
+ return false unless value.is_a?(String)
115
+
116
+ match = /\A(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d*[1-9])?Z\z/.match(value)
117
+ return false unless match
118
+
119
+ year, month, day, hour, minute, second = match.captures.map(&:to_i)
120
+ Date.valid_date?(year, month, day, Date::GREGORIAN) && hour < 24 && minute < 60 && second <= 60 &&
121
+ (second < 60 || (hour == 23 && minute == 59 && [6, 12].include?(month) && day == Date.new(year, month, -1).day))
122
+ end
123
+
124
+ GRANDFATHERED_LANGUAGES = %w[art-lojban cel-gaulish en-gb-oed i-ami i-bnn i-default i-enochian i-hak i-klingon
125
+ i-lux i-mingo i-navajo i-pwn i-tao i-tay i-tsu no-bok no-nyn sgn-be-fr sgn-be-nl
126
+ sgn-ch-de zh-guoyu zh-hakka zh-min zh-min-nan zh-xiang].freeze
127
+
128
+ def language_tag?(value)
129
+ return false unless value.is_a?(String)
130
+ return true if GRANDFATHERED_LANGUAGES.include?(value.downcase)
131
+ return true if value.match?(/\Ax(?:-[A-Za-z0-9]{1,8})+\z/i)
132
+
133
+ parts = value.split("-", -1)
134
+ language = parts.shift
135
+ return false unless language&.match?(/\A[A-Za-z]{2,8}\z/)
136
+
137
+ 3.times { parts.shift if parts.first&.match?(/\A[A-Za-z]{3}\z/) } if language.size <= 3
138
+ parts.shift if parts.first&.match?(/\A[A-Za-z]{4}\z/)
139
+ parts.shift if parts.first&.match?(/\A(?:[A-Za-z]{2}|[0-9]{3})\z/)
140
+ variants = []
141
+ while parts.first&.match?(/\A(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3})\z/)
142
+ variant = parts.shift.downcase
143
+ return false if variants.include?(variant)
144
+
145
+ variants << variant
146
+ end
147
+ singletons = []
148
+ until parts.empty?
149
+ singleton = parts.shift.downcase
150
+ return false unless singleton.match?(/\A[A-Za-z0-9]\z/) && !singletons.include?(singleton)
151
+ return !parts.empty? && parts.all? { |part| part.match?(/\A[A-Za-z0-9]{1,8}\z/) } if singleton == "x"
152
+
153
+ singletons << singleton
154
+ count = 0
155
+ while parts.first&.match?(/\A[A-Za-z0-9]{2,8}\z/)
156
+ parts.shift
157
+ count += 1
158
+ end
159
+ return false if count.zero?
160
+ end
161
+ true
162
+ end
163
+
164
+ def email?(value)
165
+ return false unless value.is_a?(String) && value.ascii_only?
166
+
167
+ value = value.gsub(/\r\n(?=[ \t])/, "")
168
+ return false if value.match?(/[^\x09\x20-\x7E]/)
169
+
170
+ value = without_email_comments(value)
171
+ return false unless value
172
+
173
+ atom = %q([A-Za-z0-9!#$%&'*+\-/=?^_`{|}~]+)
174
+ quoted = '"(?:[^"\\\\\r\n]|\\\\[\\x20-\\x7E])*"'
175
+ word = "(?:#{atom}|#{quoted})"
176
+ local = "#{word}(?:[ \\t]*\\.[ \\t]*#{word})*"
177
+ domain = "(?:#{atom}(?:[ \\t]*\\.[ \\t]*#{atom})*|\\[(?:[^\\[\\]\\\\\\r\\n]|\\\\[\\x20-\\x7E])*\\])"
178
+ value.match?(/\A[ \t]*#{local}[ \t]*@[ \t]*#{domain}[ \t]*\z/)
179
+ end
180
+
181
+ def without_email_comments(value)
182
+ result = +""
183
+ depth = 0
184
+ quoted = nil
185
+ escaped = false
186
+ value.each_char do |character|
187
+ if escaped
188
+ result << character if depth.zero?
189
+ escaped = false
190
+ elsif character == "\\" && (depth.positive? || quoted)
191
+ escaped = true
192
+ result << character if depth.zero?
193
+ elsif depth.positive?
194
+ depth += 1 if character == "("
195
+ depth -= 1 if character == ")"
196
+ result << " " if depth.zero?
197
+ elsif quoted
198
+ result << character
199
+ quoted = nil if character == quoted
200
+ elsif character == "("
201
+ depth = 1
202
+ else
203
+ result << character
204
+ quoted = character == "[" ? "]" : '"' if ['"', "["].include?(character)
205
+ end
206
+ end
207
+ result if depth.zero? && !quoted && !escaped
208
+ end
209
+ end
210
+ end