vpim 0.16

Sign up to get free protection for your applications and to get access to all the features.
data/lib/vpim/time.rb ADDED
@@ -0,0 +1,42 @@
1
+ =begin
2
+ $Id: time.rb,v 1.5 2005/02/02 02:55:59 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 'date'
12
+
13
+ # Extensions to builtin Time allowing addition to Time by multiples of other
14
+ # intervals than a second.
15
+
16
+ class Time
17
+ # Returns a new Time, +years+ later than this time. Feb 29 of a
18
+ # leap year will be rounded up to Mar 1 if the target date is not a leap
19
+ # year.
20
+ def plus_year(years)
21
+ Time.local(year + years, month, day, hour, min, sec, usec)
22
+ end
23
+
24
+ # Returns a new Time, +months+ later than this time. The day will be
25
+ # rounded down if it is not valid for that month.
26
+ # 31 plus 1 month will be on Feb 28!
27
+ def plus_month(months)
28
+ d = Date.new(year, month, day)
29
+ d >>= months
30
+ Time.local(d.year, d.month, d.day, hour, min, sec, usec)
31
+ end
32
+
33
+ # Returns a new Time, +days+ later than this time.
34
+ # Does this do as I expect over DST? What if the hour doesn't exist
35
+ # in the next day, due to DST changes?
36
+ def plus_day(days)
37
+ d = Date.new(year, month, day)
38
+ d += days
39
+ Time.local(d.year, d.month, d.day, hour, min, sec, usec)
40
+ end
41
+ end
42
+
data/lib/vpim/vcard.rb ADDED
@@ -0,0 +1,210 @@
1
+ =begin
2
+ $Id: vcard.rb,v 1.13 2004/12/05 03:16:33 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/dirinfo'
12
+ require 'vpim/vpim'
13
+
14
+ module Vpim
15
+ # A vCard, a specialization of a directory info object.
16
+ #
17
+ # The vCard format is specified by:
18
+ # - RFC2426: vCard MIME Directory Profile (vCard 3.0)
19
+ # - RFC2425: A MIME Content-Type for Directory Information
20
+ #
21
+ # This implements vCard 3.0, but it is also capable of decoding vCard 2.1.
22
+ #
23
+ # For information about:
24
+ # - link:rfc2426.txt: vCard MIME Directory Profile (vCard 3.0)
25
+ # - link:rfc2425.txt: A MIME Content-Type for Directory Information
26
+ # - http://www.imc.org/pdi/pdiproddev.html: vCard 2.1 Specifications
27
+ #
28
+ # vCards are usually transmitted in files with <code>.vcf</code>
29
+ # extensions.
30
+ #
31
+ # TODO - an open question is what exactly "vcard 2.1" support means. While I
32
+ # decode vCard 2.1 correctly, I don't encode it. Should I implement a
33
+ # transcoder, to vCards can be decoded from either version, and then written
34
+ # to either version? Maybe an option to Field#encode()?
35
+ #
36
+ # TODO - there are very few methods that Vcard has that DirectoryInfo
37
+ # doesn't. I could probably just do away with it entirely, but I suspect
38
+ # that there are methods that could be usefully added to Vcard, perhaps to
39
+ # get the email addresses, or the name, or perhaps to set fields, like
40
+ # email=. What would be useful?
41
+ #
42
+ # = Example
43
+ #
44
+ # Here's an example of encoding a simple vCard using the low-level API:
45
+ #
46
+ # card = Vpim::Vcard.create
47
+ # card << Vpim::DirectoryInfo::Field.create('email', user.name@example.com, 'type' => "internet" )
48
+ # card << Vpim::DirectoryInfo::Field.create('url', "http://www.example.com/user" )
49
+ # card << Vpim::DirectoryInfo::Field.create('fn', "User Name" )
50
+ # puts card.to_s
51
+ #
52
+ # New! Use the Vpim::Maker::Vcard to make vCards!
53
+ class Vcard < DirectoryInfo
54
+
55
+ private_class_method :new
56
+
57
+ # Create a vCard 3.0 object with the minimum required fields, plus any
58
+ # +fields+ you want in the card (they can also be added later).
59
+ def Vcard.create(fields = [] )
60
+ fields.unshift Field.create('VERSION', "3.0")
61
+ super(fields, 'VCARD')
62
+ end
63
+
64
+ # Decode a collection of vCards into an array of Vcard objects.
65
+ #
66
+ # +card+ can be either a String or an IO object.
67
+ #
68
+ # Since vCards are self-delimited (by a BEGIN:vCard and an END:vCard),
69
+ # multiple vCards can be concatenated into a single directory info object.
70
+ # They may or may not be related. For example, AddressBook.app (the OS X
71
+ # contact manager) will export multiple selected cards in this format.
72
+ #
73
+ # Input data will be converted from unicode if it is detected. The heuristic
74
+ # is based on the first bytes in the string:
75
+ # - 0xEF 0xBB 0xBF: UTF-8 with a BOM, the BOM is stripped
76
+ # - 0xFE 0xFF: UTF-16 with a BOM (big-endian), the BOM is stripped and string
77
+ # is converted to UTF-8
78
+ # - 0xFF 0xFE: UTF-16 with a BOM (little-endian), the BOM is stripped and string
79
+ # is converted to UTF-8
80
+ # - 0x00 'B' or 0x00 'b': UTF-16 (big-endian), the string is converted to UTF-8
81
+ # - 'B' 0x00 or 'b' 0x00: UTF-16 (little-endian), the string is converted to UTF-8
82
+ #
83
+ # If you know that you have only one vCard, then you can decode that
84
+ # single vCard by doing something like:
85
+ #
86
+ # vcard = Vcard.decode(card_data).first
87
+ #
88
+ # Note: Should the import encoding be remembered, so that it can be reencoded in
89
+ # the same format?
90
+ def Vcard.decode(card)
91
+ if card.respond_to? :to_str
92
+ string = card.to_str
93
+ elsif card.kind_of? IO
94
+ string = card.read(nil)
95
+ else
96
+ raise ArgumentError, "Vcard.decode cannot be called with a #{card.type}"
97
+ end
98
+
99
+ case string
100
+ when /^\xEF\xBB\xBF/
101
+ string = string.sub("\xEF\xBB\xBF", '')
102
+ when /^\xFE\xFF/
103
+ arr = string.unpack('n*')
104
+ arr.shift
105
+ string = arr.pack('U*')
106
+ when /^\xFF\xFE/
107
+ arr = string.unpack('v*')
108
+ arr.shift
109
+ string = arr.pack('U*')
110
+ when /^\x00\x62/i
111
+ string = string.unpack('n*').pack('U*')
112
+ when /^\x62\x00/i
113
+ string = string.unpack('v*').pack('U*')
114
+ end
115
+
116
+ entities = Vpim.expand(Vpim.decode(string))
117
+
118
+ # Since all vCards must have a begin/end, the top-level should consist
119
+ # entirely of entities/arrays, even if its a single vCard.
120
+ if entities.detect { |e| ! e.kind_of? Array }
121
+ raise "Not a valid vCard"
122
+ end
123
+
124
+ vcards = []
125
+
126
+ for e in entities
127
+ vcards.push(new(e.flatten, 'VCARD'))
128
+ end
129
+
130
+ vcards
131
+ end
132
+
133
+ # The vCard version multiplied by 10 as an Integer. If no VERSION field
134
+ # is present (which is non-conformant), nil is returned. For example, a
135
+ # version 2.1 vCard would have a version of 21, and a version 3.0 vCard
136
+ # would have a version of 30.
137
+ def version
138
+ v = self["version"]
139
+ unless v
140
+ raise Vpim::InvalidEncodingError, 'Invalid vCard - it has no version field!'
141
+ end
142
+ v = v.to_f * 10
143
+ v = v.to_i
144
+ end
145
+
146
+ # The value of the field named +name+, optionally limited to fields of
147
+ # type +type+. If no match is found, nil is returned, if multiple matches
148
+ # are found, the first match to have one of its type values be 'pref'
149
+ # (preferred) is returned, otherwise the first match is returned.
150
+ def [](name, type=nil)
151
+ fields = enum_by_name(name).find_all { |f| type == nil || f.type?(type) }
152
+
153
+ # limit to preferred, if possible
154
+ pref = fields.find_all { |f| f.pref? }
155
+
156
+ if(pref.first)
157
+ fields = pref
158
+ end
159
+
160
+ fields.first ? fields.first.value : nil
161
+ end
162
+
163
+ # The nickname or nil if there is none.
164
+ def nickname
165
+ nn = self['nickname']
166
+ if nn
167
+ nn = nn.sub(/^\s+/, '')
168
+ nn = nn.sub(/\s+$/, '')
169
+ nn = nil if nn == ''
170
+ end
171
+ nn
172
+ end
173
+
174
+
175
+ # Returns the birthday as a Date on success, nil if there was no birthday
176
+ # field, and raises an error if the birthday field could not be expressed
177
+ # as a recurring event.
178
+ #
179
+ # Also, I have a lot of vCards with dates that look like:
180
+ # 678-09-23
181
+ # The 678 is garbage, but 09-23 is really the birthday. This substitutes the
182
+ # current year for the 3 digit year, and then converts to a Date.
183
+ def birthday
184
+ bday = self.field('BDAY')
185
+
186
+ return nil unless bday
187
+
188
+ begin
189
+ date = bday.to_date.first
190
+
191
+ rescue Vpim::InvalidEncodingError
192
+ if bday.value =~ /(\d+)-(\d+)-(\d+)/
193
+ y = $1.to_i
194
+ m = $2.to_i
195
+ d = $3.to_i
196
+ if(y < 1900)
197
+ y = Time.now.year
198
+ end
199
+ date = Date.new(y, m, d)
200
+ else
201
+ raise
202
+ end
203
+ end
204
+
205
+ date
206
+ end
207
+
208
+ end
209
+ end
210
+
@@ -0,0 +1,381 @@
1
+ =begin
2
+ $Id: vevent.rb,v 1.12 2005/01/21 04:09:55 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/dirinfo'
12
+ require 'vpim/field'
13
+ require 'vpim/rfc2425'
14
+ require 'vpim/vpim'
15
+
16
+ =begin
17
+ A vTodo that is done:
18
+
19
+ BEGIN:VTODO
20
+ COMPLETED:20040303T050000Z
21
+ DTSTAMP:20040304T011707Z
22
+ DTSTART;TZID=Canada/Eastern:20030524T115238
23
+ SEQUENCE:2
24
+ STATUS:COMPLETED
25
+ SUMMARY:Wash Car
26
+ UID:E7609713-8E13-11D7-8ACC-000393AD088C
27
+ END:VTODO
28
+
29
+ BEGIN:VTODO
30
+ DTSTAMP:20030909T015533Z
31
+ DTSTART;TZID=Canada/Eastern:20030808T000000
32
+ SEQUENCE:1
33
+ SUMMARY:Renew Passport
34
+ UID:EC76B256-BBE9-11D7-8401-000393AD088C
35
+ END:VTODO
36
+
37
+
38
+ =end
39
+
40
+ module Vpim
41
+ class Icalendar
42
+ class Vtodo
43
+ def initialize(fields) #:nodoc:
44
+ @fields = fields
45
+
46
+ outer, inner = Vpim.outer_inner(fields)
47
+
48
+ @properties = Vpim::DirectoryInfo.create(outer)
49
+
50
+ @elements = inner
51
+
52
+ # TODO - don't get properties here, put the accessor in a module,
53
+ # which can cache the results.
54
+
55
+ @summary = @properties.text('SUMMARY').first
56
+ @description = @properties.text('DESCRIPTION').first
57
+ @comment = @properties.text('COMMENT').first
58
+ @location = @properties.text('LOCATION').first
59
+ @status = @properties.text('STATUS').first
60
+ @uid = @properties.text('UID').first
61
+ @priority = @properties.text('PRIORITY').first
62
+
63
+ # See "TODO - fields" in dirinfo.rb
64
+ @dtstamp = @properties.field('dtstamp')
65
+ @dtstart = @properties.field('dtstart')
66
+ @dtend = @properties.field('dtend')
67
+ @duration = @properties.field('duration')
68
+ @due = @properties.field('due')
69
+ @rrule = @properties['rrule']
70
+
71
+ # Need to seperate status-handling out into a module...
72
+ @status_values = [ 'COMPLETED' ];
73
+
74
+ end
75
+
76
+ attr_reader :description, :summary, :comment, :location
77
+ attr_reader :properties, :fields # :nodoc:
78
+
79
+ # Create a new Vtodo object.
80
+ #
81
+ # If specified, +fields+ must be either an array of Field objects to
82
+ # add, or a Hash of String names to values that will be used to build
83
+ # Field objects. The latter is a convenient short-cut allowing the Field
84
+ # objects to be created for you when called like:
85
+ #
86
+ # Vtodo.create('SUMMARY' => "buy mangos")
87
+ #
88
+ # TODO - maybe todos are usually created in a particular way? I can
89
+ # make it easier. Ideally, I would like to make it hard to encode an invalid
90
+ # Event.
91
+ def Vtodo.create(fields=[])
92
+ di = DirectoryInfo.create([], 'VTODO')
93
+
94
+ Vpim::DirectoryInfo::Field.create_array(fields).each { |f| di.push_unique f }
95
+
96
+ new(di.to_a)
97
+ end
98
+
99
+ =begin
100
+ I think that the initialization shouldn't be done in the #initialize, so, for example,
101
+ @status = @properties.text('STATUS').first
102
+ should be in the method below.
103
+
104
+ That way, I can construct a Vtodo by just including a module for each field that is allowed
105
+ in a Vtodo, simply.
106
+ =end
107
+ def status
108
+ if(!@status); return nil; end
109
+
110
+ s = @status.upcase
111
+
112
+ unless @status_values.include?(s)
113
+ raise Vpim::InvalidEncodingError, "Invalid status '#{@status}'"
114
+ end
115
+
116
+ s
117
+ end
118
+
119
+ # +priority+ is a number from 1 to 9, with 1 being the highest and 0
120
+ # meaning "no priority", equivalent to not specifying the PRIORITY field.
121
+ # Other values are reserved by RFC2446.
122
+ def priority
123
+ p = @priority ? @priority.to_i : 0
124
+
125
+ if( p < 0 || p > 9 )
126
+ raise Vpim::InvalidEncodingError, 'Invalid priority #{@priority} - it must be 0-9!'
127
+ end
128
+ p
129
+ end
130
+ end
131
+ end
132
+ end
133
+
134
+ module Vpim
135
+ class Icalendar
136
+ class Vevent
137
+ def initialize(fields) #:nodoc:
138
+ @fields = fields
139
+
140
+ outer, inner = Vpim.outer_inner(fields)
141
+
142
+ @properties = Vpim::DirectoryInfo.create(outer)
143
+
144
+ @elements = inner
145
+
146
+ # TODO - don't get properties here, put the accessor in a module,
147
+ # which can cache the results.
148
+
149
+ @summary = @properties.text('SUMMARY').first
150
+ @description = @properties.text('DESCRIPTION').first
151
+ @comment = @properties.text('COMMENT').first
152
+ @location = @properties.text('LOCATION').first
153
+ @status = @properties.text('STATUS').first
154
+ @uid = @properties.text('UID').first
155
+
156
+ # See "TODO - fields" in dirinfo.rb
157
+ @dtstamp = @properties.field('dtstamp')
158
+ @dtstart = @properties.field('dtstart')
159
+ @dtend = @properties.field('dtend')
160
+ @duration = @properties.field('duration')
161
+ @rrule = @properties['rrule']
162
+
163
+ # Need to seperate status-handling out into a module...
164
+ @status_values = [ 'TENTATIVE', 'CONFIRMED', 'CANCELLED' ];
165
+
166
+ end
167
+
168
+ # Create a new Vevent object. All events must have a DTSTART field,
169
+ # specify it as either a Time or a Date in +start+, it defaults to "now"
170
+ # (is this useful?).
171
+ #
172
+ # If specified, +fields+ must be either an array of Field objects to
173
+ # add, or a Hash of String names to values that will be used to build
174
+ # Field objects. The latter is a convenient short-cut allowing the Field
175
+ # objects to be created for you when called like:
176
+ #
177
+ # Vevent.create(Date.today, 'SUMMARY' => "today's event")
178
+ #
179
+ # TODO - maybe events are usually created in a particular way? With a
180
+ # start/duration or a start/end? Maybe I can make it easier. Ideally, I
181
+ # would like to make it hard to encode an invalid Event.
182
+ def Vevent.create(start = Time.now, fields=[])
183
+ dtstart = DirectoryInfo::Field.create('DTSTART', start)
184
+ di = DirectoryInfo.create([ dtstart ], 'VEVENT')
185
+
186
+ Vpim::DirectoryInfo::Field.create_array(fields).each { |f| di.push_unique f }
187
+
188
+ new(di.to_a)
189
+ end
190
+
191
+ # Creates a yearly repeating event, such as for a birthday.
192
+ def Vevent.create_yearly(date, summary)
193
+ create(
194
+ date,
195
+ 'SUMMARY' => summary.to_str,
196
+ 'RRULE' => 'FREQ=YEARLY'
197
+ )
198
+ end
199
+
200
+ attr_reader :description, :summary, :comment, :location
201
+ attr_reader :properties, :fields # :nodoc:
202
+
203
+ #--
204
+ # The methods below should be shared, somehow, by all calendar components, not just Events.
205
+ #++
206
+
207
+ # Accept an event invitation. The +invitee+ is the Address that wishes
208
+ # to accept the event invitation as confirmed.
209
+ def accept(invitee)
210
+ # The event created is identical to this one, but
211
+ # - without the attendees
212
+ # - with the invitee added with a PARTSTAT of ACCEPTED
213
+ invitee = invitee.copy
214
+ invitee.partstat = 'ACCEPTED'
215
+
216
+ fields = []
217
+
218
+ @properties.each_with_index do
219
+ |f,i|
220
+
221
+ # put invitee in as field[1]
222
+ fields << invitee.field if i == 1
223
+
224
+ fields << f unless f.name? 'ATTENDEE'
225
+ end
226
+
227
+ Vevent.new(fields)
228
+ end
229
+
230
+ # Status values are not rejected during decoding. However, if the
231
+ # status is requested, and it's value is not one of the defined
232
+ # allowable values, an exception is raised.
233
+ def status
234
+ if(!@status); return nil; end
235
+
236
+ s = @status.upcase
237
+
238
+ unless @status_values.include?(s)
239
+ raise Vpim::InvalidEncodingError, "Invalid status '#{@status}'"
240
+ end
241
+
242
+ s
243
+ end
244
+
245
+ # TODO - def status? ...
246
+
247
+ # TODO - def status= ...
248
+
249
+ # The unique identifier of this calendar component, a string. It cannot be
250
+ # nil, if it is not found in the component, the calendar is malformed, and
251
+ # this method will raise an exception.
252
+ def uid
253
+ if(!@uid)
254
+ raise Vpim::InvalidEncodingError, 'Invalid component - no UID field was found!'
255
+ end
256
+
257
+ @uid
258
+ end
259
+
260
+ # The time stamp for this calendar component. Describe what this is....
261
+ # This field is required!
262
+ def dtstamp
263
+ if(!@dtstamp)
264
+ raise Vpim::InvalidEncodingError, 'Invalid component - no DTSTAMP field was found!'
265
+ end
266
+
267
+ @dtstamp.to_time.first
268
+ end
269
+
270
+ # The start time for this calendar component. Describe what this is....
271
+ # This field is required!
272
+ def dtstart
273
+ if(!@dtstart)
274
+ raise Vpim::InvalidEncodingError, 'Invalid component - no DTSTART field was found!'
275
+ end
276
+
277
+ @dtstart.to_time.first
278
+ end
279
+
280
+ =begin
281
+ # Set the start time for the event to +start+, a Time object.
282
+ # TODO - def dtstart=(start) ... start should be allowed to be Time/Date/DateTime
283
+ =end
284
+
285
+ # The duration in seconds of a Event, Todo, or Vfreebusy component, or
286
+ # for Alarms, the delay period prior to repeating the alarm. The
287
+ # duration is calculated from the DTEND and DTBEGIN fields if the
288
+ # DURATION field is not present. Durations of zero seconds are possible.
289
+ def duration
290
+ if(!@duration)
291
+ return nil unless @dtend
292
+
293
+ b = dtstart
294
+ e = dtend
295
+
296
+ return (e - b).to_i
297
+ end
298
+
299
+ Icalendar.decode_duration(@duration.value_raw)
300
+ end
301
+
302
+ # The end time for this calendar component. For an Event, if there is no
303
+ # end time, then nil is returned, and the event takes up no time.
304
+ # However, the end time will be calculated from the event duration, if
305
+ # present.
306
+ def dtend
307
+ if(@dtend)
308
+ @dtend.to_time.first
309
+ elsif duration
310
+ dtstart + duration
311
+ else
312
+ nil
313
+ end
314
+ end
315
+
316
+ # The recurrence rule, if any, for this event. Recurrence starts at the
317
+ # DTSTART time.
318
+ def rrule
319
+ @rrule
320
+ end
321
+
322
+ # The times this event occurs, as a Vpim::Rrule.
323
+ #
324
+ # Note: the event may occur only once.
325
+ #
326
+ # Note: occurences are currently calculated only from DTSTART and RRULE,
327
+ # no allowance for EXDATE or other fields is made.
328
+ def occurences
329
+ Vpim::Rrule.new(dtstart, @rrule)
330
+ end
331
+
332
+ # Check if this event overlaps with the time period later than or equal to +t0+, but
333
+ # earlier than +t1+.
334
+ def occurs_in?(t0, t1)
335
+ occurences.each_until(t1).detect { |t| tend = t + (duration || 0); tend > t0 }
336
+ end
337
+
338
+ # Return the event organizer, an object of Icalendar::Address (or nil if
339
+ # there is no ORGANIZER field).
340
+ #
341
+ # TODO - verify that it is illegal for there to be more than one
342
+ # ORGANIZER, if more than one is allowed, this needs to return an array.
343
+ def organizer
344
+ unless instance_variables.include? "@organizer"
345
+ @organizer = @properties.field('ORGANIZER')
346
+
347
+ if @organizer
348
+ @organizer = Icalendar::Address.new(@organizer)
349
+ end
350
+ end
351
+ @organizer.freeze
352
+ end
353
+
354
+ # Return an array of attendees, an empty array if there are none. The
355
+ # attendees are objects of Icalendar::Address. If +uri+ is specified
356
+ # only the return the attendees with this +uri+.
357
+ def attendees(uri = nil)
358
+ unless instance_variables.include? "@attendees"
359
+ @attendees = @properties.enum_by_name('ATTENDEE').map { |a| Icalendar::Address.new(a).freeze }
360
+ @attendees.freeze
361
+ end
362
+ if uri
363
+ @attendees.select { |a| a == uri } .freeze
364
+ else
365
+ @attendees
366
+ end
367
+ end
368
+
369
+ # Return true if the +uri+, usually a mailto: URI, is an attendee.
370
+ def attendee?(uri)
371
+ attendees.include? uri
372
+ end
373
+
374
+ # CONTACT - value is text, parameters are ALTREP and LANGUAGE.
375
+ #
376
+ # textual contact information, or an altrep referring to a URI pointing
377
+ # at a vCard or LDAP entry...
378
+ end
379
+ end
380
+ end
381
+