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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +100 -0
- data/SECURITY.md +9 -0
- data/data/enums.yml +450 -0
- data/data/types.yml +706 -0
- data/lib/jscalendar/calendar.rb +75 -0
- data/lib/jscalendar/errors.rb +27 -0
- data/lib/jscalendar/model.rb +200 -0
- data/lib/jscalendar/patch_object.rb +84 -0
- data/lib/jscalendar/recurrence.rb +194 -0
- data/lib/jscalendar/temporal.rb +118 -0
- data/lib/jscalendar/time_zone.rb +99 -0
- data/lib/jscalendar/types.rb +210 -0
- data/lib/jscalendar/validator.rb +400 -0
- data/lib/jscalendar/version.rb +5 -0
- data/lib/jscalendar.rb +55 -0
- data/sig/jscalendar.rbs +652 -0
- metadata +117 -0
|
@@ -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
|