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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0b119c6f8e0f3fa86e9ca3606eea776a73cd872049fac80674a4f8ffba0bfaf4
4
+ data.tar.gz: 7f4d5929f5c6227694375e5ec77130b18e7b7b72e72bc01f67c633a63f73aa3d
5
+ SHA512:
6
+ metadata.gz: 4c842735fafa37d1d224ac8d8a48ee18237897baada5e8dc755a327bc0c9e67d29a4060ca9ca0f1d1cd435ec7d701462cdbe84c145579e720d1dad85b32031ce
7
+ data.tar.gz: b6187b613165259a2c277483f4ed46b404d103618545ff6b1c7caa77481428b96402c970711e70c9ea6db1e5bc8cbd5b70e942859a2d533dd2cb6ac9d7b8a9aa
@@ -0,0 +1,173 @@
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
+ class Collection
11
+ attr_reader :path, :type, :displayname, :description, :color, :props
12
+
13
+ def initialize(path:, type: :collection, displayname: nil, description: nil, color: nil, props: {})
14
+ @path = path
15
+ @type = type
16
+ @displayname = displayname
17
+ @description = description
18
+ @color = color
19
+ @props = props || {}
20
+ end
21
+
22
+ def update_attrs(updates)
23
+ @displayname = updates[:displayname] if updates.key?(:displayname)
24
+ @description = updates[:description] if updates.key?(:description)
25
+ @color = updates[:color] if updates.key?(:color)
26
+ @props = (@props || {}).merge(updates[:props]) if updates.key?(:props)
27
+ self
28
+ end
29
+
30
+ def to_propfind_xml
31
+ prop_lines = []
32
+
33
+ if @type == :calendar
34
+ prop_lines << '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
35
+ elsif @type == :addressbook
36
+ prop_lines << '<d:resourcetype><d:collection/><cr:addressbook/></d:resourcetype>'
37
+ else
38
+ prop_lines << '<d:resourcetype><d:collection/></d:resourcetype>'
39
+ end
40
+
41
+ prop_lines << "<d:displayname>#{Xml.escape(@displayname)}</d:displayname>" if @displayname
42
+ prop_lines << "<c:calendar-description>#{Xml.escape(@description)}</c:calendar-description>" if @description
43
+ prop_lines << "<x:calendar-color>#{Xml.escape(@color)}</x:calendar-color>" if @color
44
+
45
+ item_etags = @path.storage_class.list_items(@path.to_s).map { |_, data| data[:etag] }
46
+ ctag = CTag.compute(
47
+ path: @path.to_s,
48
+ displayname: @displayname,
49
+ description: @description,
50
+ color: @color,
51
+ item_etags: item_etags
52
+ )
53
+ prop_lines << "<cs:getctag>#{ctag}</cs:getctag>"
54
+ prop_lines << "<d:sync-token>http://caldav.local/sync/#{ctag}</d:sync-token>"
55
+
56
+ if @type == :calendar
57
+ prop_lines << '<c:supported-calendar-component-set><c:comp name="VEVENT"/><c:comp name="VTODO"/><c:comp name="VJOURNAL"/></c:supported-calendar-component-set>'
58
+ end
59
+
60
+ @props.each do |key, value|
61
+ prop_lines << "<#{key}>#{Xml.escape(value)}</#{key}>"
62
+ end
63
+
64
+ <<~XML
65
+ <d:response>
66
+ <d:href>#{Xml.escape(@path.to_s)}</d:href>
67
+ <d:propstat>
68
+ <d:prop>
69
+ #{prop_lines.join("\n ")}
70
+ </d:prop>
71
+ <d:status>HTTP/1.1 200 OK</d:status>
72
+ </d:propstat>
73
+ </d:response>
74
+ XML
75
+ end
76
+
77
+ def to_propname_xml
78
+ names = ['<d:resourcetype/>']
79
+ names << '<d:displayname/>' if @displayname
80
+ names << '<c:calendar-description/>' if @description
81
+ names << '<x:calendar-color/>' if @color
82
+ names << '<cs:getctag/>'
83
+ names << '<d:sync-token/>'
84
+ names << '<c:supported-calendar-component-set/>' if @type == :calendar
85
+
86
+ <<~XML
87
+ <d:response>
88
+ <d:href>#{Xml.escape(@path.to_s)}</d:href>
89
+ <d:propstat>
90
+ <d:prop>
91
+ #{names.join("\n ")}
92
+ </d:prop>
93
+ <d:status>HTTP/1.1 200 OK</d:status>
94
+ </d:propstat>
95
+ </d:response>
96
+ XML
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ test do
103
+ def normalize(xml)
104
+ xml.gsub(/>\s+</, '><').strip
105
+ end
106
+
107
+ # Minimal mock storage for Collection tests
108
+ class MockStorageForCollection < Protocol::Caldav::Storage
109
+ def list_items(_path)
110
+ []
111
+ end
112
+ end
113
+
114
+ describe "Protocol::Caldav::Collection" do
115
+ def make_collection(type: :calendar, displayname: "Work", **opts)
116
+ storage = MockStorageForCollection.new
117
+ path = Protocol::Caldav::Path.new("/calendars/admin/work/", storage_class: storage)
118
+ Protocol::Caldav::Collection.new(path: path, type: type, displayname: displayname, **opts)
119
+ end
120
+
121
+ it "renders calendar resourcetype" do
122
+ xml = make_collection(type: :calendar).to_propfind_xml
123
+ xml.should.include "<c:calendar/>"
124
+ end
125
+
126
+ it "renders addressbook resourcetype" do
127
+ xml = make_collection(type: :addressbook).to_propfind_xml
128
+ xml.should.include "<cr:addressbook/>"
129
+ end
130
+
131
+ it "includes displayname when set" do
132
+ xml = make_collection(displayname: "Work").to_propfind_xml
133
+ xml.should.include "<d:displayname>Work</d:displayname>"
134
+ end
135
+
136
+ it "omits displayname when nil" do
137
+ xml = make_collection(displayname: nil).to_propfind_xml
138
+ xml.should.not.include "displayname"
139
+ end
140
+
141
+ it "includes ctag" do
142
+ xml = make_collection.to_propfind_xml
143
+ xml.should.include "<cs:getctag>"
144
+ end
145
+
146
+ it "includes supported-calendar-component-set for calendars" do
147
+ xml = make_collection(type: :calendar).to_propfind_xml
148
+ xml.should.include "supported-calendar-component-set"
149
+ end
150
+
151
+ it "omits supported-calendar-component-set for addressbooks" do
152
+ xml = make_collection(type: :addressbook).to_propfind_xml
153
+ xml.should.not.include "supported-calendar-component-set"
154
+ end
155
+
156
+ it "escapes special characters in displayname" do
157
+ xml = make_collection(displayname: "Work & <Personal>").to_propfind_xml
158
+ xml.should.include "Work &amp; &lt;Personal&gt;"
159
+ end
160
+
161
+ it "update_attrs modifies named fields" do
162
+ col = make_collection(displayname: "Old")
163
+ col.update_attrs(displayname: "New")
164
+ col.displayname.should.equal "New"
165
+ end
166
+
167
+ it "update_attrs leaves unmentioned fields alone" do
168
+ col = make_collection(displayname: "Work", description: "Desc")
169
+ col.update_attrs(displayname: "New")
170
+ col.description.should.equal "Desc"
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+
6
+ module Protocol
7
+ module Caldav
8
+ module Constants
9
+ DAV_HEADERS = {
10
+ 'dav' => '1, 2, 3, calendar-access, addressbook, extended-mkcol',
11
+ 'allow' => 'OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCALENDAR, MKCOL, MOVE, REPORT',
12
+ 'content-type' => 'text/xml; charset=utf-8'
13
+ }.freeze
14
+
15
+ DAV_NS = 'DAV:'
16
+ CALDAV_NS = 'urn:ietf:params:xml:ns:caldav'
17
+ CARDDAV_NS = 'urn:ietf:params:xml:ns:carddav'
18
+ CALSERVER_NS = 'http://calendarserver.org/ns/'
19
+ APPLE_NS = 'http://apple.com/ns/ical/'
20
+
21
+ CALENDAR_MEDIA_TYPE = 'text/calendar'
22
+ VCARD_MEDIA_TYPE = 'text/vcard'
23
+ end
24
+ end
25
+ end
26
+
27
+
28
+ test do
29
+ describe "Protocol::Caldav::Constants" do
30
+ it "DAV_HEADERS is frozen" do
31
+ Protocol::Caldav::Constants::DAV_HEADERS.should.be.frozen
32
+ end
33
+
34
+ it "DAV_HEADERS includes calendar-access" do
35
+ Protocol::Caldav::Constants::DAV_HEADERS['dav'].should.include 'calendar-access'
36
+ end
37
+
38
+ it "DAV_HEADERS includes addressbook" do
39
+ Protocol::Caldav::Constants::DAV_HEADERS['dav'].should.include 'addressbook'
40
+ end
41
+
42
+ it "defines all required namespace URIs" do
43
+ Protocol::Caldav::Constants::DAV_NS.should.equal 'DAV:'
44
+ Protocol::Caldav::Constants::CALDAV_NS.should.equal 'urn:ietf:params:xml:ns:caldav'
45
+ Protocol::Caldav::Constants::CARDDAV_NS.should.equal 'urn:ietf:params:xml:ns:carddav'
46
+ Protocol::Caldav::Constants::CALSERVER_NS.should.equal 'http://calendarserver.org/ns/'
47
+ end
48
+
49
+ it "defines media types" do
50
+ Protocol::Caldav::Constants::CALENDAR_MEDIA_TYPE.should.equal 'text/calendar'
51
+ Protocol::Caldav::Constants::VCARD_MEDIA_TYPE.should.equal 'text/vcard'
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+
6
+ module Protocol
7
+ module Caldav
8
+ module ContentLine
9
+ module_function
10
+
11
+ # Unfold lines per RFC 5545 §3.1: CRLF followed by a single space or tab
12
+ # is removed (the space/tab is part of the folding, not the value).
13
+ # Also normalizes line endings to LF.
14
+ def unfold(text)
15
+ text.gsub("\r\n", "\n").gsub("\r", "\n").gsub(/\n[ \t]/, '')
16
+ end
17
+
18
+ # Parse a single content line into [name, params, value].
19
+ # Format: NAME;PARAM1=VAL1;PARAM2="VAL2":value
20
+ # The value is everything after the first unquoted colon.
21
+ def parse_line(line)
22
+ # Find the first colon not inside a quoted parameter value
23
+ in_quotes = false
24
+ colon_idx = nil
25
+ line.each_char.with_index do |ch, i|
26
+ if ch == '"'
27
+ in_quotes = !in_quotes
28
+ elsif ch == ':' && !in_quotes
29
+ colon_idx = i
30
+ break
31
+ end
32
+ end
33
+
34
+ return nil unless colon_idx
35
+
36
+ left = line[0...colon_idx]
37
+ value = line[(colon_idx + 1)..]
38
+
39
+ parts = split_params(left)
40
+ name = parts.shift
41
+ params = {}
42
+ parts.each do |param_str|
43
+ key, val = param_str.split('=', 2)
44
+ next unless key
45
+ val = val[1..-2] if val&.start_with?('"') && val&.end_with?('"')
46
+ params[key.upcase] = val || ''
47
+ end
48
+
49
+ [name, params, value]
50
+ end
51
+
52
+ # Split the left side of a content line by semicolons,
53
+ # respecting quoted values.
54
+ def split_params(str)
55
+ parts = []
56
+ current = +''
57
+ in_quotes = false
58
+
59
+ str.each_char do |ch|
60
+ if ch == '"'
61
+ in_quotes = !in_quotes
62
+ current << ch
63
+ elsif ch == ';' && !in_quotes
64
+ parts << current
65
+ current = +''
66
+ else
67
+ current << ch
68
+ end
69
+ end
70
+ parts << current unless current.empty?
71
+ parts
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+
78
+ test do
79
+ describe "Protocol::Caldav::ContentLine" do
80
+ describe ".unfold" do
81
+ it "unfolds CRLF SPACE continuations" do
82
+ Protocol::Caldav::ContentLine.unfold("SUMMARY:Annual\r\n planning").should.equal "SUMMARY:Annualplanning"
83
+ end
84
+
85
+ it "unfolds CRLF TAB continuations" do
86
+ Protocol::Caldav::ContentLine.unfold("SUMMARY:Annual\r\n\tplanning").should.equal "SUMMARY:Annualplanning"
87
+ end
88
+
89
+ it "does not unfold CRLF followed by non-whitespace" do
90
+ result = Protocol::Caldav::ContentLine.unfold("SUMMARY:A\r\nDTSTART:B")
91
+ result.should.equal "SUMMARY:A\nDTSTART:B"
92
+ end
93
+
94
+ it "removes the space/tab character (not kept in value)" do
95
+ Protocol::Caldav::ContentLine.unfold("X:hello\r\n world").should.equal "X:helloworld"
96
+ end
97
+
98
+ it "handles three consecutive folded lines" do
99
+ Protocol::Caldav::ContentLine.unfold("X:a\r\n b\r\n c").should.equal "X:abc"
100
+ end
101
+
102
+ it "handles LF-only line endings" do
103
+ Protocol::Caldav::ContentLine.unfold("X:a\n b").should.equal "X:ab"
104
+ end
105
+ end
106
+
107
+ describe ".parse_line" do
108
+ it "parses a property with no parameters" do
109
+ name, params, value = Protocol::Caldav::ContentLine.parse_line("SUMMARY:Hello")
110
+ name.should.equal "SUMMARY"
111
+ params.should.equal({})
112
+ value.should.equal "Hello"
113
+ end
114
+
115
+ it "parses a property with one parameter" do
116
+ name, params, value = Protocol::Caldav::ContentLine.parse_line("DTSTART;TZID=America/New_York:20260101T090000")
117
+ name.should.equal "DTSTART"
118
+ params["TZID"].should.equal "America/New_York"
119
+ value.should.equal "20260101T090000"
120
+ end
121
+
122
+ it "parses a parameter with a quoted value" do
123
+ name, params, value = Protocol::Caldav::ContentLine.parse_line('ATTENDEE;CN="Smith, John":mailto:j@e.com')
124
+ params["CN"].should.equal "Smith, John"
125
+ value.should.equal "mailto:j@e.com"
126
+ end
127
+
128
+ it "handles a colon inside a quoted parameter value" do
129
+ name, params, value = Protocol::Caldav::ContentLine.parse_line('X;FOO="a:b":realvalue')
130
+ params["FOO"].should.equal "a:b"
131
+ value.should.equal "realvalue"
132
+ end
133
+
134
+ it "handles a property value containing colons" do
135
+ _, _, value = Protocol::Caldav::ContentLine.parse_line("URL:https://example.com:8080/path")
136
+ value.should.equal "https://example.com:8080/path"
137
+ end
138
+
139
+ it "handles an empty property value" do
140
+ _, _, value = Protocol::Caldav::ContentLine.parse_line("DESCRIPTION:")
141
+ value.should.equal ""
142
+ end
143
+
144
+ it "returns nil for a line with no colon" do
145
+ Protocol::Caldav::ContentLine.parse_line("NOCOLON").should.be.nil
146
+ end
147
+
148
+ it "uppercases parameter names" do
149
+ _, params, _ = Protocol::Caldav::ContentLine.parse_line("X;tzid=utc:val")
150
+ params.key?("TZID").should.equal true
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+
6
+ require 'digest'
7
+
8
+ module Protocol
9
+ module Caldav
10
+ module CTag
11
+ module_function
12
+
13
+ def compute(path:, displayname:, description: nil, color: nil, item_etags: [])
14
+ sorted = item_etags.sort.join(":")
15
+ Digest::SHA256.hexdigest("#{path}:#{displayname}:#{description}:#{color}:#{sorted}")[0..15]
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+
22
+ test do
23
+ describe "Protocol::Caldav::CTag" do
24
+ it "is stable for the same collection state" do
25
+ a = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work")
26
+ b = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work")
27
+ a.should.equal b
28
+ end
29
+
30
+ it "changes when displayname changes" do
31
+ a = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work")
32
+ b = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Personal")
33
+ a.should.not.equal b
34
+ end
35
+
36
+ it "changes when description changes" do
37
+ a = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", description: "desc1")
38
+ b = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", description: "desc2")
39
+ a.should.not.equal b
40
+ end
41
+
42
+ it "changes when color changes" do
43
+ a = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", color: "#ff0000")
44
+ b = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", color: "#00ff00")
45
+ a.should.not.equal b
46
+ end
47
+
48
+ it "changes when an item is added" do
49
+ a = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", item_etags: ["etag1"])
50
+ b = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", item_etags: ["etag1", "etag2"])
51
+ a.should.not.equal b
52
+ end
53
+
54
+ it "changes when an item etag changes" do
55
+ a = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", item_etags: ["etag1"])
56
+ b = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", item_etags: ["etag2"])
57
+ a.should.not.equal b
58
+ end
59
+
60
+ it "changes when an item is removed" do
61
+ a = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", item_etags: ["etag1", "etag2"])
62
+ b = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", item_etags: ["etag1"])
63
+ a.should.not.equal b
64
+ end
65
+
66
+ it "does not depend on item_etags order" do
67
+ a = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", item_etags: ["b", "a"])
68
+ b = Protocol::Caldav::CTag.compute(path: "/cal/", displayname: "Work", item_etags: ["a", "b"])
69
+ a.should.equal b
70
+ end
71
+
72
+ it "same state on different paths produces different ctags" do
73
+ a = Protocol::Caldav::CTag.compute(path: "/cal/a/", displayname: "Work")
74
+ b = Protocol::Caldav::CTag.compute(path: "/cal/b/", displayname: "Work")
75
+ a.should.not.equal b
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+
6
+ require 'digest'
7
+
8
+ module Protocol
9
+ module Caldav
10
+ module ETag
11
+ module_function
12
+
13
+ def compute(body)
14
+ %("#{Digest::SHA256.hexdigest(body)[0..15]}")
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+
21
+ test do
22
+ describe "Protocol::Caldav::ETag" do
23
+ it "is stable: same body produces same etag" do
24
+ a = Protocol::Caldav::ETag.compute("hello")
25
+ b = Protocol::Caldav::ETag.compute("hello")
26
+ a.should.equal b
27
+ end
28
+
29
+ it "different bodies produce different etags" do
30
+ a = Protocol::Caldav::ETag.compute("hello")
31
+ b = Protocol::Caldav::ETag.compute("world")
32
+ a.should.not.equal b
33
+ end
34
+
35
+ it "is double-quoted per RFC 7232" do
36
+ etag = Protocol::Caldav::ETag.compute("test")
37
+ etag.should.match(/\A"[0-9a-f]+"/)
38
+ end
39
+
40
+ it "truncates to consistent length" do
41
+ etag = Protocol::Caldav::ETag.compute("test")
42
+ inner = etag[1..-2] # strip quotes
43
+ inner.length.should.equal 16
44
+ end
45
+
46
+ it "is binary-safe: body with null bytes produces an etag" do
47
+ etag = Protocol::Caldav::ETag.compute("hello\x00world")
48
+ etag.should.match(/\A"[0-9a-f]+"/)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+
6
+ module Protocol
7
+ module Caldav
8
+ module Filter
9
+ module Addressbook
10
+ Filter = Struct.new(:test, :prop_filters, keyword_init: true) do
11
+ def initialize(test: 'anyof', prop_filters: [])
12
+ super
13
+ end
14
+ end
15
+
16
+ PropFilter = Struct.new(:name, :is_not_defined, :text_match, :param_filters, keyword_init: true) do
17
+ def initialize(name:, is_not_defined: false, text_match: nil, param_filters: [])
18
+ super
19
+ end
20
+ end
21
+
22
+ ParamFilter = Struct.new(:name, :is_not_defined, :text_match, keyword_init: true) do
23
+ def initialize(name:, is_not_defined: false, text_match: nil)
24
+ super
25
+ end
26
+ end
27
+
28
+ TextMatch = Struct.new(:value, :collation, :match_type, :negate_condition, keyword_init: true) do
29
+ def initialize(value:, collation: 'i;ascii-casemap', match_type: 'contains', negate_condition: false)
30
+ super
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+
39
+ test do
40
+ describe "Protocol::Caldav::Filter::Addressbook" do
41
+ it "Filter defaults test to anyof" do
42
+ f = Protocol::Caldav::Filter::Addressbook::Filter.new
43
+ f.test.should.equal "anyof"
44
+ f.prop_filters.should.equal []
45
+ end
46
+
47
+ it "Filter accepts allof" do
48
+ f = Protocol::Caldav::Filter::Addressbook::Filter.new(test: "allof")
49
+ f.test.should.equal "allof"
50
+ end
51
+
52
+ it "PropFilter defaults" do
53
+ pf = Protocol::Caldav::Filter::Addressbook::PropFilter.new(name: "FN")
54
+ pf.name.should.equal "FN"
55
+ pf.is_not_defined.should.equal false
56
+ pf.text_match.should.be.nil
57
+ end
58
+
59
+ it "no comp-filter or time-range in addressbook" do
60
+ pf = Protocol::Caldav::Filter::Addressbook::PropFilter.new(name: "FN")
61
+ pf.should.not.respond_to(:time_range)
62
+ pf.should.not.respond_to(:comp_filters)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+
6
+ module Protocol
7
+ module Caldav
8
+ module Filter
9
+ module Calendar
10
+ CompFilter = Struct.new(:name, :is_not_defined, :time_range, :prop_filters, :comp_filters, keyword_init: true) do
11
+ def initialize(name:, is_not_defined: false, time_range: nil, prop_filters: [], comp_filters: [])
12
+ super
13
+ end
14
+ end
15
+
16
+ PropFilter = Struct.new(:name, :is_not_defined, :time_range, :text_match, :param_filters, keyword_init: true) do
17
+ def initialize(name:, is_not_defined: false, time_range: nil, text_match: nil, param_filters: [])
18
+ super
19
+ end
20
+ end
21
+
22
+ ParamFilter = Struct.new(:name, :is_not_defined, :text_match, keyword_init: true) do
23
+ def initialize(name:, is_not_defined: false, text_match: nil)
24
+ super
25
+ end
26
+ end
27
+
28
+ TextMatch = Struct.new(:value, :collation, :match_type, :negate_condition, keyword_init: true) do
29
+ def initialize(value:, collation: 'i;ascii-casemap', match_type: 'contains', negate_condition: false)
30
+ super
31
+ end
32
+ end
33
+
34
+ TimeRange = Struct.new(:start_time, :end_time, keyword_init: true)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+
41
+ test do
42
+ describe "Protocol::Caldav::Filter::Calendar" do
43
+ it "CompFilter accepts documented fields" do
44
+ cf = Protocol::Caldav::Filter::Calendar::CompFilter.new(name: "VCALENDAR")
45
+ cf.name.should.equal "VCALENDAR"
46
+ cf.is_not_defined.should.equal false
47
+ cf.time_range.should.be.nil
48
+ cf.prop_filters.should.equal []
49
+ cf.comp_filters.should.equal []
50
+ end
51
+
52
+ it "PropFilter defaults" do
53
+ pf = Protocol::Caldav::Filter::Calendar::PropFilter.new(name: "SUMMARY")
54
+ pf.name.should.equal "SUMMARY"
55
+ pf.is_not_defined.should.equal false
56
+ pf.text_match.should.be.nil
57
+ pf.param_filters.should.equal []
58
+ end
59
+
60
+ it "TextMatch defaults" do
61
+ tm = Protocol::Caldav::Filter::Calendar::TextMatch.new(value: "test")
62
+ tm.collation.should.equal "i;ascii-casemap"
63
+ tm.match_type.should.equal "contains"
64
+ tm.negate_condition.should.equal false
65
+ end
66
+
67
+ it "TimeRange holds start and end" do
68
+ tr = Protocol::Caldav::Filter::Calendar::TimeRange.new(start_time: "20260101T000000Z", end_time: "20260201T000000Z")
69
+ tr.start_time.should.equal "20260101T000000Z"
70
+ tr.end_time.should.equal "20260201T000000Z"
71
+ end
72
+
73
+ it "CompFilter with is_not_defined and other fields is valid" do
74
+ cf = Protocol::Caldav::Filter::Calendar::CompFilter.new(
75
+ name: "VTODO",
76
+ is_not_defined: true,
77
+ prop_filters: [Protocol::Caldav::Filter::Calendar::PropFilter.new(name: "X")]
78
+ )
79
+ cf.is_not_defined.should.equal true
80
+ cf.prop_filters.length.should.equal 1
81
+ end
82
+ end
83
+ end