protocol-caldav 1.0.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/lib/protocol/caldav/collection.rb +173 -0
- data/lib/protocol/caldav/constants.rb +54 -0
- data/lib/protocol/caldav/content_line.rb +154 -0
- data/lib/protocol/caldav/ctag.rb +78 -0
- data/lib/protocol/caldav/etag.rb +51 -0
- data/lib/protocol/caldav/filter/addressbook.rb +65 -0
- data/lib/protocol/caldav/filter/calendar.rb +83 -0
- data/lib/protocol/caldav/filter/match.rb +776 -0
- data/lib/protocol/caldav/filter/parser.rb +391 -0
- data/lib/protocol/caldav/ical/component.rb +84 -0
- data/lib/protocol/caldav/ical/expand.rb +208 -0
- data/lib/protocol/caldav/ical/freebusy.rb +161 -0
- data/lib/protocol/caldav/ical/parser.rb +191 -0
- data/lib/protocol/caldav/ical/property.rb +39 -0
- data/lib/protocol/caldav/ical/rrule.rb +399 -0
- data/lib/protocol/caldav/item.rb +120 -0
- data/lib/protocol/caldav/multistatus.rb +61 -0
- data/lib/protocol/caldav/path.rb +185 -0
- data/lib/protocol/caldav/storage.rb +120 -0
- data/lib/protocol/caldav/vcard/card.rb +58 -0
- data/lib/protocol/caldav/vcard/parser.rb +88 -0
- data/lib/protocol/caldav/version.rb +7 -0
- data/lib/protocol/caldav/xml.rb +122 -0
- data/lib/protocol/caldav.rb +30 -0
- metadata +95 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
require "protocol/caldav"
|
|
6
|
+
|
|
7
|
+
module Protocol
|
|
8
|
+
module Caldav
|
|
9
|
+
class Path
|
|
10
|
+
attr_reader :to_s, :storage_class
|
|
11
|
+
|
|
12
|
+
def initialize(raw, storage_class: nil)
|
|
13
|
+
p = raw.to_s.gsub(%r{/+}, '/')
|
|
14
|
+
p = "/#{p}" unless p.start_with?('/')
|
|
15
|
+
@to_s = p
|
|
16
|
+
@storage_class = storage_class
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def parent
|
|
20
|
+
parts = @to_s.chomp('/').split('/')
|
|
21
|
+
if parts.length <= 1
|
|
22
|
+
self.class.new('/', storage_class: @storage_class)
|
|
23
|
+
else
|
|
24
|
+
self.class.new("#{parts[0..-2].join('/')}/", storage_class: @storage_class)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def depth
|
|
29
|
+
@to_s.chomp('/').split('/').reject(&:empty?).length
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def child_of?(other)
|
|
33
|
+
parent_str = other.to_s
|
|
34
|
+
parent_str = "#{parent_str}/" unless parent_str.end_with?('/')
|
|
35
|
+
if @to_s.start_with?(parent_str)
|
|
36
|
+
remainder = @to_s[parent_str.length..]
|
|
37
|
+
remainder.chomp('/').count('/').zero? && !remainder.chomp('/').empty?
|
|
38
|
+
else
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parent_exists?
|
|
44
|
+
raise ArgumentError, "storage_class required for parent_exists?" unless @storage_class
|
|
45
|
+
|
|
46
|
+
if parent.depth <= 2
|
|
47
|
+
true
|
|
48
|
+
else
|
|
49
|
+
@storage_class.collection_exists?(parent.to_s)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ensure_trailing_slash
|
|
54
|
+
if @to_s.end_with?('/')
|
|
55
|
+
self
|
|
56
|
+
else
|
|
57
|
+
self.class.new("#{@to_s}/", storage_class: @storage_class)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def start_with?(prefix)
|
|
62
|
+
@to_s.start_with?(prefix)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ==(other)
|
|
66
|
+
to_s == other.to_s
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_propfind_xml
|
|
70
|
+
<<~XML
|
|
71
|
+
<d:response>
|
|
72
|
+
<d:href>#{Xml.escape(@to_s)}</d:href>
|
|
73
|
+
<d:propstat>
|
|
74
|
+
<d:prop>
|
|
75
|
+
<d:resourcetype><d:collection/></d:resourcetype>
|
|
76
|
+
</d:prop>
|
|
77
|
+
<d:status>HTTP/1.1 200 OK</d:status>
|
|
78
|
+
</d:propstat>
|
|
79
|
+
</d:response>
|
|
80
|
+
XML
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
test do
|
|
88
|
+
describe "Protocol::Caldav::Path" do
|
|
89
|
+
it "normalizes // to /" do
|
|
90
|
+
Protocol::Caldav::Path.new("//foo//bar").to_s.should.equal "/foo/bar"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "normalizes leading ///foo to /foo" do
|
|
94
|
+
Protocol::Caldav::Path.new("///foo").to_s.should.equal "/foo"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "adds leading / if missing" do
|
|
98
|
+
Protocol::Caldav::Path.new("foo/bar").to_s.should.equal "/foo/bar"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
describe "#parent" do
|
|
102
|
+
it "parent of /a/b/c/ is /a/b/" do
|
|
103
|
+
Protocol::Caldav::Path.new("/a/b/c/").parent.to_s.should.equal "/a/b/"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "parent of /a/ is /" do
|
|
107
|
+
Protocol::Caldav::Path.new("/a/").parent.to_s.should.equal "/"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "parent of / is / (idempotent)" do
|
|
111
|
+
Protocol::Caldav::Path.new("/").parent.to_s.should.equal "/"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe "#depth" do
|
|
116
|
+
it "depth of / is 0" do
|
|
117
|
+
Protocol::Caldav::Path.new("/").depth.should.equal 0
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "depth of /a/ is 1" do
|
|
121
|
+
Protocol::Caldav::Path.new("/a/").depth.should.equal 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "depth of /a/b is 2 (trailing-slash-insensitive)" do
|
|
125
|
+
Protocol::Caldav::Path.new("/a/b").depth.should.equal 2
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
describe "#child_of?" do
|
|
130
|
+
it "returns true for direct child" do
|
|
131
|
+
child = Protocol::Caldav::Path.new("/a/b/")
|
|
132
|
+
parent = Protocol::Caldav::Path.new("/a/")
|
|
133
|
+
child.child_of?(parent).should.equal true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "returns false for grandchild" do
|
|
137
|
+
grandchild = Protocol::Caldav::Path.new("/a/b/c/")
|
|
138
|
+
grandparent = Protocol::Caldav::Path.new("/a/")
|
|
139
|
+
grandchild.child_of?(grandparent).should.equal false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it "returns false for sibling" do
|
|
143
|
+
a = Protocol::Caldav::Path.new("/a/b/")
|
|
144
|
+
b = Protocol::Caldav::Path.new("/a/c/")
|
|
145
|
+
a.child_of?(b).should.equal false
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "returns false for self" do
|
|
149
|
+
a = Protocol::Caldav::Path.new("/a/")
|
|
150
|
+
a.child_of?(a).should.equal false
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
describe "#ensure_trailing_slash" do
|
|
155
|
+
it "is idempotent on a slashed path" do
|
|
156
|
+
Protocol::Caldav::Path.new("/a/").ensure_trailing_slash.to_s.should.equal "/a/"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it "adds slash to unslashed" do
|
|
160
|
+
Protocol::Caldav::Path.new("/a").ensure_trailing_slash.to_s.should.equal "/a/"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
describe "#start_with?" do
|
|
165
|
+
it "delegates to string semantics" do
|
|
166
|
+
Protocol::Caldav::Path.new("/calendars/admin/").start_with?("/calendars/").should.equal true
|
|
167
|
+
Protocol::Caldav::Path.new("/addressbooks/admin/").start_with?("/calendars/").should.equal false
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
describe "#==" do
|
|
172
|
+
it "paths with same string are equal" do
|
|
173
|
+
a = Protocol::Caldav::Path.new("/a/")
|
|
174
|
+
b = Protocol::Caldav::Path.new("/a/")
|
|
175
|
+
(a == b).should.equal true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "paths from different storage_class but same string compare equal" do
|
|
179
|
+
a = Protocol::Caldav::Path.new("/a/", storage_class: Object.new)
|
|
180
|
+
b = Protocol::Caldav::Path.new("/a/", storage_class: Object.new)
|
|
181
|
+
(a == b).should.equal true
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
|
|
6
|
+
module Protocol
|
|
7
|
+
module Caldav
|
|
8
|
+
class Storage
|
|
9
|
+
# --- Collections ---
|
|
10
|
+
|
|
11
|
+
def create_collection(path, props = {})
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def get_collection(path)
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def delete_collection(path)
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def list_collections(parent_path)
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def update_collection(path, props)
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def collection_exists?(path)
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# --- Items ---
|
|
36
|
+
|
|
37
|
+
def get_item(path)
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def put_item(path, body, content_type)
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def delete_item(path)
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def list_items(collection_path)
|
|
50
|
+
raise NotImplementedError
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def move_item(from_path, to_path)
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def get_multi(paths)
|
|
58
|
+
raise NotImplementedError
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# --- General ---
|
|
62
|
+
|
|
63
|
+
def exists?(path)
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def etag(path)
|
|
68
|
+
raise NotImplementedError
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# --- Sync ---
|
|
72
|
+
|
|
73
|
+
# Snapshot the current item state for a collection and return a sync token.
|
|
74
|
+
# Subsequent calls to sync_changes with this token return the diff.
|
|
75
|
+
def snapshot_sync(collection_path)
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Return [new_token, changes] where changes is an array of [path, status]
|
|
80
|
+
# status is :modified (200) or :deleted (404).
|
|
81
|
+
# Returns nil if the token is invalid/unknown.
|
|
82
|
+
def sync_changes(collection_path, token)
|
|
83
|
+
raise NotImplementedError
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
test do
|
|
91
|
+
describe "Protocol::Caldav::Storage" do
|
|
92
|
+
it "every method raises NotImplementedError" do
|
|
93
|
+
s = Protocol::Caldav::Storage.new
|
|
94
|
+
methods = %i[create_collection get_collection delete_collection list_collections
|
|
95
|
+
update_collection collection_exists? get_item put_item delete_item
|
|
96
|
+
list_items move_item get_multi exists? etag snapshot_sync sync_changes]
|
|
97
|
+
three_arg = %i[put_item]
|
|
98
|
+
two_arg = %i[update_collection move_item sync_changes]
|
|
99
|
+
methods.each do |m|
|
|
100
|
+
if three_arg.include?(m)
|
|
101
|
+
lambda { s.send(m, "/x", "body", "ct") }.should.raise NotImplementedError
|
|
102
|
+
elsif two_arg.include?(m)
|
|
103
|
+
lambda { s.send(m, "/x", {}) }.should.raise NotImplementedError
|
|
104
|
+
else
|
|
105
|
+
lambda { s.send(m, "/x") }.should.raise NotImplementedError
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it "can be subclassed with partial implementation" do
|
|
111
|
+
klass = Class.new(Protocol::Caldav::Storage) do
|
|
112
|
+
def exists?(path)
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
klass.new.exists?("/x").should.equal true
|
|
117
|
+
lambda { klass.new.get_item("/x") }.should.raise NotImplementedError
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
require "protocol/caldav"
|
|
6
|
+
|
|
7
|
+
module Protocol
|
|
8
|
+
module Caldav
|
|
9
|
+
module Vcard
|
|
10
|
+
Card = Struct.new(:properties, keyword_init: true) do
|
|
11
|
+
def initialize(properties: [])
|
|
12
|
+
super(properties: properties)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def find_property(prop_name)
|
|
16
|
+
properties.find { |p| p.name.casecmp?(prop_name) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def find_all_properties(prop_name)
|
|
20
|
+
properties.select { |p| p.name.casecmp?(prop_name) }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
test do
|
|
28
|
+
describe "Protocol::Caldav::Vcard::Card" do
|
|
29
|
+
def prop(name, value)
|
|
30
|
+
Protocol::Caldav::Ical::Property.new(name: name, params: {}, value: value)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it "find_property returns the first matching property" do
|
|
34
|
+
card = Protocol::Caldav::Vcard::Card.new(properties: [prop("FN", "John"), prop("FN", "Jane")])
|
|
35
|
+
card.find_property("FN").value.should.equal "John"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "find_property is case-insensitive" do
|
|
39
|
+
card = Protocol::Caldav::Vcard::Card.new(properties: [prop("FN", "John")])
|
|
40
|
+
card.find_property("fn").value.should.equal "John"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "find_property returns nil when absent" do
|
|
44
|
+
card = Protocol::Caldav::Vcard::Card.new(properties: [])
|
|
45
|
+
card.find_property("FN").should.be.nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "has no sub-component finder (vCards don't nest)" do
|
|
49
|
+
card = Protocol::Caldav::Vcard::Card.new
|
|
50
|
+
card.should.not.respond_to(:find_components)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "find_all_properties returns all matching" do
|
|
54
|
+
card = Protocol::Caldav::Vcard::Card.new(properties: [prop("TEL", "123"), prop("EMAIL", "x"), prop("TEL", "456")])
|
|
55
|
+
card.find_all_properties("TEL").length.should.equal 2
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
|
|
6
|
+
require "protocol/caldav"
|
|
7
|
+
|
|
8
|
+
module Protocol
|
|
9
|
+
module Caldav
|
|
10
|
+
module Vcard
|
|
11
|
+
module Parser
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def parse(text)
|
|
15
|
+
return nil if text.nil? || text.strip.empty?
|
|
16
|
+
|
|
17
|
+
text = text.sub(/\A\xEF\xBB\xBF/, '')
|
|
18
|
+
|
|
19
|
+
lines = ContentLine.unfold(text).split("\n").map(&:strip).reject(&:empty?)
|
|
20
|
+
props = []
|
|
21
|
+
inside = false
|
|
22
|
+
|
|
23
|
+
lines.each do |line|
|
|
24
|
+
parsed = ContentLine.parse_line(line)
|
|
25
|
+
next unless parsed
|
|
26
|
+
|
|
27
|
+
name, params, value = parsed
|
|
28
|
+
|
|
29
|
+
if name.casecmp?('BEGIN') && value.strip.casecmp?('VCARD')
|
|
30
|
+
inside = true
|
|
31
|
+
elsif name.casecmp?('END') && value.strip.casecmp?('VCARD')
|
|
32
|
+
break
|
|
33
|
+
elsif inside
|
|
34
|
+
props << Ical::Property.new(name: name, params: params, value: value)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
props.empty? ? nil : Card.new(properties: props)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
test do
|
|
47
|
+
describe "Protocol::Caldav::Vcard::Parser" do
|
|
48
|
+
def parse(text)
|
|
49
|
+
Protocol::Caldav::Vcard::Parser.parse(text)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it "parses a flat BEGIN:VCARD / END:VCARD" do
|
|
53
|
+
card = parse("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John\r\nEND:VCARD")
|
|
54
|
+
card.should.not.be.nil
|
|
55
|
+
card.find_property("FN").value.should.equal "John"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "handles VERSION:3.0 and VERSION:4.0 without distinction" do
|
|
59
|
+
card3 = parse("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:A\r\nEND:VCARD")
|
|
60
|
+
card4 = parse("BEGIN:VCARD\r\nVERSION:4.0\r\nFN:A\r\nEND:VCARD")
|
|
61
|
+
card3.find_property("VERSION").value.should.equal "3.0"
|
|
62
|
+
card4.find_property("VERSION").value.should.equal "4.0"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "handles structured values in N property as raw string" do
|
|
66
|
+
card = parse("BEGIN:VCARD\r\nN:Doe;John;;;Jr.\r\nEND:VCARD")
|
|
67
|
+
card.find_property("N").value.should.equal "Doe;John;;;Jr."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "returns nil for empty input" do
|
|
71
|
+
parse("").should.be.nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it "returns nil for nil input" do
|
|
75
|
+
parse(nil).should.be.nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "tolerates LF-only line endings" do
|
|
79
|
+
card = parse("BEGIN:VCARD\nFN:Test\nEND:VCARD")
|
|
80
|
+
card.find_property("FN").value.should.equal "Test"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "tolerates BOM" do
|
|
84
|
+
card = parse("\xEF\xBB\xBFBEGIN:VCARD\r\nFN:Test\r\nEND:VCARD")
|
|
85
|
+
card.should.not.be.nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/setup"
|
|
4
|
+
require "scampi"
|
|
5
|
+
|
|
6
|
+
module Protocol
|
|
7
|
+
module Caldav
|
|
8
|
+
module Xml
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def escape(str)
|
|
12
|
+
return '' unless str
|
|
13
|
+
|
|
14
|
+
str.to_s
|
|
15
|
+
.gsub('&', '&')
|
|
16
|
+
.gsub('<', '<')
|
|
17
|
+
.gsub('>', '>')
|
|
18
|
+
.gsub('"', '"')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def extract_value(xml, tag)
|
|
22
|
+
return nil if xml.nil? || xml.empty?
|
|
23
|
+
|
|
24
|
+
match = xml.match(/<[^>]*#{Regexp.escape(tag)}[^>]*>([^<]*)</)
|
|
25
|
+
return nil unless match
|
|
26
|
+
|
|
27
|
+
value = match[1].strip
|
|
28
|
+
value.empty? ? nil : value
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def extract_attr(xml, tag, attr)
|
|
32
|
+
return nil if xml.nil? || xml.empty?
|
|
33
|
+
|
|
34
|
+
match = xml.match(/<[^>]*#{Regexp.escape(tag)}[^>]*#{Regexp.escape(attr)}="([^"]*)"/)
|
|
35
|
+
match ? match[1] : nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
test do
|
|
43
|
+
describe "Protocol::Caldav::Xml" do
|
|
44
|
+
describe ".escape" do
|
|
45
|
+
it "escapes ampersand" do
|
|
46
|
+
Protocol::Caldav::Xml.escape("a&b").should.equal "a&b"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it "escapes less-than" do
|
|
50
|
+
Protocol::Caldav::Xml.escape("a<b").should.equal "a<b"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it "escapes greater-than" do
|
|
54
|
+
Protocol::Caldav::Xml.escape("a>b").should.equal "a>b"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "escapes double-quote" do
|
|
58
|
+
Protocol::Caldav::Xml.escape('a"b').should.equal "a"b"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it "escapes all five entities together" do
|
|
62
|
+
Protocol::Caldav::Xml.escape('&<>"').should.equal '&<>"'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "returns empty string for nil" do
|
|
66
|
+
Protocol::Caldav::Xml.escape(nil).should.equal ''
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it "returns empty string for empty string" do
|
|
70
|
+
Protocol::Caldav::Xml.escape('').should.equal ''
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "passes through plain text unchanged" do
|
|
74
|
+
Protocol::Caldav::Xml.escape("hello world").should.equal "hello world"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe ".extract_value" do
|
|
79
|
+
it "extracts text content from a tag" do
|
|
80
|
+
Protocol::Caldav::Xml.extract_value('<d:displayname>Work</d:displayname>', 'displayname').should.equal 'Work'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "returns nil for empty content" do
|
|
84
|
+
Protocol::Caldav::Xml.extract_value('<d:displayname></d:displayname>', 'displayname').should.be.nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "returns nil when tag not found" do
|
|
88
|
+
Protocol::Caldav::Xml.extract_value('<d:other>x</d:other>', 'displayname').should.be.nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "returns nil for nil input" do
|
|
92
|
+
Protocol::Caldav::Xml.extract_value(nil, 'displayname').should.be.nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it "returns nil for empty input" do
|
|
96
|
+
Protocol::Caldav::Xml.extract_value('', 'displayname').should.be.nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it "strips whitespace from value" do
|
|
100
|
+
Protocol::Caldav::Xml.extract_value('<d:displayname> Work </d:displayname>', 'displayname').should.equal 'Work'
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
describe ".extract_attr" do
|
|
105
|
+
it "extracts attribute value from a tag" do
|
|
106
|
+
Protocol::Caldav::Xml.extract_attr('<c:comp-filter name="VCALENDAR">', 'comp-filter', 'name').should.equal 'VCALENDAR'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "returns nil when tag not found" do
|
|
110
|
+
Protocol::Caldav::Xml.extract_attr('<c:other name="x">', 'comp-filter', 'name').should.be.nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it "returns nil for nil input" do
|
|
114
|
+
Protocol::Caldav::Xml.extract_attr(nil, 'comp-filter', 'name').should.be.nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "returns nil for empty input" do
|
|
118
|
+
Protocol::Caldav::Xml.extract_attr('', 'comp-filter', 'name').should.be.nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'caldav/version'
|
|
4
|
+
require_relative 'caldav/constants'
|
|
5
|
+
require_relative 'caldav/etag'
|
|
6
|
+
require_relative 'caldav/ctag'
|
|
7
|
+
require_relative 'caldav/xml'
|
|
8
|
+
require_relative 'caldav/multistatus'
|
|
9
|
+
require_relative 'caldav/path'
|
|
10
|
+
require_relative 'caldav/storage'
|
|
11
|
+
require_relative 'caldav/collection'
|
|
12
|
+
require_relative 'caldav/item'
|
|
13
|
+
require_relative 'caldav/content_line'
|
|
14
|
+
require_relative 'caldav/ical/property'
|
|
15
|
+
require_relative 'caldav/ical/component'
|
|
16
|
+
require_relative 'caldav/ical/parser'
|
|
17
|
+
require_relative 'caldav/ical/rrule'
|
|
18
|
+
require_relative 'caldav/ical/expand'
|
|
19
|
+
require_relative 'caldav/ical/freebusy'
|
|
20
|
+
require_relative 'caldav/vcard/card'
|
|
21
|
+
require_relative 'caldav/vcard/parser'
|
|
22
|
+
require_relative 'caldav/filter/calendar'
|
|
23
|
+
require_relative 'caldav/filter/addressbook'
|
|
24
|
+
require_relative 'caldav/filter/parser'
|
|
25
|
+
require_relative 'caldav/filter/match'
|
|
26
|
+
|
|
27
|
+
module Protocol
|
|
28
|
+
module Caldav
|
|
29
|
+
end
|
|
30
|
+
end
|