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.
@@ -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