lutaml-vformat 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/.gitignore +15 -0
- data/.rspec +3 -0
- data/.rubocop.yml +10 -0
- data/Gemfile +10 -0
- data/LICENSE.md +33 -0
- data/README.adoc +172 -0
- data/Rakefile +12 -0
- data/lib/lutaml/vformat/adapter.rb +10 -0
- data/lib/lutaml/vformat/codec.rb +58 -0
- data/lib/lutaml/vformat/component.rb +34 -0
- data/lib/lutaml/vformat/document.rb +31 -0
- data/lib/lutaml/vformat/errors.rb +17 -0
- data/lib/lutaml/vformat/escaping.rb +36 -0
- data/lib/lutaml/vformat/folding.rb +55 -0
- data/lib/lutaml/vformat/mapping.rb +107 -0
- data/lib/lutaml/vformat/mapping_rule.rb +23 -0
- data/lib/lutaml/vformat/model_reader.rb +116 -0
- data/lib/lutaml/vformat/model_support.rb +31 -0
- data/lib/lutaml/vformat/model_writer.rb +101 -0
- data/lib/lutaml/vformat/parser.rb +146 -0
- data/lib/lutaml/vformat/property.rb +49 -0
- data/lib/lutaml/vformat/structured.rb +44 -0
- data/lib/lutaml/vformat/transform.rb +41 -0
- data/lib/lutaml/vformat/value/base.rb +45 -0
- data/lib/lutaml/vformat/value/date_and_or_time.rb +70 -0
- data/lib/lutaml/vformat/value/scalar.rb +103 -0
- data/lib/lutaml/vformat/value/text.rb +126 -0
- data/lib/lutaml/vformat/value.rb +43 -0
- data/lib/lutaml/vformat/version.rb +7 -0
- data/lib/lutaml/vformat/writer.rb +50 -0
- data/lib/lutaml/vformat.rb +46 -0
- data/lutaml-vformat.gemspec +38 -0
- metadata +94 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# Components to models.
|
|
6
|
+
class ModelReader
|
|
7
|
+
include ModelSupport
|
|
8
|
+
|
|
9
|
+
def initialize(register)
|
|
10
|
+
@register = register
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def component(klass, component)
|
|
14
|
+
mapping = klass.mappings_for(:vobject)
|
|
15
|
+
attrs = attrs_of(klass)
|
|
16
|
+
values = {}
|
|
17
|
+
values.update(property_values(mapping, attrs, component))
|
|
18
|
+
values.update(child_values(mapping, attrs, component))
|
|
19
|
+
build(klass, values)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def build(klass, values)
|
|
25
|
+
klass.new(values.reject { |_, v| v.nil? || v == [] })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def pick(attribute, list)
|
|
29
|
+
attribute.collection? ? list : list.first
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def property_values(mapping, attrs, component)
|
|
33
|
+
mapping.rules(:property).to_h do |rule|
|
|
34
|
+
attribute = attrs.fetch(rule.to)
|
|
35
|
+
[rule.to, read_properties(attribute, rule, component)]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def child_values(mapping, attrs, component)
|
|
40
|
+
mapping.rules(:component).to_h do |rule|
|
|
41
|
+
attribute = attrs.fetch(rule.to)
|
|
42
|
+
children = component.components_named(rule.name).map do |child|
|
|
43
|
+
component(type_of(attribute), child)
|
|
44
|
+
end
|
|
45
|
+
[rule.to, pick(attribute, children)]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def read_properties(attribute, rule, component)
|
|
50
|
+
type = type_of(attribute)
|
|
51
|
+
list = component.properties_named(rule.name).map do |prop|
|
|
52
|
+
if property_model?(type)
|
|
53
|
+
property_model(type, prop)
|
|
54
|
+
else
|
|
55
|
+
leaf(type, prop, rule.value_type)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
pick(attribute, list)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def leaf(type, prop, value_type)
|
|
62
|
+
Codec.read(type, prop.value, value_type: value_type)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def property_model(klass, prop)
|
|
66
|
+
mapping = klass.mappings_for(:vobject)
|
|
67
|
+
attrs = attrs_of(klass)
|
|
68
|
+
values = value_and_group(mapping, attrs, prop)
|
|
69
|
+
values.update(params(mapping, attrs, prop))
|
|
70
|
+
values.update(fields(mapping, attrs, prop))
|
|
71
|
+
build(klass, values)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def value_and_group(mapping, attrs, prop)
|
|
75
|
+
values = mapping.rules(:value).to_h do |rule|
|
|
76
|
+
type = type_of(attrs.fetch(rule.to))
|
|
77
|
+
[rule.to, leaf(type, prop, rule.value_type)]
|
|
78
|
+
end
|
|
79
|
+
mapping.rules(:group).each { |rule| values[rule.to] = prop.group }
|
|
80
|
+
values
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def params(mapping, attrs, prop)
|
|
84
|
+
mapping.rules(:param).to_h do |rule|
|
|
85
|
+
attribute = attrs.fetch(rule.to)
|
|
86
|
+
type = type_of(attribute)
|
|
87
|
+
list = prop.param_values(rule.name).map do |text|
|
|
88
|
+
Codec.read_param(type, text, value_type: rule.value_type)
|
|
89
|
+
end
|
|
90
|
+
[rule.to, pick(attribute, list)]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def fields(mapping, attrs, prop)
|
|
95
|
+
rules = mapping.rules(:field)
|
|
96
|
+
return {} if rules.empty?
|
|
97
|
+
|
|
98
|
+
slots = Structured.split_raw(prop.value)
|
|
99
|
+
rules.to_h do |rule|
|
|
100
|
+
attribute = attrs.fetch(rule.to)
|
|
101
|
+
[rule.to, pick(attribute, field_values(attribute, rule, slots))]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def field_values(attribute, rule, slots)
|
|
106
|
+
type = type_of(attribute)
|
|
107
|
+
# An empty field is no value; an empty item inside a list is kept.
|
|
108
|
+
values = Array(slots[rule.name])
|
|
109
|
+
values = [] if values == [""]
|
|
110
|
+
values.map do |text|
|
|
111
|
+
Codec.read(type, text, value_type: rule.value_type)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# Attribute and mapping lookups shared by ModelReader and ModelWriter.
|
|
6
|
+
module ModelSupport
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def attrs_of(klass)
|
|
10
|
+
klass.attributes(@register)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def type_of(attribute)
|
|
14
|
+
attribute.type(@register)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def property_model?(type)
|
|
18
|
+
type.is_a?(Class) && type <= Lutaml::Model::Serialize &&
|
|
19
|
+
type.mappings_for(:vobject)&.element_type == :property
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Array() would splat Struct-based values such as
|
|
23
|
+
# DateAndOrTime::Instant.
|
|
24
|
+
def list_of(value)
|
|
25
|
+
return [] if value.nil?
|
|
26
|
+
|
|
27
|
+
value.is_a?(::Array) ? value : [value]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# Models to components.
|
|
6
|
+
class ModelWriter
|
|
7
|
+
include ModelSupport
|
|
8
|
+
|
|
9
|
+
def initialize(register)
|
|
10
|
+
@register = register
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def component(name, klass, model)
|
|
14
|
+
mapping = klass.mappings_for(:vobject)
|
|
15
|
+
attrs = attrs_of(klass)
|
|
16
|
+
Component.new(
|
|
17
|
+
name,
|
|
18
|
+
properties: properties(mapping, attrs, model),
|
|
19
|
+
components: children(mapping, attrs, model),
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def properties(mapping, attrs, model)
|
|
26
|
+
mapping.rules(:property).flat_map do |rule|
|
|
27
|
+
attribute = attrs.fetch(rule.to)
|
|
28
|
+
list_of(model.public_send(rule.to)).map do |value|
|
|
29
|
+
property(rule, type_of(attribute), value)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def children(mapping, attrs, model)
|
|
35
|
+
mapping.rules(:component).flat_map do |rule|
|
|
36
|
+
type = type_of(attrs.fetch(rule.to))
|
|
37
|
+
list_of(model.public_send(rule.to)).map do |child|
|
|
38
|
+
component(rule.name, type, child)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def property(rule, type, value)
|
|
44
|
+
return property_model(rule.name, type, value) if property_model?(type)
|
|
45
|
+
|
|
46
|
+
Property.new(rule.name,
|
|
47
|
+
Codec.write(type, value, value_type: rule.value_type))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def property_model(name, klass, model)
|
|
51
|
+
mapping = klass.mappings_for(:vobject)
|
|
52
|
+
attrs = attrs_of(klass)
|
|
53
|
+
group_rule = mapping.rules(:group).first
|
|
54
|
+
Property.new(
|
|
55
|
+
name, model_value(mapping, attrs, model),
|
|
56
|
+
params: params(mapping, attrs, model),
|
|
57
|
+
group: group_rule && model.public_send(group_rule.to)
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def params(mapping, attrs, model)
|
|
62
|
+
mapping.rules(:param).each_with_object({}) do |rule, result|
|
|
63
|
+
list = param_list(rule, attrs, model)
|
|
64
|
+
result[rule.name] = list unless list.empty?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def param_list(rule, attrs, model)
|
|
69
|
+
type = type_of(attrs.fetch(rule.to))
|
|
70
|
+
list_of(model.public_send(rule.to)).map do |v|
|
|
71
|
+
Codec.write_param(type, v, value_type: rule.value_type)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def model_value(mapping, attrs, model)
|
|
76
|
+
fields = mapping.rules(:field)
|
|
77
|
+
return fields_value(fields, attrs, model) unless fields.empty?
|
|
78
|
+
|
|
79
|
+
rule = mapping.rules(:value).first
|
|
80
|
+
value = rule && model.public_send(rule.to)
|
|
81
|
+
return "" if value.nil?
|
|
82
|
+
|
|
83
|
+
Codec.write(type_of(attrs.fetch(rule.to)), value,
|
|
84
|
+
value_type: rule.value_type)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def fields_value(rules, attrs, model)
|
|
88
|
+
slots = Array.new(rules.map(&:name).max + 1) { [] }
|
|
89
|
+
rules.each { |rule| slots[rule.name] = field_list(rule, attrs, model) }
|
|
90
|
+
Structured.join_raw(slots)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def field_list(rule, attrs, model)
|
|
94
|
+
type = type_of(attrs.fetch(rule.to))
|
|
95
|
+
list_of(model.public_send(rule.to)).map do |v|
|
|
96
|
+
Codec.write(type, v, value_type: rule.value_type)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strscan"
|
|
4
|
+
|
|
5
|
+
module Lutaml
|
|
6
|
+
module Vformat
|
|
7
|
+
# Text to Components. Unfolds first, then reads content lines.
|
|
8
|
+
class Parser
|
|
9
|
+
NAME = /[A-Za-z0-9-]+/
|
|
10
|
+
BARE_ENCODINGS = %w[BASE64 QUOTED-PRINTABLE 8BIT 7BIT].freeze
|
|
11
|
+
GROUP_AND_NAME = /(?:(?<group>#{NAME})\.)?(?<name>#{NAME})/
|
|
12
|
+
PARAM_VALUE = /"[^"]*"|[^";:,]*/
|
|
13
|
+
|
|
14
|
+
def self.parse(data)
|
|
15
|
+
new(data).parse
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(data)
|
|
19
|
+
@text = normalize(data)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Array<Component>] the top-level components
|
|
23
|
+
def parse
|
|
24
|
+
roots = []
|
|
25
|
+
stack = []
|
|
26
|
+
lines.each { |line, number| handle(line, number, roots, stack) }
|
|
27
|
+
unless stack.empty?
|
|
28
|
+
raise ParseError,
|
|
29
|
+
"missing END:#{stack.last.name} (unterminated component)"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
roots
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Non-blank unfolded lines with their 1-based number. LF, CRLF and
|
|
38
|
+
# CR CR LF endings are all read.
|
|
39
|
+
def lines
|
|
40
|
+
Folding.unfold(lf_endings(@text)).split("\n").each_with_index
|
|
41
|
+
.reject { |line, _| line.strip.empty? }
|
|
42
|
+
.map { |line, index| [line, index + 1] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Linear, unlike a /\r+\n/ match, on long runs of CR.
|
|
46
|
+
def lf_endings(text)
|
|
47
|
+
text.split("\n", -1).map do |line|
|
|
48
|
+
line = line.chop while line.end_with?("\r")
|
|
49
|
+
line
|
|
50
|
+
end.join("\n")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def normalize(data)
|
|
54
|
+
text = data.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
55
|
+
text = text.delete_prefix("")
|
|
56
|
+
raise ParseError, "input is not valid UTF-8" unless text.valid_encoding?
|
|
57
|
+
|
|
58
|
+
text
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def handle(line, number, roots, stack)
|
|
62
|
+
property = parse_line(line, number)
|
|
63
|
+
case property.name
|
|
64
|
+
when "BEGIN" then open_component(property, stack)
|
|
65
|
+
when "END" then close_component(property, number, roots, stack)
|
|
66
|
+
else add_property(property, number, stack)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def open_component(property, stack)
|
|
71
|
+
stack.push(Component.new(property.value.strip))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def close_component(property, number, roots, stack)
|
|
75
|
+
name = property.value.strip.upcase
|
|
76
|
+
current = stack.pop
|
|
77
|
+
if current.nil? || current.name != name
|
|
78
|
+
raise ParseError,
|
|
79
|
+
"line #{number}: END:#{name} does not match " \
|
|
80
|
+
"#{current ? "BEGIN:#{current.name}" : 'any open component'}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
(stack.last ? stack.last.components : roots) << current
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def add_property(property, number, stack)
|
|
87
|
+
if stack.empty?
|
|
88
|
+
raise ParseError,
|
|
89
|
+
"line #{number}: property outside a component"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
stack.last.properties << property
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def parse_line(line, number)
|
|
96
|
+
scanner = StringScanner.new(line)
|
|
97
|
+
unless scanner.scan(GROUP_AND_NAME)
|
|
98
|
+
raise ParseError, "line #{number}: invalid property name"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
group = scanner[:group]
|
|
102
|
+
name = scanner[:name]
|
|
103
|
+
params = scan_params(scanner, number)
|
|
104
|
+
raise ParseError, "line #{number}: missing ':'" unless scanner.scan(":")
|
|
105
|
+
|
|
106
|
+
Property.new(name, scanner.rest, params: params, group: group)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def scan_params(scanner, number)
|
|
110
|
+
params = {}
|
|
111
|
+
while scanner.scan(";")
|
|
112
|
+
name, values = scan_param(scanner, number)
|
|
113
|
+
(params[name] ||= []).concat(values)
|
|
114
|
+
end
|
|
115
|
+
params
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def scan_param(scanner, number)
|
|
119
|
+
unless scanner.scan(NAME)
|
|
120
|
+
raise ParseError, "line #{number}: invalid parameter name"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
name = scanner.matched.upcase
|
|
124
|
+
return bare_param(name) unless scanner.scan("=")
|
|
125
|
+
|
|
126
|
+
[name, scan_param_values(scanner)]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# vCard 2.1 style "TEL;WORK:" or "PHOTO;BASE64:". Address books still
|
|
130
|
+
# write these in 3.0 cards, so read them as TYPE or ENCODING values.
|
|
131
|
+
def bare_param(name)
|
|
132
|
+
BARE_ENCODINGS.include?(name) ? ["ENCODING", [name]] : ["TYPE", [name]]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def scan_param_values(scanner)
|
|
136
|
+
values = []
|
|
137
|
+
loop do
|
|
138
|
+
scanner.scan(PARAM_VALUE)
|
|
139
|
+
values << scanner.matched.delete_prefix('"').delete_suffix('"')
|
|
140
|
+
break unless scanner.scan(",")
|
|
141
|
+
end
|
|
142
|
+
values
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# One content line: [group.]NAME[;PARAM=v1,v2]...:value
|
|
6
|
+
#
|
|
7
|
+
# +value+ is the raw wire text (still escaped); use #text or #fields for
|
|
8
|
+
# the unescaped views. Names and parameter names are case-insensitive and
|
|
9
|
+
# kept upper-case.
|
|
10
|
+
class Property
|
|
11
|
+
attr_reader :name, :group, :params, :value
|
|
12
|
+
|
|
13
|
+
def initialize(name, value = "", params: {}, group: nil)
|
|
14
|
+
@name = name.to_s.upcase
|
|
15
|
+
@group = group
|
|
16
|
+
@params = params.each_with_object({}) do |(k, v), h|
|
|
17
|
+
h[k.to_s.upcase] = Array(v).map(&:to_s)
|
|
18
|
+
end
|
|
19
|
+
@value = value.to_s
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def param(name)
|
|
23
|
+
params[name.to_s.upcase]&.first
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def param_values(name)
|
|
27
|
+
params[name.to_s.upcase] || []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The VALUE parameter, lower-cased, or nil.
|
|
31
|
+
def value_type
|
|
32
|
+
param("VALUE")&.downcase
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def text
|
|
36
|
+
Escaping.unescape(value)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def fields
|
|
40
|
+
Structured.split(value)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ==(other)
|
|
44
|
+
other.is_a?(Property) && [name, group, params, value] ==
|
|
45
|
+
[other.name, other.group, other.params, other.value]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# Structured values: fields separated by ";", each field holding one or
|
|
6
|
+
# more values separated by ",". Separators are only those not escaped
|
|
7
|
+
# with a backslash.
|
|
8
|
+
module Structured
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @return [Array<Array<String>>] fields, each an array of unescaped values
|
|
12
|
+
def split(raw)
|
|
13
|
+
split_raw(raw).map { |field| field.map { |v| Escaping.unescape(v) } }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Like #split, but values stay in wire (escaped) form.
|
|
17
|
+
def split_raw(raw)
|
|
18
|
+
split_unescaped(raw, ";").map { |field| split_unescaped(field, ",") }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Joins fields of already-escaped values.
|
|
22
|
+
def join_raw(fields)
|
|
23
|
+
fields.map { |field| Array(field).join(",") }.join(";")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @param fields [Array<String, Array<String>, nil>]
|
|
27
|
+
def join(fields)
|
|
28
|
+
fields.map do |field|
|
|
29
|
+
Array(field).map { |v| Escaping.escape(v) }.join(",")
|
|
30
|
+
end.join(";")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Splits on +separator+ except where a backslash escapes it; the
|
|
34
|
+
# backslashes stay in the parts.
|
|
35
|
+
def split_unescaped(raw, separator)
|
|
36
|
+
parts = [+""]
|
|
37
|
+
raw.scan(/\\.|./m) do |token|
|
|
38
|
+
token == separator ? parts << +"" : parts.last << token
|
|
39
|
+
end
|
|
40
|
+
parts
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# The transformer registered for :vobject: models from a parsed Document
|
|
6
|
+
# and Documents from models.
|
|
7
|
+
class Transform < Lutaml::Model::Transform
|
|
8
|
+
def self.data_to_model(context, data, _format, options = {})
|
|
9
|
+
new(context, options[:register]).data_to_model(data)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.model_to_data(context, model, _format, options = {})
|
|
13
|
+
new(context, options[:register]).model_to_data(model)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def data_to_model(document)
|
|
17
|
+
mapping = context.mappings_for(:vobject)
|
|
18
|
+
name = mapping.component_name
|
|
19
|
+
root = document.components.find { |c| name.nil? || c.name == name }
|
|
20
|
+
unless root
|
|
21
|
+
raise ParseError,
|
|
22
|
+
"no #{name || 'component'} component in the input"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
ModelReader.new(lutaml_register).component(context, root)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def model_to_data(model)
|
|
29
|
+
name = context.mappings_for(:vobject).component_name
|
|
30
|
+
unless name
|
|
31
|
+
raise SerializeError,
|
|
32
|
+
"#{context} declares no component_root, so it cannot be " \
|
|
33
|
+
"written as a top-level component"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
writer = ModelWriter.new(lutaml_register)
|
|
37
|
+
Document.new([writer.component(name, context, model)])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
module Value
|
|
6
|
+
# A vObject value type. Subclasses define .parse (wire text to a Ruby
|
|
7
|
+
# object, raising ValueError) and .format (Ruby object to wire text).
|
|
8
|
+
# They are also lutaml-model attribute types: assigning wire text or a
|
|
9
|
+
# native object casts to the native object.
|
|
10
|
+
class Base < Lutaml::Model::Type::Value
|
|
11
|
+
# Whether wire text of this type is subject to text escaping.
|
|
12
|
+
def self.escaped?
|
|
13
|
+
false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.cast(value, _options = EMPTY_OPTIONS)
|
|
17
|
+
return nil if value.nil?
|
|
18
|
+
return value if Lutaml::Model::Utils.uninitialized?(value)
|
|
19
|
+
|
|
20
|
+
native?(value) ? value : parse(value.to_s)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.valid?(wire)
|
|
24
|
+
parse(wire)
|
|
25
|
+
true
|
|
26
|
+
rescue ValueError
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.from_vobject(wire)
|
|
31
|
+
parse(wire)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.native?(_value)
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.invalid!(type_name, wire)
|
|
39
|
+
raise ValueError, "#{wire.inspect} is not a valid #{type_name} value"
|
|
40
|
+
end
|
|
41
|
+
private_class_method :invalid!
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
module Value
|
|
6
|
+
# RFC 6350 section 4.3.4 date-and-or-time (and the extended ISO 8601
|
|
7
|
+
# spellings vCard 3.0 / iCalendar producers emit). The native value
|
|
8
|
+
# keeps the exact text and classifies it.
|
|
9
|
+
class DateAndOrTime < Base
|
|
10
|
+
Instant = Struct.new(:kind, :text) do
|
|
11
|
+
include Comparable
|
|
12
|
+
|
|
13
|
+
def to_s
|
|
14
|
+
text
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def <=>(other)
|
|
18
|
+
other.is_a?(Instant) ? text <=> other.text : nil
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
MONTH = "(?<mo>0[1-9]|1[0-2])"
|
|
23
|
+
DAY = "(?<dd>0[1-9]|[12]\\d|3[01])"
|
|
24
|
+
HOUR = "(?<hh>[01]\\d|2[0-3])"
|
|
25
|
+
MINUTE = "(?<mi>[0-5]\\d)"
|
|
26
|
+
SECOND = "(?<ss>[0-5]\\d|60)"
|
|
27
|
+
ZONE = "(?:Z|[+-](?:[01]\\d|2[0-3])(?::?[0-5]\\d)?)"
|
|
28
|
+
YEAR = "\\d{4}"
|
|
29
|
+
|
|
30
|
+
DATE_NOREDUC = [
|
|
31
|
+
"#{YEAR}-#{MONTH}-#{DAY}", "#{YEAR}#{MONTH}#{DAY}",
|
|
32
|
+
"--#{MONTH}-?#{DAY}", "---#{DAY}"
|
|
33
|
+
].join("|").freeze
|
|
34
|
+
DATE = [
|
|
35
|
+
DATE_NOREDUC, "#{YEAR}-#{MONTH}", YEAR, "--#{MONTH}"
|
|
36
|
+
].join("|").freeze
|
|
37
|
+
TIME_NOTRUNC = "#{HOUR}(?::?#{MINUTE}(?::?#{SECOND})?)?#{ZONE}?".freeze
|
|
38
|
+
TIME = [
|
|
39
|
+
TIME_NOTRUNC, "-#{MINUTE}(?::?#{SECOND})?#{ZONE}?",
|
|
40
|
+
"--#{SECOND}#{ZONE}?"
|
|
41
|
+
].join("|").freeze
|
|
42
|
+
|
|
43
|
+
DATE_RE = /\A(?:#{DATE})\z/
|
|
44
|
+
DATE_TIME_RE = /\A(?:#{DATE_NOREDUC})T(?:#{TIME_NOTRUNC})\z/
|
|
45
|
+
TIME_RE = /\AT(?:#{TIME})\z/
|
|
46
|
+
|
|
47
|
+
def self.native?(value)
|
|
48
|
+
value.is_a?(Instant)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.parse(wire)
|
|
52
|
+
kind = if DATE_TIME_RE.match?(wire) then :date_time
|
|
53
|
+
elsif DATE_RE.match?(wire) then :date
|
|
54
|
+
elsif TIME_RE.match?(wire) then :time
|
|
55
|
+
else invalid!("date-and-or-time", wire)
|
|
56
|
+
end
|
|
57
|
+
Instant.new(kind, wire).freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.format(value)
|
|
61
|
+
value.is_a?(Instant) ? value.text : parse(value.to_s).text
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.serialize(value)
|
|
65
|
+
value.nil? ? nil : format(value)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|