vpim 0.16

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,555 @@
1
+ =begin
2
+ $Id: icalendar.rb,v 1.24 2005/01/07 03:32:44 sam Exp $
3
+
4
+ Copyright (C) 2005 Sam Roberts
5
+
6
+ This library is free software; you can redistribute it and/or modify it
7
+ under the same terms as the ruby language itself, see the file COPYING for
8
+ details.
9
+ =end
10
+
11
+ require 'vpim/rfc2425'
12
+ require 'vpim/dirinfo'
13
+ require 'vpim/rrule'
14
+ require 'vpim/vevent'
15
+ require 'vpim/vpim'
16
+
17
+ =begin
18
+
19
+ ... ; y/n (whether I've seen it in Apple's calendars)
20
+ name
21
+ section
22
+ type/comments
23
+
24
+
25
+ icalbody = icalprops component
26
+
27
+ icalprops =
28
+ prodid / ; y PRODID 4.7.3 required, TEXT
29
+ version / ; y 4.7.4 required, TEXT, "2.0"
30
+ calscal / ; y 4.7.1 only defined value is GREGORIAN
31
+ method / ; n METHOD 4.7.2 used with transport protocols
32
+
33
+ component =
34
+ eventc / ; y VEVENT 4.6.1
35
+ todoc / ; y VTODO 4.6.2
36
+ journalc / ; n
37
+ freebusyc / ; n
38
+ timezonec / ; n
39
+
40
+ alarmc ; y VALARM 4.6.6 occurs inside a VEVENT or a VTODO
41
+
42
+ class ; y CLASS 4.8.1.3 private/public/confidentical/... (default=public)
43
+
44
+ comment ; n 4.8.1.4 TEXT
45
+ description ; y 4.8.1.5 TEXT
46
+ summary ; y 4.8.1.12 TEXT
47
+ location ; y 4.8.1.7 TEXT intended venue
48
+
49
+ priority ; n why? 4.8.1.9 INTEGER, why isn't this seen for my TODO items?
50
+
51
+ status ; y 4.8.1.11 TEXT, different values defined for event, todo, journal
52
+
53
+ Event: TENTATIVE, CONFIRMED, CANCELLED
54
+
55
+ Todo: NEEDS-ACTION, COMPLETED, IN-PROCESS, CANCELLED
56
+
57
+ Journal: DRAFT, FINAL, CANCELLED
58
+
59
+ dtstart ; y DTSTART 4.8.2.4 DATE-TIME is default, value=date can be set
60
+ dtend ; y DTEND 4.8.2.2 Unless it has Z (UTC), or a tzid, then it is local-time.
61
+
62
+ dtstamp ; y DTSTAMP 4.8.7.2 DATE-TIME, creation time, inclusion is mandatory, but what does
63
+ it mean? It seems to be when the icalendar was actually created (as opposed to when the user entered
64
+ the information into the calendar database, for example), but in that case my Apple icalendars should
65
+ have all components having the same DTSTAMP, but they don't!
66
+
67
+ duration ; y DURATION 4.8.2.5 dur-value
68
+
69
+ dur-value = (["+"] / "-") "P" (dur-date / dur-time / dur-week)
70
+
71
+ dur-date = dur-day [dur-time]
72
+ = 1*DIGIT "D" [ dur-time ]
73
+
74
+ dur-time = "T" (dur-hour / dur-minute / dur-second)
75
+ = "T" (
76
+ 1*DIGIT "H" [ 1*DIGIT "M" [ 1*DIGIT "S" ] ] /
77
+ 1*DIGIT "M" [ 1*DIGIT "S" ] /
78
+ 1*DIGIT "S"
79
+ )
80
+
81
+ dur-week = 1*DIGIT "W"
82
+ dur-day = 1*DIGIT "D"
83
+ dur-hour = 1*DIGIT "H" [dur-minute]
84
+ dur-minute = 1*DIGIT "M" [dur-second]
85
+ dur-second = 1*DIGIT "S"
86
+
87
+ The EBNF is complicated, because they want to say that /some/ component
88
+ must be present, and that if you have a "T", you need a time after it,
89
+ and that you can't have an hour followed by seconds with no intervening
90
+ minutes... but we don't care about that during decoding, so we rewrite
91
+ the EBNF as:
92
+
93
+ dur-value = ["+" / "-"] "P" [ 1*DIGIT "W" ] [ 1*DIGIT "D" ] [ "T" [ 1*DIGIT "H" ] [ 1*DIGIT "M" ] [ 1*DIGIT "S" ] ]
94
+
95
+ dtdue ; n DTDUE 4.8.2.3
96
+
97
+ uid ; y UID 4.8.4.7 TEXT, recommended to generate them in RFC822 form
98
+
99
+ rrule ; y RRULE 4.8.5.4 RECUR, can occur multiple times!
100
+
101
+
102
+
103
+ VEVENT Ifx:
104
+
105
+ TEXT: summary, description, comment, location, uid
106
+
107
+ think about: uid
108
+
109
+ Vevent#status -> The status, upper-case.
110
+ Vevent#status= -> set a new status, only allow the defined statuses!
111
+ Vevent#status?(s) -> check if the status is s
112
+
113
+ can contain alarms... should it include an alarms module?
114
+
115
+ =end
116
+
117
+ module Vpim
118
+ # An iCalendar.
119
+ #
120
+ # A Calendar is some meta-information followed by a sequence of components.
121
+ #
122
+ # Defined components are Event, Todo, Freebusy, Journal, and Timezone, each
123
+ # of which are represented by their own class, though they share many
124
+ # properties in common. For example, Event and Todo may both contain
125
+ # multiple Alarm components.
126
+ #
127
+ # = Reference
128
+ #
129
+ # The iCalendar format is specified by a series of IETF documents:
130
+ #
131
+ # - link:rfc2445.txt: Internet Calendaring and Scheduling Core Object Specification
132
+ # - link:rfc2446.txt: iCalendar Transport-Independent Interoperability Protocol
133
+ # (iTIP) Scheduling Events, BusyTime, To-dos and Journal Entries
134
+ # - link:rfc2447.txt: iCalendar Message-Based Interoperability Protocol
135
+ #
136
+ # iCalendar (RFC 2445) is based on vCalendar, but does not appear to be
137
+ # altogether compatible. iCalendar files have VERSION:2.0 and vCalendar have
138
+ # VERSION:1.0. While much appears to be similar, the recurrence rule syntax,
139
+ # at least, is completely different.
140
+ #
141
+ # iCalendars are usually transmitted in files with <code>.ics</code>
142
+ # extensions.
143
+ class Icalendar
144
+ include Vpim
145
+
146
+ # Regular expression strings for the EBNF of RFC 2445
147
+ module Bnf #:nodoc:
148
+ # dur-value = ["+" / "-"] "P" [ 1*DIGIT "W" ] [ 1*DIGIT "D" ] [ "T" [ 1*DIGIT "H" ] [ 1*DIGIT "M" ] [ 1*DIGIT "S" ] ]
149
+ DURATION = '([-+])?P(\d+W)?(\d+D)?T?(\d+H)?(\d+M)?(\d+S)?'
150
+ end
151
+
152
+ private_class_method :new
153
+
154
+ # Create a new Icalendar object from +fields+, an array of
155
+ # DirectoryInfo::Field objects.
156
+ #
157
+ # When decoding Calendar data, you would usually use Icalendar.decode(),
158
+ # which decodes the data into the field arrays, and calls this method
159
+ # for each Calendar it finds.
160
+ def initialize(fields) #:nodoc:
161
+ # seperate into the outer-level fields, and the arrays of component
162
+ # fields
163
+ outer, inner = Vpim.outer_inner(fields)
164
+
165
+ # Make a dirinfo out of outer, and check its an iCalendar
166
+ @properties = DirectoryInfo.create(outer)
167
+ @properties.check_begin_end('VCALENDAR')
168
+
169
+ # Categorize the components
170
+ @vevents = []
171
+ @vtodos = []
172
+ @others = []
173
+
174
+ inner.each do |component|
175
+ # First field in every component should be a "BEGIN:".
176
+ name = component.first
177
+ if ! name.name? 'begin'
178
+ raise InvalidEncodingError, "calendar component begins with #{name.name}, instead of BEGIN!"
179
+ end
180
+
181
+ name = name.value.upcase
182
+
183
+ case name
184
+ when 'VEVENT' then @vevents << Vevent.new(component)
185
+ when 'VTODO' then @vtodos << Vtodo.new(component)
186
+ else @others << component
187
+ end
188
+ end
189
+ end
190
+
191
+ # Create a new Icalendar object with the minimal set of fields for a valid
192
+ # Calendar. If specified, +fields+ must be an array of
193
+ # DirectoryInfo::Field objects to add. They can override the the default
194
+ # Calendar fields, so, for example, this can be used to set a custom PRODID field.
195
+ #
196
+ # TODO - allow hash args like Vevent.create
197
+ def Icalendar.create(fields=[])
198
+ di = DirectoryInfo.create( [ DirectoryInfo::Field.create('VERSION', '2.0') ], 'VCALENDAR' )
199
+
200
+ DirectoryInfo::Field.create_array(fields).each { |f| di.push_unique f }
201
+
202
+ di.push_unique DirectoryInfo::Field.create('PRODID', "-//Ensemble Independant//vPim #{Vpim.version}//EN")
203
+ di.push_unique DirectoryInfo::Field.create('CALSCALE', "Gregorian")
204
+
205
+ new(di.to_a)
206
+ end
207
+
208
+ # Create a new Icalendar object with a protocol method of REPLY.
209
+ #
210
+ # Meeting requests, and such, are Calendar containers with a protocol
211
+ # method of REQUEST, and contains some number of Events, Todos, etc.,
212
+ # that may need replying to. In order to reply to any of these components
213
+ # of a request, you must first build a Calendar object to hold your reply
214
+ # components.
215
+ #
216
+ # This method builds the reply Calendar, you then will add to it replies
217
+ # to the specific components of the request Calendar that you are replying
218
+ # to. If you have any particular fields that you want to be in the
219
+ # Calendar, other than the defaults, then can be supplied as +fields+, an
220
+ # array of Field objects.
221
+ def Icalendar.create_reply(fields=[])
222
+ fields << DirectoryInfo::Field.create('METHOD', 'REPLY')
223
+
224
+ Icalendar.create(fields)
225
+ end
226
+
227
+ # Encode the Calendar as a string. The width is the maximum width of the
228
+ # encoded lines, it can be specified, but is better left to the default.
229
+ #
230
+ # TODO - only does top-level now, needs to add the events/todos/etc.
231
+ def encode(width=nil)
232
+ # We concatenate the fields of all objects, create a DirInfo, then
233
+ # encode it.
234
+ di = DirectoryInfo.create(self.fields.flatten)
235
+ di.encode(width)
236
+ end
237
+
238
+ # Used during encoding.
239
+ def fields # :nodoc:
240
+ fields = @properties.to_a
241
+
242
+ last = fields.pop
243
+
244
+ @vevents.each { |c| fields << c.fields }
245
+ @vtodos.each { |c| fields << c.fields }
246
+ @others.each { |c| fields << c.fields }
247
+
248
+ fields << last
249
+ end
250
+
251
+ alias to_s encode
252
+
253
+ # Push a calendar component onto the calendar.
254
+ def push(component)
255
+ case component
256
+ when Vevent
257
+ @vevents << component
258
+ when Vtodo
259
+ @vtodos << component
260
+ else
261
+ raise ArgumentError, "can't add component type #{component.type} to a calendar"
262
+ end
263
+ end
264
+
265
+ # Check if the protocol method is +method+
266
+ def protocol?(method)
267
+ protocol == method.upcase
268
+ end
269
+
270
+ def Icalendar.decode_duration(str) #:nodoc:
271
+ unless match = %r{\s*#{Bnf::DURATION}\s*}.match(str)
272
+ raise InvalidEncodingError, "duration not valid (#{str})"
273
+ end
274
+ dur = 0
275
+
276
+ # Remember: match[0] is the whole match string, match[1] is $1, etc.
277
+
278
+ # Week
279
+ if match[2]
280
+ dur = match[2].to_i
281
+ end
282
+ # Days
283
+ dur *= 7
284
+ if match[3]
285
+ dur += match[3].to_i
286
+ end
287
+ # Hours
288
+ dur *= 24
289
+ if match[4]
290
+ dur += match[4].to_i
291
+ end
292
+ # Minutes
293
+ dur *= 60
294
+ if match[5]
295
+ dur += match[5].to_i
296
+ end
297
+ # Seconds
298
+ dur *= 60
299
+ if match[6]
300
+ dur += match[6].to_i
301
+ end
302
+
303
+ if match[1] && match[1] == '-'
304
+ dur = -dur
305
+ end
306
+
307
+ dur
308
+ end
309
+
310
+ # Decode iCalendar data into an array of Icalendar objects.
311
+ #
312
+ # Since iCalendars are self-delimited (by a BEGIN:VCALENDAR and an
313
+ # END:VCALENDAR), multiple iCalendars can be concatenated into a single
314
+ # file.
315
+ #
316
+ # cal must be either a string, or an IO object.
317
+ def Icalendar.decode(cal)
318
+ if cal.respond_to? :to_str
319
+ string = cal.to_str
320
+ elsif cal.kind_of? IO
321
+ string = cal.read(nil)
322
+ else
323
+ raise ArgumentError, "Icalendar.decode cannot be called with a #{cal.type}"
324
+ end
325
+
326
+ entities = Vpim.expand(Vpim.decode(string))
327
+
328
+ # Since all iCalendars must have a begin/end, the top-level should
329
+ # consist entirely of entities/arrays, even if its a single iCalendar.
330
+ if entities.detect { |e| ! e.kind_of? Array }
331
+ raise "Not a valid iCalendar"
332
+ end
333
+
334
+ calendars = []
335
+
336
+ entities.each do |e|
337
+ calendars << new(e)
338
+ end
339
+
340
+ calendars
341
+ end
342
+
343
+ # The iCalendar version multiplied by 10 as an Integer. If no VERSION field
344
+ # is present (which is non-conformant), nil is returned. iCalendar must
345
+ # have a version of 20, and vCalendar would have a version of 10.
346
+ def version
347
+ v = @properties['VERSION']
348
+
349
+ unless v
350
+ raise InvalidEncodingError, "Invalid calendar, no version field!"
351
+ end
352
+
353
+ v = v.to_f * 10
354
+ v = v.to_i
355
+ end
356
+
357
+ # The value of the PRODID field, an unstructured string meant to
358
+ # identify the software which encoded the Calendar data.
359
+ def producer
360
+ #f = @properties.field('PRODID')
361
+ #f && f.to_text
362
+ @properties.text('PRODID').first
363
+ end
364
+
365
+ # The value of the METHOD field. Protocol methods are used when iCalendars
366
+ # are exchanged in a calendar messaging system, such as iTIP or iMIP. When
367
+ # METHOD is not specified, the Calendar object is merely being used to
368
+ # transport a snapshot of some calendar information; without the intention
369
+ # of conveying a scheduling semantic.
370
+ #
371
+ # Note that this can't be called 'method', that name is reserved.
372
+ def protocol
373
+ m = @properties['METHOD']
374
+ m ? m.upcase : m
375
+ end
376
+
377
+ # The array of all calendar events (each is a Vevent).
378
+ #
379
+ # TODO - should this take an interval: t0,t1?
380
+ def events
381
+ @vevents
382
+ end
383
+
384
+ # The array of all calendar todos (each is a Vtodo).
385
+ def todos
386
+ @vtodos
387
+ end
388
+ end
389
+
390
+ end
391
+
392
+ =begin
393
+
394
+ Notes on a CAL-ADDRESS
395
+
396
+ When used with ATTENDEE, the parameters are:
397
+ CN
398
+ CUTYPE
399
+ DELEGATED-FROM
400
+ DELEGATED-TO
401
+ DIR
402
+ LANGUAGE
403
+ MEMBER
404
+ PARTSTAT
405
+ ROLE
406
+ RSVP
407
+ SENT-BY
408
+
409
+ When used with ORGANIZER, the parameters are:
410
+ CN
411
+ DIR
412
+ LANGUAGE
413
+ SENT-BY
414
+
415
+
416
+ What I've seen in Notes invitations, and iCal responses:
417
+ ROLE
418
+ PARTSTAT
419
+ RSVP
420
+ CN
421
+
422
+ Support these last 4, for now.
423
+
424
+ =end
425
+
426
+ module Vpim
427
+ class Icalendar
428
+ # Used to represent calendar fields containing CAL-ADDRESS values.
429
+ # The organizer or the attendees of a calendar event are examples of such
430
+ # a field.
431
+ #
432
+ # Example:
433
+ # ORGANIZER;CN="A. Person":mailto:a_person@example.com
434
+ # ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION
435
+ # ;CN="Sam Roberts";RSVP=TRUE:mailto:SRoberts@example.com
436
+ #
437
+ class Address
438
+
439
+ # Create an Address from a DirectoryInfo::Field, +field+.
440
+ #
441
+ # TODO - make private, and split into the encode/decode/create trinity.
442
+ def initialize(field)
443
+ unless field.value
444
+ raise ArgumentError
445
+ end
446
+
447
+ @field = field
448
+ end
449
+
450
+ # Return a representation of this Address as a DirectoryInfo::Field.
451
+ def field
452
+ @field.copy
453
+ end
454
+
455
+ # Create a copy of Address. If the original Address was frozen, this one
456
+ # won't be.
457
+ def copy
458
+ Marshal.load(Marshal.dump(self))
459
+ end
460
+
461
+ # Addresses in a CAL-ADDRESS are represented as a URI, usually a mailto URI.
462
+ def uri
463
+ @field.value
464
+ end
465
+
466
+ # Return true if the +uri+ is == to this address' URI. The comparison
467
+ # is case-insensitive.
468
+ def ==(uri)
469
+ self.uri.downcase == uri.downcase
470
+ end
471
+
472
+ # The common or displayable name associated with the calendar address,
473
+ # or nil if there is none.
474
+ def cn
475
+ return nil unless n = @field.param('CN')
476
+
477
+ # FIXME = the CN param may have no value, which is an error, but don't try
478
+ # to decode it, return either nil, or InvalidEncoding
479
+ Vpim.decode_text(n.first)
480
+ end
481
+
482
+ # A string representation of an address, using the common name, and the
483
+ # URI. The URI protocol is stripped if it's "mailto:".
484
+ #
485
+ # TODO - this needs to properly escape the cn string!
486
+ def to_s
487
+ u = uri
488
+ u.gsub!(/^mailto: */i, '')
489
+
490
+ if cn
491
+ "\"#{cn}\" <#{uri}>"
492
+ else
493
+ uri
494
+ end
495
+ end
496
+
497
+ # The participation role for the calendar user specified by the address.
498
+ #
499
+ # The standard roles are:
500
+ # - CHAIR Indicates chair of the calendar entity
501
+ # - REQ-PARTICIPANT Indicates a participant whose participation is required
502
+ # - OPT-PARTICIPANT Indicates a participant whose participation is optional
503
+ # - NON-PARTICIPANT Indicates a participant who is copied for information purposes only
504
+ #
505
+ # The default role is REQ-PARTICIPANT, returned if no ROLE parameter was
506
+ # specified.
507
+ def role
508
+ return 'REQ-PARTICIPANT' unless r = @field.param('ROLE')
509
+ r.first.upcase
510
+ end
511
+
512
+ # The participation status for the calendar user specified by the
513
+ # property PARTSTAT, a String.
514
+ #
515
+ # These are the participation statuses for an Event:
516
+ # - NEEDS-ACTION Event needs action
517
+ # - ACCEPTED Event accepted
518
+ # - DECLINED Event declined
519
+ # - TENTATIVE Event tentatively accepted
520
+ # - DELEGATED Event delegated
521
+ #
522
+ # Default is NEEDS-ACTION.
523
+ #
524
+ # TODO - make the default depend on the component type.
525
+ def partstat
526
+ return 'NEEDS-ACTION' unless r = @field.param('PARTSTAT')
527
+ r.first.upcase
528
+ end
529
+
530
+ # Set or change the participation status of the address, the PARTSTAT,
531
+ # to +status+.
532
+ #
533
+ # See #partstat.
534
+ def partstat=(status)
535
+ @field['partstat'] = status.to_str
536
+ status
537
+ end
538
+
539
+ # The value of the RSVP field, either +true+ or +false+. It is used to
540
+ # specify whether there is an expectation of a favor of a reply from the
541
+ # calendar user specified by the property value.
542
+ def rsvp
543
+ return false unless r = @field.param('RSVP')
544
+ r = r.first
545
+ return false unless r
546
+ case r
547
+ when /TRUE/i then true
548
+ when /FALSE/i then false
549
+ else raise InvalidEncodingError, "RSVP param value not TRUE/FALSE: #{r}"
550
+ end
551
+ end
552
+ end
553
+ end
554
+ end
555
+