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
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 & <Personal>"
|
|
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
|