icalendar 0.95
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.
- data/COPYING +56 -0
- data/GPL +340 -0
- data/README +121 -0
- data/Rakefile +101 -0
- data/docs/api/classes/Array.html +146 -0
- data/docs/api/classes/Date.html +157 -0
- data/docs/api/classes/DateTime.html +178 -0
- data/docs/api/classes/Fixnum.html +146 -0
- data/docs/api/classes/Float.html +146 -0
- data/docs/api/classes/Icalendar/Alarm.html +184 -0
- data/docs/api/classes/Icalendar/Base.html +118 -0
- data/docs/api/classes/Icalendar/Calendar.html +411 -0
- data/docs/api/classes/Icalendar/Component.html +306 -0
- data/docs/api/classes/Icalendar/DateProp.html +187 -0
- data/docs/api/classes/Icalendar/DateProp/ClassMethods.html +195 -0
- data/docs/api/classes/Icalendar/Event.html +202 -0
- data/docs/api/classes/Icalendar/Freebusy.html +157 -0
- data/docs/api/classes/Icalendar/InvalidComponentClass.html +117 -0
- data/docs/api/classes/Icalendar/InvalidPropertyValue.html +117 -0
- data/docs/api/classes/Icalendar/Journal.html +190 -0
- data/docs/api/classes/Icalendar/Parameter.html +166 -0
- data/docs/api/classes/Icalendar/Parser.html +447 -0
- data/docs/api/classes/Icalendar/Timezone.html +197 -0
- data/docs/api/classes/Icalendar/Todo.html +199 -0
- data/docs/api/classes/String.html +160 -0
- data/docs/api/classes/Time.html +161 -0
- data/docs/api/created.rid +1 -0
- data/docs/api/files/COPYING.html +163 -0
- data/docs/api/files/GPL.html +531 -0
- data/docs/api/files/README.html +241 -0
- data/docs/api/files/lib/icalendar/base_rb.html +108 -0
- data/docs/api/files/lib/icalendar/calendar_rb.html +101 -0
- data/docs/api/files/lib/icalendar/component/alarm_rb.html +101 -0
- data/docs/api/files/lib/icalendar/component/event_rb.html +101 -0
- data/docs/api/files/lib/icalendar/component/freebusy_rb.html +101 -0
- data/docs/api/files/lib/icalendar/component/journal_rb.html +101 -0
- data/docs/api/files/lib/icalendar/component/timezone_rb.html +101 -0
- data/docs/api/files/lib/icalendar/component/todo_rb.html +101 -0
- data/docs/api/files/lib/icalendar/component_rb.html +101 -0
- data/docs/api/files/lib/icalendar/conversions_rb.html +108 -0
- data/docs/api/files/lib/icalendar/helpers_rb.html +101 -0
- data/docs/api/files/lib/icalendar/parameter_rb.html +101 -0
- data/docs/api/files/lib/icalendar/parser_rb.html +109 -0
- data/docs/api/files/lib/icalendar_rb.html +118 -0
- data/docs/api/fr_class_index.html +48 -0
- data/docs/api/fr_file_index.html +43 -0
- data/docs/api/fr_method_index.html +63 -0
- data/docs/api/index.html +24 -0
- data/docs/api/rdoc-style.css +208 -0
- data/docs/examples/create_cal.rb +40 -0
- data/docs/examples/parse_cal.rb +19 -0
- data/docs/examples/single_event.ics +18 -0
- data/docs/rfcs/rfc2425.pdf +0 -0
- data/docs/rfcs/rfc2426.pdf +0 -0
- data/docs/rfcs/rfc2445.pdf +0 -0
- data/docs/rfcs/rfc2446.pdf +0 -0
- data/docs/rfcs/rfc2447.pdf +0 -0
- data/docs/rfcs/rfc3283.txt +738 -0
- data/lib/icalendar.rb +27 -0
- data/lib/icalendar/#helpers.rb# +92 -0
- data/lib/icalendar/base.rb +31 -0
- data/lib/icalendar/calendar.rb +112 -0
- data/lib/icalendar/component.rb +253 -0
- data/lib/icalendar/component/alarm.rb +35 -0
- data/lib/icalendar/component/event.rb +68 -0
- data/lib/icalendar/component/freebusy.rb +35 -0
- data/lib/icalendar/component/journal.rb +61 -0
- data/lib/icalendar/component/timezone.rb +43 -0
- data/lib/icalendar/component/todo.rb +65 -0
- data/lib/icalendar/conversions.rb +115 -0
- data/lib/icalendar/helpers.rb +109 -0
- data/lib/icalendar/parameter.rb +33 -0
- data/lib/icalendar/parser.rb +395 -0
- data/test/calendar_test.rb +46 -0
- data/test/component/event_test.rb +47 -0
- data/test/component_test.rb +74 -0
- data/test/parser_test.rb +83 -0
- data/test/property_helpers.rb +35 -0
- data/test/simplecal.ics +119 -0
- data/test/single_event.ics +23 -0
- metadata +135 -0
@@ -0,0 +1,33 @@
|
|
1
|
+
=begin
|
2
|
+
Copyright (C) 2005 Jeff Rose
|
3
|
+
|
4
|
+
This library is free software; you can redistribute it and/or modify it
|
5
|
+
under the same terms as the ruby language itself, see the file COPYING for
|
6
|
+
details.
|
7
|
+
=end
|
8
|
+
|
9
|
+
module Icalendar
|
10
|
+
|
11
|
+
# A property can have attributes associated with it. These "property
|
12
|
+
# parameters" contain meta-information about the property or the
|
13
|
+
# property value. Property parameters are provided to specify such
|
14
|
+
# information as the location of an alternate text representation for a
|
15
|
+
# property value, the language of a text property value, the data type
|
16
|
+
# of the property value and other attributes.
|
17
|
+
class Parameter < Icalendar::Content
|
18
|
+
|
19
|
+
def to_s
|
20
|
+
s = ""
|
21
|
+
|
22
|
+
s << "#{@name}="
|
23
|
+
if is_escapable?
|
24
|
+
s << escape(print_value())
|
25
|
+
else
|
26
|
+
s << print_value
|
27
|
+
end
|
28
|
+
|
29
|
+
s
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,395 @@
|
|
1
|
+
=begin
|
2
|
+
Copyright (C) 2005 Jeff Rose
|
3
|
+
Copyright (C) 2005 Sam Roberts
|
4
|
+
|
5
|
+
This library is free software; you can redistribute it and/or modify it
|
6
|
+
under the same terms as the ruby language itself, see the file COPYING for
|
7
|
+
details.
|
8
|
+
=end
|
9
|
+
|
10
|
+
require 'date'
|
11
|
+
require 'uri'
|
12
|
+
|
13
|
+
module Icalendar
|
14
|
+
class Parser < Icalendar::Base
|
15
|
+
# 1*(ALPHA / DIGIT / "=")
|
16
|
+
NAME = '[-a-z0-9]+'
|
17
|
+
|
18
|
+
# <"> <Any character except CTLs, DQUOTE> <">
|
19
|
+
QSTR = '"[^"]*"'
|
20
|
+
|
21
|
+
# *<Any character except CTLs, DQUOTE, ";", ":", ",">
|
22
|
+
PTEXT = '[^";:,]*'
|
23
|
+
|
24
|
+
# param-value = ptext / quoted-string
|
25
|
+
PVALUE = "#{PTEXT}|#{QSTR}"
|
26
|
+
|
27
|
+
# Contentline
|
28
|
+
LINE = "(#{NAME})([^:]*)\:(.*)"
|
29
|
+
|
30
|
+
# param = name "=" param-value *("," param-value)
|
31
|
+
PARAM = ";(#{NAME})(=?)((?:#{PVALUE})(?:,#{PVALUE})*)"
|
32
|
+
|
33
|
+
# date = date-fullyear ["-"] date-month ["-"] date-mday
|
34
|
+
# date-fullyear = 4 DIGIT
|
35
|
+
# date-month = 2 DIGIT
|
36
|
+
# date-mday = 2 DIGIT
|
37
|
+
DATE = '(\d\d\d\d)-?(\d\d)-?(\d\d)'
|
38
|
+
|
39
|
+
# time = time-hour [":"] time-minute [":"] time-second [time-secfrac] [time-zone]
|
40
|
+
# time-hour = 2 DIGIT
|
41
|
+
# time-minute = 2 DIGIT
|
42
|
+
# time-second = 2 DIGIT
|
43
|
+
# time-secfrac = "," 1*DIGIT
|
44
|
+
# time-zone = "Z" / time-numzone
|
45
|
+
# time-numzome = sign time-hour [":"] time-minute
|
46
|
+
TIME = '(\d\d):?(\d\d):?(\d\d)(\.\d+)?(Z|[-+]\d\d:?\d\d)?'
|
47
|
+
|
48
|
+
def initialize(src)
|
49
|
+
@@logger.debug("New Calendar Parser")
|
50
|
+
|
51
|
+
# Setup the parser method hash table
|
52
|
+
setup_parsers()
|
53
|
+
|
54
|
+
# Define the next line method different depending on whether
|
55
|
+
# this is a string or an IO object so we can be efficient about
|
56
|
+
# parsing large files...
|
57
|
+
|
58
|
+
# Just do the unfolding work in one shot if its a whole string
|
59
|
+
if src.respond_to?(:split)
|
60
|
+
unfolded = []
|
61
|
+
|
62
|
+
# Split into an array of lines, then unfold those into a new array
|
63
|
+
src.split(/\r?\n/).each do |line|
|
64
|
+
|
65
|
+
# If it's a continuation line, add it to the last.
|
66
|
+
# If it's an empty line, drop it from the input.
|
67
|
+
if( line =~ /^[ \t]/ )
|
68
|
+
unfolded << unfolded.pop + line[1, line.size-1]
|
69
|
+
elsif( line =~ /^$/ )
|
70
|
+
else
|
71
|
+
unfolded << line
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
@lines = unfolded
|
76
|
+
@index = 0
|
77
|
+
|
78
|
+
# Now that we are unfolded we can just iterate through the array.
|
79
|
+
# Dynamically define next line for a string.
|
80
|
+
def next_line
|
81
|
+
if @index == @lines.size
|
82
|
+
return nil
|
83
|
+
else
|
84
|
+
line = @lines[@index]
|
85
|
+
@index += 1
|
86
|
+
return line
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# If its a file we need to read and unfold on the go to save from reading
|
91
|
+
# large amounts of data into memory.
|
92
|
+
elsif src.respond_to?(:gets)
|
93
|
+
@file = src
|
94
|
+
@prev_line = src.gets
|
95
|
+
if !@prev_line.nil?
|
96
|
+
@prev_line.chomp!
|
97
|
+
end
|
98
|
+
|
99
|
+
# Dynamically define next line for an IO object
|
100
|
+
def next_line
|
101
|
+
line = @prev_line
|
102
|
+
|
103
|
+
if line.nil?
|
104
|
+
return nil
|
105
|
+
end
|
106
|
+
|
107
|
+
# Loop through until we get to a non-continuation line...
|
108
|
+
loop do
|
109
|
+
nextLine = @file.gets
|
110
|
+
if !nextLine.nil?
|
111
|
+
nextLine.chomp!
|
112
|
+
end
|
113
|
+
|
114
|
+
# If it's a continuation line, add it to the last.
|
115
|
+
# If it's an empty line, drop it from the input.
|
116
|
+
if( nextLine =~ /^[ \t]/ )
|
117
|
+
line << nextLine[1, nextLine.size]
|
118
|
+
elsif( nextLine =~ /^$/ )
|
119
|
+
else
|
120
|
+
@prev_line = nextLine
|
121
|
+
break
|
122
|
+
end
|
123
|
+
end
|
124
|
+
line
|
125
|
+
end
|
126
|
+
else
|
127
|
+
raise ArgumentError, "CalendarParser.new cannot be called with a #{src.class} type!"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Parse the calendar into an object representation
|
132
|
+
def parse
|
133
|
+
calendars = []
|
134
|
+
|
135
|
+
# Outer loop for Calendar objects
|
136
|
+
while (line = next_line)
|
137
|
+
fields = parse_line(line)
|
138
|
+
|
139
|
+
# Just iterate through until we find the beginning of a calendar object
|
140
|
+
if fields[:name] == "BEGIN" and fields[:value] == "VCALENDAR"
|
141
|
+
cal = parse_component
|
142
|
+
calendars << cal
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
calendars
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
# Parse a single VCALENDAR object
|
152
|
+
# -- This should consist of the PRODID, VERSION, option METHOD & CALSCALE,
|
153
|
+
# and then one or more calendar components: VEVENT, VTODO, VJOURNAL,
|
154
|
+
# VFREEBUSY, VTIMEZONE
|
155
|
+
def parse_component(component = Calendar.new)
|
156
|
+
while (line = next_line)
|
157
|
+
fields = parse_line(line)
|
158
|
+
|
159
|
+
name = fields[:name]
|
160
|
+
|
161
|
+
# Although properties are supposed to come before components, we should
|
162
|
+
# be able to handle them in any order...
|
163
|
+
if name == "END"
|
164
|
+
break
|
165
|
+
elsif name == "BEGIN" # New component
|
166
|
+
case(fields[:value])
|
167
|
+
when "VEVENT"
|
168
|
+
component.events << parse_component(Event.new)
|
169
|
+
when "VTODO"
|
170
|
+
component.todos << parse_component(Todo.new)
|
171
|
+
when "VJOURNAL"
|
172
|
+
component.journals << parse_component(Journal.new)
|
173
|
+
when "VFREEBUSY"
|
174
|
+
component.freebusys << parse_component(Freebusy.new)
|
175
|
+
when "VTIMEZONE"
|
176
|
+
component.timezones << parse_component(Timezone.new)
|
177
|
+
when "VALARM"
|
178
|
+
component.alarms << parse_component(Alarm.new)
|
179
|
+
end
|
180
|
+
else # If its not a component then it should be a property
|
181
|
+
|
182
|
+
# Just set the properties so that the parser can still
|
183
|
+
# parse invalid files...
|
184
|
+
@@logger.debug("Setting #{name} => #{fields[:value]}")
|
185
|
+
|
186
|
+
# Lookup the property name to see if we have a string to
|
187
|
+
# object parser for this property type.
|
188
|
+
if @parsers.has_key?(name.upcase)
|
189
|
+
val = @parsers[name.upcase].call(name, fields[:params], fields[:value])
|
190
|
+
else
|
191
|
+
val = fields[:value]
|
192
|
+
end
|
193
|
+
|
194
|
+
if component.multi_property?(name.upcase) && component.properties
|
195
|
+
val = [val]
|
196
|
+
|
197
|
+
if fields[:params].empty?
|
198
|
+
params = [nil]
|
199
|
+
else
|
200
|
+
params = fields[:params]
|
201
|
+
end
|
202
|
+
|
203
|
+
if component.properties.has_key?(name)
|
204
|
+
component.properties[name] += val
|
205
|
+
component.property_params[name] += params
|
206
|
+
else
|
207
|
+
component.properties[name] = val
|
208
|
+
component.property_params[name] = params
|
209
|
+
end
|
210
|
+
|
211
|
+
else
|
212
|
+
component.properties[name] = val
|
213
|
+
|
214
|
+
unless fields[:params].empty?
|
215
|
+
component.property_params[name] = fields[:params]
|
216
|
+
end
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
component
|
222
|
+
|
223
|
+
end
|
224
|
+
|
225
|
+
def parse_line(line)
|
226
|
+
unless line =~ %r{#{LINE}}i # Case insensitive match for a valid line
|
227
|
+
raise "Invalid line in calendar string!"
|
228
|
+
end
|
229
|
+
|
230
|
+
name = $1.upcase # The case insensitive part is upcased for easier comparison...
|
231
|
+
paramslist = $2
|
232
|
+
value = $3
|
233
|
+
|
234
|
+
params = {}
|
235
|
+
|
236
|
+
# Collect the params, if any.
|
237
|
+
if paramslist.size > 1
|
238
|
+
|
239
|
+
# v3.0 and v2.1 params
|
240
|
+
paramslist.scan( %r{#{PARAM}}i ) do
|
241
|
+
|
242
|
+
# param names are case-insensitive, and multi-valued
|
243
|
+
pname = $1
|
244
|
+
pvals = $3
|
245
|
+
|
246
|
+
# v2.1 pvals have no '=' sign, figure out what kind of param it
|
247
|
+
# is (either its a known encoding, or we treat it as a 'type'
|
248
|
+
# param).
|
249
|
+
if $2 == ""
|
250
|
+
pvals = $1
|
251
|
+
case $1
|
252
|
+
when /quoted-printable/i
|
253
|
+
pname = 'encoding'
|
254
|
+
|
255
|
+
when /base64/i
|
256
|
+
pname = 'encoding'
|
257
|
+
|
258
|
+
else
|
259
|
+
pname = 'type'
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
unless params.key? pname
|
264
|
+
params[pname] = []
|
265
|
+
end
|
266
|
+
pvals.scan( %r{(#{PVALUE})} ) do
|
267
|
+
if $1.size > 0
|
268
|
+
params[pname] << $1
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
{:name => name, :params => params, :value => value}
|
275
|
+
end
|
276
|
+
|
277
|
+
## Following is a collection of parsing functions for various
|
278
|
+
## icalendar property value data types... First we setup
|
279
|
+
## a hash with property names pointing to methods...
|
280
|
+
def setup_parsers
|
281
|
+
@parsers = {}
|
282
|
+
|
283
|
+
# Integer properties
|
284
|
+
m = self.method(:parse_integer)
|
285
|
+
@parsers["PERCENT-COMPLETE"] = m
|
286
|
+
@parsers["PRIORITY"] = m
|
287
|
+
@parsers["REPEAT"] = m
|
288
|
+
@parsers["SEQUENCE"] = m
|
289
|
+
|
290
|
+
# Dates and Times
|
291
|
+
m = self.method(:parse_datetime)
|
292
|
+
@parsers["COMPLETED"] = m
|
293
|
+
@parsers["DTEND"] = m
|
294
|
+
@parsers["DUE"] = m
|
295
|
+
@parsers["DTSTART"] = m
|
296
|
+
@parsers["RECURRENCE-ID"] = m
|
297
|
+
@parsers["EXDATE"] = m
|
298
|
+
@parsers["RDATE"] = m
|
299
|
+
@parsers["CREATED"] = m
|
300
|
+
@parsers["DTSTAMP"] = m
|
301
|
+
@parsers["LAST-MODIFIED"] = m
|
302
|
+
|
303
|
+
# URI's
|
304
|
+
m = self.method(:parse_uri)
|
305
|
+
@parsers["TZURL"] = m
|
306
|
+
@parsers["ATTENDEE"] = m
|
307
|
+
@parsers["ORGANIZER"] = m
|
308
|
+
@parsers["URL"] = m
|
309
|
+
|
310
|
+
# This is a URI by default, and if its not a valid URI
|
311
|
+
# it will be returned as a string which works for binary data
|
312
|
+
# the other possible type.
|
313
|
+
@parsers["ATTACH"] = m
|
314
|
+
|
315
|
+
# GEO
|
316
|
+
m = self.method(:parse_geo)
|
317
|
+
@parsers["GEO"] = m
|
318
|
+
|
319
|
+
end
|
320
|
+
|
321
|
+
# Booleans
|
322
|
+
# NOTE: It appears that although this is a valid data type
|
323
|
+
# there aren't any properties that use it... Maybe get
|
324
|
+
# rid of this in the future.
|
325
|
+
def parse_boolean(name, params, value)
|
326
|
+
if value.upcase == "FALSE"
|
327
|
+
false
|
328
|
+
else
|
329
|
+
true
|
330
|
+
end
|
331
|
+
end
|
332
|
+
|
333
|
+
# Dates, Date-Times & Times
|
334
|
+
# NOTE: invalid dates & times will be returned as strings...
|
335
|
+
def parse_datetime(name, params, value)
|
336
|
+
begin
|
337
|
+
DateTime.parse(value)
|
338
|
+
rescue Exception
|
339
|
+
value
|
340
|
+
end
|
341
|
+
|
342
|
+
end
|
343
|
+
|
344
|
+
# Durations
|
345
|
+
# TODO: Need to figure out the best way to represent durations
|
346
|
+
# so just returning string for now.
|
347
|
+
def parse_duration(name, params, value)
|
348
|
+
value
|
349
|
+
end
|
350
|
+
|
351
|
+
# Floats
|
352
|
+
# NOTE: returns 0.0 if it can't parse the value
|
353
|
+
def parse_float(name, params, value)
|
354
|
+
value.to_f
|
355
|
+
end
|
356
|
+
|
357
|
+
# Integers
|
358
|
+
# NOTE: returns 0 if it can't parse the value
|
359
|
+
def parse_integer(name, params, value)
|
360
|
+
value.to_i
|
361
|
+
end
|
362
|
+
|
363
|
+
# Periods
|
364
|
+
# TODO: Got to figure out how to represent periods also...
|
365
|
+
def parse_period(name, params, value)
|
366
|
+
value
|
367
|
+
end
|
368
|
+
|
369
|
+
# Calendar Address's & URI's
|
370
|
+
# NOTE: invalid URI's will be returned as strings...
|
371
|
+
def parse_uri(name, params, value)
|
372
|
+
begin
|
373
|
+
URI.parse(value)
|
374
|
+
rescue Exception
|
375
|
+
value
|
376
|
+
end
|
377
|
+
end
|
378
|
+
|
379
|
+
# Geographical location (GEO)
|
380
|
+
# NOTE: returns an array with two floats (long & lat)
|
381
|
+
# if the parsing fails return the string
|
382
|
+
def parse_geo(name, params, value)
|
383
|
+
strloc = value.split(';')
|
384
|
+
if strloc.size != 2
|
385
|
+
return value
|
386
|
+
end
|
387
|
+
|
388
|
+
val = []
|
389
|
+
val[0] = strloc[0].to_f
|
390
|
+
val[1] = strloc[1].to_f
|
391
|
+
val
|
392
|
+
end
|
393
|
+
|
394
|
+
end
|
395
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
$:.unshift(File.dirname(__FILE__) + '/../lib')
|
2
|
+
|
3
|
+
require 'test/unit'
|
4
|
+
require 'icalendar'
|
5
|
+
|
6
|
+
require 'date'
|
7
|
+
|
8
|
+
class TestCalendar < Test::Unit::TestCase
|
9
|
+
# Generate a calendar using the raw api, and then spit it out
|
10
|
+
# as a string. Parse the string and make sure everything matches up.
|
11
|
+
def test_raw_generation
|
12
|
+
# Create a fresh calendar
|
13
|
+
cal = Icalendar::Calendar.new
|
14
|
+
|
15
|
+
cal.calscale = "GREGORIAN"
|
16
|
+
cal.version = "3.2"
|
17
|
+
cal.prodid = "test-prodid"
|
18
|
+
|
19
|
+
# Now generate the string and then parse it so we can verify
|
20
|
+
# that everything was set, generated and parsed correctly.
|
21
|
+
calString = cal.to_ical
|
22
|
+
|
23
|
+
cals = Icalendar::Parser.new(calString).parse
|
24
|
+
|
25
|
+
cal2 = cals.first
|
26
|
+
assert_equal("GREGORIAN", cal2.calscale)
|
27
|
+
assert_equal("3.2", cal2.version)
|
28
|
+
assert_equal("test-prodid", cal2.prodid)
|
29
|
+
end
|
30
|
+
|
31
|
+
def test_block_creation
|
32
|
+
cal = Icalendar::Calendar.new
|
33
|
+
cal.event do
|
34
|
+
dtend "19970903T190000Z"
|
35
|
+
summary "This is my summary"
|
36
|
+
end
|
37
|
+
|
38
|
+
event = cal.event
|
39
|
+
event.dtend = "19970903T190000Z"
|
40
|
+
event.summary = "This is my summary"
|
41
|
+
|
42
|
+
ev = cal.events.each do |ev|
|
43
|
+
assert_equal("19970903T190000Z", ev.dtend)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|