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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protocol
4
+ module Caldav
5
+ VERSION = "1.0.0"
6
+ end
7
+ 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('&', '&amp;')
16
+ .gsub('<', '&lt;')
17
+ .gsub('>', '&gt;')
18
+ .gsub('"', '&quot;')
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&amp;b"
47
+ end
48
+
49
+ it "escapes less-than" do
50
+ Protocol::Caldav::Xml.escape("a<b").should.equal "a&lt;b"
51
+ end
52
+
53
+ it "escapes greater-than" do
54
+ Protocol::Caldav::Xml.escape("a>b").should.equal "a&gt;b"
55
+ end
56
+
57
+ it "escapes double-quote" do
58
+ Protocol::Caldav::Xml.escape('a"b').should.equal "a&quot;b"
59
+ end
60
+
61
+ it "escapes all five entities together" do
62
+ Protocol::Caldav::Xml.escape('&<>"').should.equal '&amp;&lt;&gt;&quot;'
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