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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1b4498040b2d01535f5469b36d771aa21ba3ed86ece6149af63fe5fd2a78a71d
|
|
4
|
+
data.tar.gz: b17bc582b5a99aa90aecb71b27547ffee35accc9d74f5ea85f7a46170e1176db
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a258aa0b6bae31d8ad38c57f00468917a41b290f6ac25515416f419bd4c5eeec290fda5bc66487abc90098a0a0e0bb78c9f2119471ed55a89de1a16b64da7244
|
|
7
|
+
data.tar.gz: 955446d8c4ed7d24a71473f7ef34cb24fa2e8fd8690dbbdc4bb366d41693ab9ee8db56b2835483b16d70df641d4646ba3bf5602fff78fc35ab9f6955a47647db
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
Licenses & Copyright
|
|
2
|
+
====================
|
|
3
|
+
|
|
4
|
+
This license file adheres to the formatting guidelines of
|
|
5
|
+
[readable-licenses](https://github.com/nevir/readable-licenses).
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
Ribose's BSD 2-Clause License
|
|
9
|
+
-----------------------------
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026, [Ribose Inc](https://www.ribose.com).
|
|
12
|
+
All rights reserved.
|
|
13
|
+
|
|
14
|
+
Redistribution and use in source and binary forms, with or without modification,
|
|
15
|
+
are permitted provided that the following conditions are met:
|
|
16
|
+
|
|
17
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
18
|
+
this list of conditions and the following disclaimer.
|
|
19
|
+
|
|
20
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
21
|
+
this list of conditions and the following disclaimer in the documentation
|
|
22
|
+
and/or other materials provided with the distribution.
|
|
23
|
+
|
|
24
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
25
|
+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
26
|
+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
27
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
|
|
28
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
29
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
30
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
31
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
32
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
|
33
|
+
THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.adoc
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
= lutaml-vformat
|
|
2
|
+
|
|
3
|
+
The vObject wire format (the line format shared by RFC 6350 vCard and RFC 5545
|
|
4
|
+
iCalendar) for https://github.com/lutaml/lutaml-model[lutaml-model]. The gem
|
|
5
|
+
registers a `:vobject` format into `Lutaml::Model::FormatRegistry` when it is
|
|
6
|
+
required, so models gain `from_vobject` and `to_vobject`.
|
|
7
|
+
|
|
8
|
+
This gem is the wire layer only. It has no vCard or iCalendar vocabulary; the
|
|
9
|
+
vCard property set is a separate gem built on this one.
|
|
10
|
+
|
|
11
|
+
== Installation
|
|
12
|
+
|
|
13
|
+
[source,ruby]
|
|
14
|
+
----
|
|
15
|
+
gem "lutaml-vformat"
|
|
16
|
+
----
|
|
17
|
+
|
|
18
|
+
Requires `lutaml-model` `~> 0.8.164` (the version the registration API was
|
|
19
|
+
measured against) and Ruby 3.0 or later.
|
|
20
|
+
|
|
21
|
+
== What it covers
|
|
22
|
+
|
|
23
|
+
* `BEGIN:` / `END:` component nesting to any depth
|
|
24
|
+
* property parameters, including quoted values and comma-separated lists
|
|
25
|
+
* property groups (`item1.TEL`)
|
|
26
|
+
* structured values (`;` between fields, `,` between values in a field)
|
|
27
|
+
* text escaping: backslash, comma, semicolon and newline
|
|
28
|
+
* line folding at 75 octets (counted in octets, never inside a UTF-8
|
|
29
|
+
sequence) and unfolding
|
|
30
|
+
* value types: text, uri, binary, boolean, integer, float, utc-offset,
|
|
31
|
+
language-tag, date-and-or-time
|
|
32
|
+
|
|
33
|
+
Output uses CRLF line endings and ends with one.
|
|
34
|
+
|
|
35
|
+
== Usage
|
|
36
|
+
|
|
37
|
+
[source,ruby]
|
|
38
|
+
----
|
|
39
|
+
require "lutaml/vformat"
|
|
40
|
+
|
|
41
|
+
class Phone < Lutaml::Model::Serializable
|
|
42
|
+
attribute :number, :string
|
|
43
|
+
attribute :types, :string, collection: true
|
|
44
|
+
attribute :pref, :integer
|
|
45
|
+
|
|
46
|
+
vobject do
|
|
47
|
+
type :property
|
|
48
|
+
map_value to: :number
|
|
49
|
+
map_param "TYPE", to: :types
|
|
50
|
+
map_param "PREF", to: :pref
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class PersonName < Lutaml::Model::Serializable
|
|
55
|
+
attribute :family, :string
|
|
56
|
+
attribute :given, :string
|
|
57
|
+
|
|
58
|
+
vobject do
|
|
59
|
+
type :property
|
|
60
|
+
map_field 0, to: :family # N:family;given
|
|
61
|
+
map_field 1, to: :given
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class Card < Lutaml::Model::Serializable
|
|
66
|
+
attribute :fn, :string
|
|
67
|
+
attribute :n, PersonName
|
|
68
|
+
attribute :tel, Phone, collection: true
|
|
69
|
+
attribute :bday, Lutaml::Vformat::Value::DateAndOrTime
|
|
70
|
+
|
|
71
|
+
vobject do
|
|
72
|
+
type :component
|
|
73
|
+
component_root "VCARD"
|
|
74
|
+
map_property "FN", to: :fn
|
|
75
|
+
map_property "N", to: :n
|
|
76
|
+
map_property "TEL", to: :tel
|
|
77
|
+
map_property "BDAY", to: :bday
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
card = Card.from_vobject(<<~VCARD.gsub("\n", "\r\n"))
|
|
82
|
+
BEGIN:VCARD
|
|
83
|
+
FN:Jo Doe
|
|
84
|
+
N:Doe;Jo
|
|
85
|
+
TEL;TYPE=work,voice;PREF=1:+1 555 0100
|
|
86
|
+
BDAY:19850412
|
|
87
|
+
END:VCARD
|
|
88
|
+
VCARD
|
|
89
|
+
|
|
90
|
+
card.tel.first.types # => ["work", "voice"]
|
|
91
|
+
card.bday.kind # => :date
|
|
92
|
+
card.to_vobject # => "BEGIN:VCARD\r\nFN:Jo Doe\r\n..."
|
|
93
|
+
----
|
|
94
|
+
|
|
95
|
+
=== Mapping DSL
|
|
96
|
+
|
|
97
|
+
Inside `vobject do ... end`:
|
|
98
|
+
|
|
99
|
+
[cols="2,5"]
|
|
100
|
+
|===
|
|
101
|
+
| `type :component` / `type :property` | What the model represents.
|
|
102
|
+
| `component_root "NAME"` | The `BEGIN:` name of a top-level component model.
|
|
103
|
+
| `map_property "NAME", to: :attr` | A content line of the component. The attribute is a scalar type or a `:property` model. Repeated lines need `collection: true`.
|
|
104
|
+
| `map_component "NAME", to: :attr` | A nested `BEGIN:NAME` block; the attribute is a `:component` model.
|
|
105
|
+
| `map_value to: :attr` | In a property model: the property value.
|
|
106
|
+
| `map_param "NAME", to: :attr` | In a property model: a parameter. A `collection: true` attribute receives every value.
|
|
107
|
+
| `map_group to: :attr` | In a property model: the group prefix.
|
|
108
|
+
| `map_field index, to: :attr` | In a property model: one field of a structured value.
|
|
109
|
+
|===
|
|
110
|
+
|
|
111
|
+
`map_property`, `map_value`, `map_param` and `map_field` accept
|
|
112
|
+
`value_type:` (`:text`, `:uri`, `:binary`, `:boolean`, `:integer`, `:float`,
|
|
113
|
+
`:utc_offset`, `:language_tag`, `:date_and_or_time`). Without it the type
|
|
114
|
+
comes from the attribute (`:string`, `:integer`, `:float`, `:boolean` and the
|
|
115
|
+
`Lutaml::Vformat::Value` classes).
|
|
116
|
+
|
|
117
|
+
=== Value types
|
|
118
|
+
|
|
119
|
+
[cols="2,3,3"]
|
|
120
|
+
|===
|
|
121
|
+
| Type | Ruby object | Wire form
|
|
122
|
+
|
|
123
|
+
| `Value::Text` | `String` | escaped
|
|
124
|
+
| `Value::Uri` | `String` | as is
|
|
125
|
+
| `Value::Binary` | `Value::Binary::Blob` (`#bytes`) | strict base64
|
|
126
|
+
| `Value::Boolean` | `true` / `false` | `TRUE` / `FALSE`
|
|
127
|
+
| `Value::Integer` | `Integer` | `-12`
|
|
128
|
+
| `Value::Float` | `Float` | `1.5`, never exponent form
|
|
129
|
+
| `Value::UtcOffset` | `Integer` (seconds east of UTC) | `+0530`, `-0800`
|
|
130
|
+
| `Value::LanguageTag` | `String` | RFC 5646 tag
|
|
131
|
+
| `Value::DateAndOrTime` | `Value::DateAndOrTime::Instant` (`#kind`, `#to_s`) | `19850412`, `--0412`, `19961022T140000Z`, `T1022`
|
|
132
|
+
|===
|
|
133
|
+
|
|
134
|
+
Each class has `.parse(wire)` and `.format(object)`; an invalid wire value
|
|
135
|
+
raises `Lutaml::Vformat::ValueError`.
|
|
136
|
+
|
|
137
|
+
=== Without models
|
|
138
|
+
|
|
139
|
+
[source,ruby]
|
|
140
|
+
----
|
|
141
|
+
document = Lutaml::Vformat::Document.parse(text)
|
|
142
|
+
event = document.component("VCALENDAR").components_named("VEVENT").first
|
|
143
|
+
event.property("DTSTART").value
|
|
144
|
+
event.property("DTSTART").param("TZID")
|
|
145
|
+
document.to_vobject
|
|
146
|
+
----
|
|
147
|
+
|
|
148
|
+
Names and parameter names are case-insensitive and kept upper-case.
|
|
149
|
+
Property values stay in wire (escaped) form; use `Property#text` or
|
|
150
|
+
`Property#fields` for the unescaped view.
|
|
151
|
+
|
|
152
|
+
== Errors
|
|
153
|
+
|
|
154
|
+
Malformed input (unbalanced `BEGIN`/`END`, a line without `:`, invalid
|
|
155
|
+
UTF-8, a value that does not match its type) raises
|
|
156
|
+
`Lutaml::Vformat::ParseError`; through `Model.from_vobject` it surfaces as
|
|
157
|
+
`Lutaml::Model::InvalidFormatError`. A parameter value that contains a double
|
|
158
|
+
quote cannot be written and raises `Lutaml::Vformat::SerializeError`.
|
|
159
|
+
|
|
160
|
+
== Not covered in 0.1.0
|
|
161
|
+
|
|
162
|
+
* the vCard and iCalendar vocabularies
|
|
163
|
+
* vCard 2.1 syntax (bare parameters such as `TEL;WORK:`, quoted-printable)
|
|
164
|
+
* RFC 6868 caret encoding of parameter values
|
|
165
|
+
* the `VALUE=` parameter: it is available as `Property#value_type` but models
|
|
166
|
+
neither read nor write it; name the type with `value_type:` or the
|
|
167
|
+
attribute type
|
|
168
|
+
* converting `DateAndOrTime` to `Date` or `Time`
|
|
169
|
+
|
|
170
|
+
== License
|
|
171
|
+
|
|
172
|
+
BSD-2-Clause. See link:LICENSE.md[LICENSE.md].
|
data/Rakefile
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# Wire text <-> attribute values. A vObject value type (or the
|
|
6
|
+
# +value_type:+ of a mapping rule) wins; plain lutaml-model String,
|
|
7
|
+
# Integer, Float and Boolean attributes use the matching vObject type;
|
|
8
|
+
# anything else is cast from the unescaped text.
|
|
9
|
+
module Codec
|
|
10
|
+
BUILTIN = {
|
|
11
|
+
Lutaml::Model::Type::String => Value::Text,
|
|
12
|
+
Lutaml::Model::Type::Integer => Value::Integer,
|
|
13
|
+
Lutaml::Model::Type::Float => Value::Float,
|
|
14
|
+
Lutaml::Model::Type::Boolean => Value::Boolean,
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def value_class(type, value_type = nil)
|
|
20
|
+
return Value.for(value_type) if value_type
|
|
21
|
+
return type if type <= Value::Base
|
|
22
|
+
|
|
23
|
+
BUILTIN[type]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def read(type, wire, value_type: nil)
|
|
27
|
+
klass = value_class(type, value_type)
|
|
28
|
+
return klass.parse(wire) if klass
|
|
29
|
+
|
|
30
|
+
type.cast(Escaping.unescape(wire))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Parameter values are never text-escaped.
|
|
34
|
+
def read_param(type, text, value_type: nil)
|
|
35
|
+
klass = value_class(type, value_type)
|
|
36
|
+
return type.cast(text) if klass.nil?
|
|
37
|
+
|
|
38
|
+
klass.escaped? ? text : klass.parse(text)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write(type, value, value_type: nil)
|
|
42
|
+
klass = value_class(type, value_type)
|
|
43
|
+
return klass.format(value) if klass
|
|
44
|
+
|
|
45
|
+
return Value::Boolean.format(value) if [true, false].include?(value)
|
|
46
|
+
|
|
47
|
+
Escaping.escape(type.serialize(value).to_s)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write_param(type, value, value_type: nil)
|
|
51
|
+
klass = value_class(type, value_type)
|
|
52
|
+
return value.to_s unless klass
|
|
53
|
+
|
|
54
|
+
klass.escaped? ? value.to_s : klass.format(value)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# A BEGIN:NAME ... END:NAME block holding properties and nested components,
|
|
6
|
+
# each in document order.
|
|
7
|
+
class Component
|
|
8
|
+
attr_reader :name, :properties, :components
|
|
9
|
+
|
|
10
|
+
def initialize(name, properties: [], components: [])
|
|
11
|
+
@name = name.to_s.upcase
|
|
12
|
+
@properties = properties
|
|
13
|
+
@components = components
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def properties_named(name)
|
|
17
|
+
properties.select { |p| p.name == name.to_s.upcase }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def components_named(name)
|
|
21
|
+
components.select { |c| c.name == name.to_s.upcase }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def property(name)
|
|
25
|
+
properties_named(name).first
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ==(other)
|
|
29
|
+
other.is_a?(Component) && [name, properties, components] ==
|
|
30
|
+
[other.name, other.properties, other.components]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# A parsed vObject stream: zero or more top-level components.
|
|
6
|
+
class Document
|
|
7
|
+
attr_reader :components
|
|
8
|
+
|
|
9
|
+
def self.parse(data, _options = {})
|
|
10
|
+
new(Parser.parse(data))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(components = [], _options = {})
|
|
14
|
+
components = components.components if components.is_a?(Document)
|
|
15
|
+
@components = Array(components)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def component(name)
|
|
19
|
+
@components.find { |c| c.name == name.to_s.upcase }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_vobject(_options = {})
|
|
23
|
+
Writer.write(@components)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ==(other)
|
|
27
|
+
other.is_a?(Document) && components == other.components
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Malformed vObject input. Registered as the format's error type, so
|
|
8
|
+
# Model.from_vobject surfaces it as Lutaml::Model::InvalidFormatError.
|
|
9
|
+
class ParseError < Error; end
|
|
10
|
+
|
|
11
|
+
# A value that does not match its declared vObject value type.
|
|
12
|
+
class ValueError < ParseError; end
|
|
13
|
+
|
|
14
|
+
# A model or document that cannot be written as vObject.
|
|
15
|
+
class SerializeError < Error; end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# Text value escaping (RFC 6350 section 3.4): backslash, semicolon and
|
|
6
|
+
# comma get a backslash prefix; a line break becomes the two characters
|
|
7
|
+
# backslash-n.
|
|
8
|
+
module Escaping
|
|
9
|
+
BACKSLASH = "\\"
|
|
10
|
+
ESCAPES = {
|
|
11
|
+
BACKSLASH => BACKSLASH * 2,
|
|
12
|
+
";" => "#{BACKSLASH};",
|
|
13
|
+
"," => "#{BACKSLASH},",
|
|
14
|
+
"\n" => "#{BACKSLASH}n",
|
|
15
|
+
}.freeze
|
|
16
|
+
ESCAPABLE = Regexp.union(ESCAPES.keys)
|
|
17
|
+
ESCAPE_SEQUENCE = /#{Regexp.escape(BACKSLASH)}(.)/m
|
|
18
|
+
UNESCAPES = { "n" => "\n", "N" => "\n" }.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def escape(text)
|
|
23
|
+
text.to_s.gsub(/\r\n|\r|\n/, "\n").gsub(ESCAPABLE, ESCAPES)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Unknown escapes drop the backslash; a trailing lone backslash is
|
|
27
|
+
# kept literally.
|
|
28
|
+
def unescape(text)
|
|
29
|
+
text.gsub(ESCAPE_SEQUENCE) do
|
|
30
|
+
char = Regexp.last_match(1)
|
|
31
|
+
UNESCAPES.fetch(char, char)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# Line folding (RFC 6350 section 3.2, RFC 5545 section 3.1).
|
|
6
|
+
#
|
|
7
|
+
# The limit counts octets of the encoded line, not characters, and a fold
|
|
8
|
+
# never falls inside a multi-byte UTF-8 sequence. A continuation line
|
|
9
|
+
# starts with one space that counts toward its limit.
|
|
10
|
+
module Folding
|
|
11
|
+
LIMIT = 75
|
|
12
|
+
CRLF = "\r\n"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def fold(line, limit: LIMIT, eol: CRLF)
|
|
17
|
+
bytes = line.b
|
|
18
|
+
return line.dup if bytes.bytesize <= limit
|
|
19
|
+
|
|
20
|
+
chunks(bytes, limit).join("#{eol} ".b).force_encoding(line.encoding)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The first chunk gets +limit+ octets; later ones one fewer, because
|
|
24
|
+
# each is written after a leading space.
|
|
25
|
+
def chunks(bytes, limit)
|
|
26
|
+
chunks = []
|
|
27
|
+
pos = 0
|
|
28
|
+
while pos < bytes.bytesize
|
|
29
|
+
cut = cut_point(bytes, pos, pos.zero? ? limit : limit - 1)
|
|
30
|
+
chunks << bytes.byteslice(pos, cut - pos)
|
|
31
|
+
pos = cut
|
|
32
|
+
end
|
|
33
|
+
chunks
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def unfold(text)
|
|
37
|
+
text.gsub(/\r?\n[ \t]/, "")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Largest end offset, at most +budget+ bytes after +pos+, that is not
|
|
41
|
+
# in the middle of a UTF-8 sequence.
|
|
42
|
+
def cut_point(bytes, pos, budget)
|
|
43
|
+
cut = pos + budget
|
|
44
|
+
return bytes.bytesize if cut >= bytes.bytesize
|
|
45
|
+
|
|
46
|
+
cut -= 1 while cut > pos && continuation?(bytes.getbyte(cut))
|
|
47
|
+
cut
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def continuation?(byte)
|
|
51
|
+
(byte & 0xC0) == 0x80
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# The +vobject do ... end+ block.
|
|
6
|
+
#
|
|
7
|
+
# vobject do
|
|
8
|
+
# type :component # or :property
|
|
9
|
+
# component_root "VEVENT" # type :component, top-level name
|
|
10
|
+
# property_root "TEL" # type :property, informational
|
|
11
|
+
#
|
|
12
|
+
# map_property "SUMMARY", to: :summary
|
|
13
|
+
# map_component "VALARM", to: :alarms
|
|
14
|
+
#
|
|
15
|
+
# # in a property model:
|
|
16
|
+
# map_value to: :value
|
|
17
|
+
# map_param "TYPE", to: :types
|
|
18
|
+
# map_group to: :group
|
|
19
|
+
# map_field 0, to: :family # structured value, ";"-separated
|
|
20
|
+
# end
|
|
21
|
+
class Mapping < Lutaml::Model::Mapping
|
|
22
|
+
TYPES = %i[component property].freeze
|
|
23
|
+
|
|
24
|
+
attr_reader :mappings, :element_type, :component_name, :property_name
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
super
|
|
28
|
+
@element_type = nil
|
|
29
|
+
@component_name = nil
|
|
30
|
+
@property_name = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def deep_dup
|
|
34
|
+
duped = super
|
|
35
|
+
duped.copy_roots(@element_type, @component_name, @property_name)
|
|
36
|
+
duped
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def copy_roots(element_type, component_name, property_name)
|
|
40
|
+
@element_type = element_type
|
|
41
|
+
@component_name = component_name
|
|
42
|
+
@property_name = property_name
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def finalize(_mapper_class)
|
|
46
|
+
@finalized = true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def finalized?
|
|
50
|
+
@finalized == true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def type(element_type)
|
|
54
|
+
unless TYPES.include?(element_type)
|
|
55
|
+
raise ArgumentError,
|
|
56
|
+
"Invalid element type: #{element_type.inspect} " \
|
|
57
|
+
"(expected one of #{TYPES.inspect})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@element_type = element_type
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def component_root(name)
|
|
64
|
+
@component_name = name.to_s.upcase
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def property_root(name)
|
|
68
|
+
@property_name = name.to_s.upcase
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def map_property(name, to:, value_type: nil)
|
|
72
|
+
add(name.to_s.upcase, to, :property, value_type)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def map_component(name, to:)
|
|
76
|
+
add(name.to_s.upcase, to, :component, nil)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def map_value(to:, value_type: nil)
|
|
80
|
+
add("VALUE", to, :value, value_type)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def map_param(name, to:, value_type: nil)
|
|
84
|
+
add(name.to_s.upcase, to, :param, value_type)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def map_field(index, to:, value_type: nil)
|
|
88
|
+
add(index, to, :field, value_type)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def map_group(to:)
|
|
92
|
+
add("GROUP", to, :group, nil)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def rules(kind)
|
|
96
|
+
@mappings.select { |r| r.kind == kind }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def add(name, to, kind, value_type)
|
|
102
|
+
@mappings << MappingRule.new(name, to: to, kind: kind,
|
|
103
|
+
value_type: value_type)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lutaml
|
|
4
|
+
module Vformat
|
|
5
|
+
# One mapping line. +kind+ is :property, :component, :value, :param,
|
|
6
|
+
# :field or :group; +name+ is the vObject name (or field index).
|
|
7
|
+
class MappingRule < Lutaml::Model::MappingRule
|
|
8
|
+
KINDS = %i[property component value param field group].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :kind, :value_type
|
|
11
|
+
|
|
12
|
+
def initialize(name, to:, kind:, value_type: nil)
|
|
13
|
+
super(name, to: to)
|
|
14
|
+
@kind = kind
|
|
15
|
+
@value_type = value_type
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def deep_dup
|
|
19
|
+
self.class.new(name.dup, to: to, kind: kind, value_type: value_type)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|