jcard_jcal 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 +132 -0
- data/SECURITY.md +10 -0
- data/data/ical_properties.yml +52 -0
- data/data/vcard_properties.yml +42 -0
- data/lib/jcal.rb +30 -0
- data/lib/jcard.rb +28 -0
- data/lib/jcard_jcal/component.rb +72 -0
- data/lib/jcard_jcal/content_line/param_codec.rb +48 -0
- data/lib/jcard_jcal/content_line/reader.rb +86 -0
- data/lib/jcard_jcal/content_line/text_codec.rb +56 -0
- data/lib/jcard_jcal/content_line/writer.rb +57 -0
- data/lib/jcard_jcal/converter.rb +229 -0
- data/lib/jcard_jcal/errors.rb +21 -0
- data/lib/jcard_jcal/property.rb +113 -0
- data/lib/jcard_jcal/registry.rb +83 -0
- data/lib/jcard_jcal/value_types.rb +358 -0
- data/lib/jcard_jcal/version.rb +5 -0
- data/lib/jcard_jcal.rb +21 -0
- data/sig/jcard_jcal.rbs +123 -0
- metadata +86 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JCardJCal
|
|
4
|
+
# Shared conversion of component trees and their property values.
|
|
5
|
+
class Converter
|
|
6
|
+
def initialize(format:, registry: nil, strict: true, max_depth: 32, max_properties: 10_000,
|
|
7
|
+
max_line_length: 1_048_576, max_binary_size: 1_048_576, max_input_size: 16_777_216)
|
|
8
|
+
@format = format
|
|
9
|
+
@registry = registry || (format == :vcard ? JCard::Registry.default : JCal::Registry.default)
|
|
10
|
+
@strict = strict
|
|
11
|
+
@limits = { depth: max_depth, properties: max_properties, line_length: max_line_length,
|
|
12
|
+
binary_size: max_binary_size, input_size: max_input_size }
|
|
13
|
+
raise ArgumentError, "limits must be positive integers" unless @limits.values.all? { |n| n.is_a?(Integer) && n.positive? }
|
|
14
|
+
|
|
15
|
+
@property_count = 0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parse_json(source)
|
|
19
|
+
check_source(source)
|
|
20
|
+
data = JSON.parse(source, max_nesting: (@limits[:depth] * 3) + 10, allow_duplicate_key: false)
|
|
21
|
+
expected = @format == :vcard ? "vcard" : "vcalendar"
|
|
22
|
+
raise ParseError, "expected #{expected} component" unless data.is_a?(Array) && data.first == expected
|
|
23
|
+
|
|
24
|
+
read_component(data, "", 1)
|
|
25
|
+
rescue JSON::ParserError => e
|
|
26
|
+
raise ParseError, e.message
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parse_text(source)
|
|
30
|
+
check_source(source)
|
|
31
|
+
roots = []
|
|
32
|
+
stack = []
|
|
33
|
+
ContentLine::Reader.new(source, max_line_length: @limits[:line_length]).each do |line|
|
|
34
|
+
case line.name
|
|
35
|
+
when "begin"
|
|
36
|
+
begin_component(line, stack)
|
|
37
|
+
when "end"
|
|
38
|
+
end_component(line, stack, roots)
|
|
39
|
+
else
|
|
40
|
+
raise ParseError.new("property outside a component", line_number: line.line_number) if stack.empty?
|
|
41
|
+
|
|
42
|
+
count_property!(line_number: line.line_number)
|
|
43
|
+
prop = read_property(line)
|
|
44
|
+
stack.last[:properties] << prop
|
|
45
|
+
stack.last[:warnings].concat(prop.warnings)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
raise ParseError, "missing END content line" unless stack.empty?
|
|
49
|
+
raise ParseError, "no component found" if roots.empty?
|
|
50
|
+
return roots if @format == :vcard
|
|
51
|
+
|
|
52
|
+
raise ParseError, "expected exactly one VCALENDAR" unless roots.size == 1
|
|
53
|
+
|
|
54
|
+
roots.first
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.write(component, format:, registry: nil, max_binary_size: 1_048_576)
|
|
58
|
+
new(format: format, registry: registry, max_binary_size: max_binary_size).write_component(component)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def write_component(component)
|
|
62
|
+
writer = ContentLine::Writer
|
|
63
|
+
result = writer.line(name: "begin", params: {}, value: component.name.upcase)
|
|
64
|
+
properties = component.properties
|
|
65
|
+
properties = properties.partition { |property| property.name == "version" }.flatten if @format == :vcard
|
|
66
|
+
properties.each { |property| result << write_property(property) }
|
|
67
|
+
component.components.each { |child| result << write_component(child) }
|
|
68
|
+
result << writer.line(name: "end", params: {}, value: component.name.upcase)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def check_source(source)
|
|
74
|
+
raise ParseError, "input must be a string" unless source.is_a?(String)
|
|
75
|
+
raise ParseError, "input size limit exceeded" if source.bytesize > @limits[:input_size]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def count_property!(**location)
|
|
79
|
+
@property_count += 1
|
|
80
|
+
raise ParseError.new("property count limit exceeded", **location) if @property_count > @limits[:properties]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def read_component(data, pointer, depth)
|
|
84
|
+
raise ParseError.new("component depth limit exceeded", pointer: pointer) if depth > @limits[:depth]
|
|
85
|
+
|
|
86
|
+
size = @format == :vcard ? 2 : 3
|
|
87
|
+
valid = data.is_a?(Array) && data.size == size && data[0].is_a?(String) && /\A[a-z0-9-]+\z/.match?(data[0]) && data[1].is_a?(Array)
|
|
88
|
+
valid &&= data[2].is_a?(Array) if @format == :ical
|
|
89
|
+
raise ParseError.new("invalid component structure", pointer: pointer) unless valid
|
|
90
|
+
|
|
91
|
+
props = data[1].each_with_index.map do |raw, index|
|
|
92
|
+
path = "#{pointer}/1/#{index}"
|
|
93
|
+
count_property!(pointer: path)
|
|
94
|
+
property_class.new(raw, **property_options, pointer: path)
|
|
95
|
+
end
|
|
96
|
+
children = @format == :ical ? data[2].each_with_index.map { |child, index| read_component(child, "#{pointer}/2/#{index}", depth + 1) } : []
|
|
97
|
+
build_component(data[0], props, children, props.flat_map(&:warnings) + children.flat_map(&:warnings))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def begin_component(line, stack)
|
|
101
|
+
validate_boundary(line)
|
|
102
|
+
name = line.value.downcase
|
|
103
|
+
expected = @format == :vcard ? "vcard" : "vcalendar"
|
|
104
|
+
raise ParseError.new("expected #{expected} component", line_number: line.line_number) if stack.empty? && name != expected
|
|
105
|
+
if (@format == :vcard && !stack.empty?) || stack.size >= @limits[:depth]
|
|
106
|
+
raise ParseError.new("component depth limit exceeded", line_number: line.line_number)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
stack << { name: name, properties: [], children: [], warnings: [] }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def end_component(line, stack, roots)
|
|
113
|
+
validate_boundary(line)
|
|
114
|
+
raise ParseError.new("BEGIN/END mismatch", line_number: line.line_number) if stack.empty? || stack.last[:name] != line.value.downcase
|
|
115
|
+
|
|
116
|
+
frame = stack.pop
|
|
117
|
+
component = build_component(frame[:name], frame[:properties], frame[:children], frame[:warnings])
|
|
118
|
+
if stack.empty?
|
|
119
|
+
roots << component
|
|
120
|
+
else
|
|
121
|
+
stack.last[:children] << component
|
|
122
|
+
stack.last[:warnings].concat(component.warnings)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def validate_boundary(line)
|
|
127
|
+
return if line.group.nil? && line.params.empty? && /\A[a-zA-Z0-9-]+\z/.match?(line.value)
|
|
128
|
+
|
|
129
|
+
raise ParseError.new("invalid component boundary", line_number: line.line_number)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def build_component(name, properties, children, warnings)
|
|
133
|
+
klass = if @format == :vcard
|
|
134
|
+
JCard::Card
|
|
135
|
+
elsif name == "vcalendar"
|
|
136
|
+
JCal::Calendar
|
|
137
|
+
else
|
|
138
|
+
JCal::Component
|
|
139
|
+
end
|
|
140
|
+
result = klass.new(name, properties, children, warnings: warnings, registry: @registry, max_binary_size: @limits[:binary_size])
|
|
141
|
+
begin
|
|
142
|
+
result.validate!
|
|
143
|
+
rescue ValidationError => e
|
|
144
|
+
raise if @strict || e.is_a?(LimitError)
|
|
145
|
+
|
|
146
|
+
warnings << e unless warnings.any? { |warning| warning.message == e.message }
|
|
147
|
+
end
|
|
148
|
+
result
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def property_class = @format == :vcard ? JCard::Property : JCal::Property
|
|
152
|
+
def property_options = { registry: @registry, strict: @strict, max_binary_size: @limits[:binary_size] }
|
|
153
|
+
|
|
154
|
+
def read_property(line)
|
|
155
|
+
params = line.params.dup
|
|
156
|
+
if @format == :ical && (line.group || params["encoding"].is_a?(Array))
|
|
157
|
+
raise ValidationError.new("iCalendar disallows groups and multiple ENCODING values", line_number: line.line_number)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
explicit_type = params.delete("value")
|
|
161
|
+
raise ParseError.new("VALUE must have one value", line_number: line.line_number) if explicit_type.is_a?(Array)
|
|
162
|
+
if (@format == :vcard && params.key?("group")) || explicit_type&.casecmp?("unknown")
|
|
163
|
+
raise ValidationError.new("reserved GROUP or UNKNOWN declaration", line_number: line.line_number)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
params["group"] = line.group if line.group
|
|
167
|
+
params["sort-as"] = params["sort-as"].split(",", -1) if @format == :vcard && params["sort-as"].is_a?(String) && params["sort-as"].include?(",")
|
|
168
|
+
definition = @registry[line.name]
|
|
169
|
+
type = explicit_type ? explicit_type.downcase : definition["type"]
|
|
170
|
+
begin
|
|
171
|
+
value = line.value
|
|
172
|
+
if @format == :ical && type != "binary" && params["encoding"]&.casecmp?("BASE64")
|
|
173
|
+
ValueTypes.from_json(value, "binary", format: :ical, max_binary_size: @limits[:binary_size])
|
|
174
|
+
value = value.unpack1("m0").force_encoding(Encoding::UTF_8)
|
|
175
|
+
params.delete("encoding")
|
|
176
|
+
end
|
|
177
|
+
values = parse_values(value, type, definition)
|
|
178
|
+
property_class.new([line.name, params, type, *values], **property_options, strict: true, line_number: line.line_number)
|
|
179
|
+
rescue ValueError, ValidationError => e
|
|
180
|
+
located = e.class.new(e.message, line_number: line.line_number)
|
|
181
|
+
raise located if @strict || e.is_a?(LimitError)
|
|
182
|
+
|
|
183
|
+
property = property_class.new([line.name, params, "unknown", line.value], **property_options, line_number: line.line_number)
|
|
184
|
+
property.warnings << located
|
|
185
|
+
property
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def parse_values(value, type, definition)
|
|
190
|
+
if definition["structured"] && type != "unknown"
|
|
191
|
+
parts = ContentLine::TextCodec.split(value, ";").map do |part|
|
|
192
|
+
raw_items = definition["component_multiple"] ? ContentLine::TextCodec.split(part, ",") : [part]
|
|
193
|
+
items = raw_items.map { |item| parse_scalar(item, type) }
|
|
194
|
+
items.size == 1 ? items.first : items
|
|
195
|
+
end
|
|
196
|
+
parts.size == 1 ? parts : [parts]
|
|
197
|
+
else
|
|
198
|
+
values = definition["multiple"] && type != "unknown" ? ContentLine::TextCodec.split(value, ",") : [value]
|
|
199
|
+
values.map { |item| parse_scalar(item, type) }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def parse_scalar(value, type)
|
|
204
|
+
ValueTypes.from_text(value, type, format: @format, max_binary_size: @limits[:binary_size])
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def write_property(property)
|
|
208
|
+
property.validate!
|
|
209
|
+
definition = @registry[property.name]
|
|
210
|
+
params = property.params
|
|
211
|
+
group = @format == :vcard ? params.delete("group") : nil
|
|
212
|
+
params["sort-as"] = params["sort-as"].join(",") if @format == :vcard && params["sort-as"].is_a?(Array)
|
|
213
|
+
params["encoding"] = "BASE64" if @format == :ical && property.type == "binary"
|
|
214
|
+
params["value"] = property.type.upcase if property.type != "unknown" && (property.type != definition["type"] || definition["value_required"])
|
|
215
|
+
values = property.values.map do |value|
|
|
216
|
+
if value.is_a?(Array) && (definition["structured"] || property.type == "text")
|
|
217
|
+
value.map { |part| Array(part).map { |item| write_scalar(item, property.type) }.join(",") }.join(";")
|
|
218
|
+
else
|
|
219
|
+
write_scalar(value, property.type)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
ContentLine::Writer.line(name: property.name, params: params, value: values.join(","), group: group)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def write_scalar(value, type)
|
|
226
|
+
ValueTypes.to_text(value, type, format: @format, max_binary_size: @limits[:binary_size])
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JCardJCal
|
|
4
|
+
# Location-aware failure in a content line or JSON value.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
attr_reader :line_number, :pointer
|
|
7
|
+
|
|
8
|
+
def initialize(message, line_number: nil, pointer: nil)
|
|
9
|
+
@line_number = line_number
|
|
10
|
+
@pointer = pointer
|
|
11
|
+
location = line_number ? "line #{line_number}" : pointer
|
|
12
|
+
super(location ? "#{location}: #{message}" : message)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class ParseError < Error; end
|
|
17
|
+
class ValidationError < Error; end
|
|
18
|
+
class ValueError < ValidationError; end
|
|
19
|
+
class LimitError < ValueError; end
|
|
20
|
+
class ConversionError < Error; end
|
|
21
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JCardJCal
|
|
4
|
+
# A jCard/jCal property, retaining unrecognized parameters and values.
|
|
5
|
+
class Property
|
|
6
|
+
attr_reader :warnings
|
|
7
|
+
|
|
8
|
+
def initialize(data, registry: nil, strict: true, pointer: "", line_number: nil, max_binary_size: 1_048_576)
|
|
9
|
+
@registry = registry || default_registry
|
|
10
|
+
@pointer = pointer
|
|
11
|
+
@line_number = line_number
|
|
12
|
+
@max_binary_size = max_binary_size
|
|
13
|
+
@warnings = []
|
|
14
|
+
@data = data
|
|
15
|
+
unless data.is_a?(Array) && data.size >= 4 && data[0].is_a?(String) && data[1].is_a?(Hash) && data[2].is_a?(String)
|
|
16
|
+
raise ParseError.new("property must contain a name, parameters, type and value", pointer: pointer, line_number: line_number)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
validate!
|
|
20
|
+
@data = copy(data)
|
|
21
|
+
rescue ValidationError => e
|
|
22
|
+
raise if strict || e.is_a?(LimitError)
|
|
23
|
+
|
|
24
|
+
@warnings << e
|
|
25
|
+
@data = copy(data)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def name = @data[0]
|
|
29
|
+
def params = copy(@data[1])
|
|
30
|
+
def type = @data[2]
|
|
31
|
+
def values = copy(@data[3..])
|
|
32
|
+
def group = @data[1]["group"]
|
|
33
|
+
def to_a = copy(@data)
|
|
34
|
+
|
|
35
|
+
def validate!
|
|
36
|
+
fail_at("property must contain a name, parameters, type and value") unless @data.is_a?(Array) && @data.size >= 4
|
|
37
|
+
@data[3..].each_with_index { |value, index| validate_binary_size(value, "/#{index + 3}") } if @data[2].casecmp?("binary")
|
|
38
|
+
fail_at("invalid property name", "/0") unless token?(@data[0]) && !%w[begin end].include?(@data[0])
|
|
39
|
+
validate_params
|
|
40
|
+
fail_at("invalid value type", "/2") unless token?(@data[2])
|
|
41
|
+
definition = @registry[@data[0]]
|
|
42
|
+
allowed = definition["types"]
|
|
43
|
+
if @data[2] != "unknown" && (definition["type"] != "unknown" || definition["value_required"]) && allowed && !allowed.include?(@data[2])
|
|
44
|
+
fail_at("value type is not allowed for this property", "/2")
|
|
45
|
+
end
|
|
46
|
+
fail_at("property requires a single value", "/3") if definition["source"] && !definition["multiple"] && @data.size != 4
|
|
47
|
+
@data[3..].each_with_index { |value, index| validate_value(value, "/#{index + 3}") }
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def copy(value)
|
|
54
|
+
JSON.parse(JSON.generate(value))
|
|
55
|
+
rescue JSON::GeneratorError => e
|
|
56
|
+
raise ParseError.new(e.message, pointer: @pointer, line_number: @line_number)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def token?(value) = value.is_a?(String) && /\A[a-z0-9-]+\z/.match?(value)
|
|
60
|
+
|
|
61
|
+
def validate_params
|
|
62
|
+
fail_at("parameters must be an object", "/1") unless @data[1].is_a?(Hash)
|
|
63
|
+
@data[1].each do |key, value|
|
|
64
|
+
path = "/1/#{key.to_s.gsub("~", "~0").gsub("/", "~1")}"
|
|
65
|
+
fail_at("invalid parameter name", path) unless token?(key) && key != "value"
|
|
66
|
+
valid = value.is_a?(String) || (value.is_a?(Array) && !value.empty? && value.all?(String))
|
|
67
|
+
fail_at("parameter must be a string or nonempty array of strings", path) unless valid
|
|
68
|
+
validate_parameter_value(value, path)
|
|
69
|
+
fail_at("invalid group parameter", path) if format == :vcard && key == "group" && (!value.is_a?(String) || !/\A[a-zA-Z0-9-]+\z/.match?(value))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_parameter_value(value, path)
|
|
74
|
+
Array(value).each { |item| ContentLine::ParamCodec.encode(item) }
|
|
75
|
+
rescue ValueError => e
|
|
76
|
+
fail_at(e.message, path, e.class)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def validate_value(value, path)
|
|
80
|
+
definition = @registry[name]
|
|
81
|
+
structured = definition["structured"] || (!definition["source"] && type == "text" && value.is_a?(Array))
|
|
82
|
+
return validate_scalar(value, path) unless structured && type != "unknown"
|
|
83
|
+
|
|
84
|
+
parts = value.is_a?(Array) ? value : [value]
|
|
85
|
+
counts = definition["components"]
|
|
86
|
+
fail_at("invalid number of structured components", path) if parts.empty? || (counts && !counts.include?(parts.size))
|
|
87
|
+
parts.each_with_index do |part, index|
|
|
88
|
+
if part.is_a?(Array)
|
|
89
|
+
fail_at("component lists are not allowed", "#{path}/#{index}") unless (definition["component_multiple"] || !definition["source"]) && !part.empty?
|
|
90
|
+
part.each_with_index { |item, subindex| validate_scalar(item, "#{path}/#{index}/#{subindex}") }
|
|
91
|
+
else
|
|
92
|
+
validate_scalar(part, "#{path}/#{index}")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_scalar(value, path)
|
|
98
|
+
ValueTypes.from_json(value, type, format: format, max_binary_size: @max_binary_size)
|
|
99
|
+
rescue ValueError => e
|
|
100
|
+
fail_at(e.message, path, e.class)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_binary_size(value, path)
|
|
104
|
+
ValueTypes.validate_binary_size(value, @max_binary_size)
|
|
105
|
+
rescue LimitError => e
|
|
106
|
+
fail_at(e.message, path, LimitError)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def fail_at(message, suffix = "", error_class = ValidationError)
|
|
110
|
+
raise error_class.new(message, pointer: "#{@pointer}#{suffix}", line_number: @line_number)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module JCardJCal
|
|
6
|
+
# Per-conversion registry: duplicate a frozen default to add application properties.
|
|
7
|
+
class Registry
|
|
8
|
+
UNKNOWN = { "type" => "unknown", "types" => ["unknown"].freeze, "multiple" => false,
|
|
9
|
+
"structured" => false, "source" => nil }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(entries = {})
|
|
12
|
+
@entries = {}
|
|
13
|
+
entries.each { |name, metadata| register(name, **metadata.transform_keys(&:to_sym)) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize_copy(other)
|
|
17
|
+
super
|
|
18
|
+
@entries = @entries.dup
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def [](name)
|
|
22
|
+
@entries.fetch(name.to_s.downcase, UNKNOWN)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def register(name, type:, types: nil, multiple: false, structured: false, **metadata)
|
|
26
|
+
raise FrozenError, "duplicate the default registry before registering properties" if frozen?
|
|
27
|
+
|
|
28
|
+
name = name.to_s.downcase
|
|
29
|
+
type = type.to_s.downcase
|
|
30
|
+
types = (types || [type]).map { |item| item.to_s.downcase }
|
|
31
|
+
unless /\A[a-z0-9-]+\z/.match?(name) && /\A[a-z0-9-]+\z/.match?(type) &&
|
|
32
|
+
!types.empty? && types.all? { |item| /\A[a-z0-9-]+\z/.match?(item) }
|
|
33
|
+
raise ArgumentError, "invalid property name or value type"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@entries[name] = immutable(metadata.transform_keys(&:to_s).merge(
|
|
37
|
+
"type" => type, "types" => types, "multiple" => multiple, "structured" => structured,
|
|
38
|
+
"source" => metadata[:source]
|
|
39
|
+
))
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def freeze
|
|
44
|
+
@entries.freeze
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.load(filename)
|
|
49
|
+
new(YAML.safe_load_file(File.expand_path("../../data/#{filename}", __dir__))).freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def immutable(value)
|
|
55
|
+
case value
|
|
56
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
|
|
57
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
58
|
+
when String then value.dup.freeze
|
|
59
|
+
else value.freeze
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
module JCard # rubocop:disable Style/OneClassPerFile
|
|
66
|
+
class Registry < JCardJCal::Registry
|
|
67
|
+
DEFAULT = load("vcard_properties.yml")
|
|
68
|
+
|
|
69
|
+
def self.default
|
|
70
|
+
DEFAULT
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
module JCal # rubocop:disable Style/OneClassPerFile
|
|
76
|
+
class Registry < JCardJCal::Registry
|
|
77
|
+
DEFAULT = load("ical_properties.yml")
|
|
78
|
+
|
|
79
|
+
def self.default
|
|
80
|
+
DEFAULT
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|