fraser-vpim-rails 0.658

Sign up to get free protection for your applications and to get access to all the features.
Files changed (61) hide show
  1. data/CHANGES +504 -0
  2. data/COPYING +58 -0
  3. data/README +182 -0
  4. data/lib/atom.rb +728 -0
  5. data/lib/plist.rb +22 -0
  6. data/lib/vpim/address.rb +219 -0
  7. data/lib/vpim/attachment.rb +102 -0
  8. data/lib/vpim/date.rb +222 -0
  9. data/lib/vpim/dirinfo.rb +277 -0
  10. data/lib/vpim/duration.rb +119 -0
  11. data/lib/vpim/enumerator.rb +32 -0
  12. data/lib/vpim/field.rb +614 -0
  13. data/lib/vpim/icalendar.rb +381 -0
  14. data/lib/vpim/maker/vcard.rb +16 -0
  15. data/lib/vpim/property/base.rb +193 -0
  16. data/lib/vpim/property/common.rb +315 -0
  17. data/lib/vpim/property/location.rb +38 -0
  18. data/lib/vpim/property/priority.rb +43 -0
  19. data/lib/vpim/property/recurrence.rb +69 -0
  20. data/lib/vpim/property/resources.rb +24 -0
  21. data/lib/vpim/repo.rb +181 -0
  22. data/lib/vpim/rfc2425.rb +367 -0
  23. data/lib/vpim/rrule.rb +589 -0
  24. data/lib/vpim/time.rb +40 -0
  25. data/lib/vpim/vcard.rb +1429 -0
  26. data/lib/vpim/version.rb +18 -0
  27. data/lib/vpim/vevent.rb +187 -0
  28. data/lib/vpim/view.rb +90 -0
  29. data/lib/vpim/vjournal.rb +58 -0
  30. data/lib/vpim/vpim.rb +65 -0
  31. data/lib/vpim/vtodo.rb +103 -0
  32. data/lib/vpim.rb +13 -0
  33. data/samples/README.mutt +93 -0
  34. data/samples/ab-query.rb +57 -0
  35. data/samples/cmd-itip.rb +156 -0
  36. data/samples/ex_cpvcard.rb +55 -0
  37. data/samples/ex_get_vcard_photo.rb +22 -0
  38. data/samples/ex_mkv21vcard.rb +34 -0
  39. data/samples/ex_mkvcard.rb +64 -0
  40. data/samples/ex_mkyourown.rb +29 -0
  41. data/samples/ics-dump.rb +210 -0
  42. data/samples/ics-to-rss.rb +84 -0
  43. data/samples/mutt-aliases-to-vcf.rb +45 -0
  44. data/samples/osx-wrappers.rb +86 -0
  45. data/samples/reminder.rb +203 -0
  46. data/samples/rrule.rb +71 -0
  47. data/samples/tabbed-file-to-vcf.rb +390 -0
  48. data/samples/vcf-dump.rb +86 -0
  49. data/samples/vcf-lines.rb +61 -0
  50. data/samples/vcf-to-ics.rb +22 -0
  51. data/samples/vcf-to-mutt.rb +121 -0
  52. data/test/test_all.rb +17 -0
  53. data/test/test_date.rb +120 -0
  54. data/test/test_dur.rb +41 -0
  55. data/test/test_field.rb +156 -0
  56. data/test/test_ical.rb +415 -0
  57. data/test/test_repo.rb +158 -0
  58. data/test/test_rrule.rb +1030 -0
  59. data/test/test_vcard.rb +973 -0
  60. data/test/test_view.rb +79 -0
  61. metadata +129 -0
data/lib/vpim/rrule.rb ADDED
@@ -0,0 +1,589 @@
1
+ =begin
2
+ Copyright (C) 2008 Sam Roberts
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
+ require 'vpim/rfc2425'
10
+ require 'vpim/date'
11
+ require 'vpim/time'
12
+ require 'vpim/vpim'
13
+
14
+ =begin
15
+ require 'pp'
16
+
17
+ $debug = ENV['DEBUG']
18
+
19
+ class Date
20
+ def inspect
21
+ self.to_s
22
+ end
23
+ end
24
+
25
+ def debug(*objs)
26
+ if $debug
27
+ pp(*objs)
28
+ print ' (', caller(1)[0], ')', "\n"
29
+ end
30
+ end
31
+ =end
32
+
33
+ module Vpim
34
+
35
+ # Implements the iCalendar recurrence rule syntax. See etc/rrule.txt for the
36
+ # syntax description and examples from RFC 2445. The description is pretty
37
+ # hard to understand, but the examples are more helpful.
38
+ #
39
+ # The implementation is reasonably complete, but still lacks support for:
40
+ #
41
+ # Recurrence by date (RDATE) and exclusions (EXDATE, EXRULE).
42
+ #
43
+ # TODO - BYWEEKNO: rules that are limited to particular weeks in a year.
44
+ #
45
+ # TODO - BYHOUR, BYMINUTE, BYSECOND: trivial to do, but I don't have an
46
+ # immediate need for them.
47
+ #
48
+ # TODO - new API? -> Rrule#infinite?
49
+ #
50
+ # == Examples
51
+ #
52
+ # - link:rrule.txt: utility for printing recurrence rules
53
+ class Rrule
54
+ include Enumerable
55
+
56
+ # The recurrence rule, +rrule+, specifies how to generate a set of times
57
+ # from a start time, +dtstart+ (which must the first of the set of
58
+ # recurring times). If +rrule+ is nil, the set contains only +dtstart+.
59
+ def initialize(dtstart, rrule = nil)
60
+ @dtstart = dtstart.in_time_zone
61
+
62
+ @rrule = rrule
63
+
64
+ # Freq is mandatory, but must occur only once.
65
+ @freq = nil
66
+
67
+ # Both Until and Count must not occur, neither is OK.
68
+ @until = nil
69
+ @count = nil
70
+
71
+ # Interval is optional, but defaults to 1.
72
+ @interval = 1
73
+
74
+ # WKST defines what day a week begins on, the default is monday.
75
+ @wkst = 'MO'
76
+
77
+ # Recurrence can modified by these.
78
+ @by = {}
79
+
80
+ if @rrule
81
+ @rrule.scan(/([^;=]+)=([^;=]+)/) do |key,value|
82
+ key.upcase!
83
+ value.upcase!
84
+
85
+ case key
86
+ when 'FREQ'
87
+ @freq = value
88
+
89
+ when 'UNTIL'
90
+ if @count
91
+ raise "found UNTIL, but COUNT already specified"
92
+ end
93
+ @until = Rrule.time_from_rfc2425(value)
94
+
95
+ when 'COUNT'
96
+ if @until
97
+ raise "found COUNT, but UNTIL already specified"
98
+ end
99
+ @count = value.to_i
100
+
101
+ when 'INTERVAL'
102
+ @interval = value.to_i
103
+ if @interval < 1
104
+ raise "interval must be a positive integer"
105
+ end
106
+
107
+ when 'WKST'
108
+ # TODO - check value is MO-SU
109
+ @wkst = value
110
+
111
+ else
112
+ @by[key] = value
113
+ end
114
+ end
115
+
116
+ if !@freq
117
+ # TODO - this shouldn't be an arg error, but a FormatError, its not the
118
+ # caller's fault!
119
+ raise ArgumentError, "recurrence rule lacks a frequency"
120
+ end
121
+ end
122
+ end
123
+
124
+ # Return an Enumerable, it's #each() will yield over all occurrences up to
125
+ # (and not including) time +dountil+.
126
+ def each_until(dountil)
127
+ Vpim::Enumerator.new(self, dountil)
128
+ end
129
+
130
+ # Yields for each +ytime+ in the recurring set of events.
131
+ #
132
+ # Warning: the set may be infinite! If you need an upper bound on the
133
+ # number of occurrences, you need to implement a count, or pass a time,
134
+ # +dountil+, which will not be iterated past (i.e. all times yielded will be
135
+ # less than +dountil+).
136
+ #
137
+ # Also, iteration will not currently continue past the limit of a Time
138
+ # object, which is some time in 2037 with the 32-bit time_t common on
139
+ # most systems.
140
+ def each(dountil = nil) #:yield: ytime
141
+ t = @dtstart.clone
142
+
143
+ # Time.to_a => [ sec, min, hour, day, month, year, wday, yday, isdst, zone ]
144
+
145
+ # Every event occurs at its start time, but only if the start time is
146
+ # earlier than DOUNTIL...
147
+ if !dountil || t < dountil
148
+ yield t
149
+ end
150
+ count = 1
151
+
152
+ # With no recurrence, DTSTART is the only occurrence.
153
+ if !@rrule
154
+ return self
155
+ end
156
+
157
+ loop do
158
+ # Build the set of times to yield within this interval (and after
159
+ # DTSTART)
160
+
161
+ days = DaySet.new(t)
162
+ hour = nil
163
+ min = nil
164
+ sec = nil
165
+
166
+ # Need to make a Dates class, and make month an instance of it, and add
167
+ # the "intersect" operator.
168
+
169
+ case @freq
170
+ #when 'YEARLY' then
171
+ # Don't need to keep track of year, all occurrences are within t's
172
+ # year.
173
+ when 'MONTHLY' then days.month = t.month
174
+ when 'WEEKLY' then #days.month = t.month
175
+ # TODO - WEEKLY
176
+ when 'DAILY' then days.mday = t.month, t.mday
177
+ when 'HOURLY' then hour = [t.hour]
178
+ when 'MINUTELY' then min = [t.min]
179
+ when 'SECONDLY' then sec = [t.sec]
180
+ end
181
+
182
+ # debug [t, days]
183
+ # Process the BY* modifiers in RFC defined order:
184
+ # BYMONTH,
185
+ # BYWEEKNO,
186
+ # BYYEARDAY,
187
+ # BYMONTHDAY,
188
+ # BYDAY,
189
+ # BYHOUR,
190
+ # BYMINUTE,
191
+ # BYSECOND,
192
+ # BYSETPOS
193
+
194
+ bymon = [nil]
195
+
196
+ if @by['BYMONTH']
197
+ bymon = @by['BYMONTH'].split(',')
198
+ bymon = bymon.map { |m| m.to_i }
199
+ # debug bymon
200
+
201
+ # In yearly, at this point, month will always be nil. At other
202
+ # frequencies, it will not.
203
+ days.intersect_bymon(bymon)
204
+
205
+ # debug days
206
+ end
207
+
208
+ # TODO - BYWEEKNO
209
+
210
+ if @by['BYYEARDAY']
211
+ byyday = @by['BYYEARDAY'].scan(/,?([+-]?[1-9]\d*)/)
212
+ # debug byyday
213
+ dates = byyearday(t.year, byyday)
214
+ days.intersect_dates(dates)
215
+ end
216
+
217
+ if @by['BYMONTHDAY']
218
+ bymday = @by['BYMONTHDAY'].scan(/,?([+-]?[1-9]\d*)/)
219
+ # debug bymday
220
+ # Generate all days matching this for all months. For yearly, this
221
+ # is what we want, for anything of monthly or higher frequency, it
222
+ # is too many days, but that's OK, since the month will already
223
+ # be specified and intersection will eliminate the out-of-range
224
+ # dates.
225
+ dates = bymonthday(t.year, bymday)
226
+ # debug dates
227
+ days.intersect_dates(dates)
228
+ # debug days
229
+ end
230
+
231
+ if @by['BYDAY']
232
+ byday = @by['BYDAY'].scan(/,?([+-]?[1-9]?\d*)?(SU|MO|TU|WE|TH|FR|SA)/i)
233
+
234
+ # BYDAY means different things in different frequencies. The +n+
235
+ # is only meaningful when freq is yearly or monthly.
236
+
237
+ case @freq
238
+ when 'YEARLY'
239
+ dates = bymon.map { |m| byday_in_monthly(t.year, m, byday) }.flatten
240
+ when 'MONTHLY'
241
+ dates = byday_in_monthly(t.year, t.month, byday)
242
+ when 'WEEKLY'
243
+ dates = byday_in_weekly(t.year, t.month, t.mday, @wkst, byday)
244
+ when 'DAILY', 'HOURLY', 'MINUTELY', 'SECONDLY'
245
+ # Reuse the byday_in_monthly. Current day is already specified,
246
+ # so this will just eliminate the current day if its not allowed
247
+ # in BYDAY.
248
+ dates = byday_in_monthly(t.year, t.month, byday)
249
+ end
250
+
251
+ # debug dates
252
+ days.intersect_dates(dates)
253
+ # debug days
254
+ end
255
+
256
+ # TODO - BYHOUR, BYMINUTE, BYSECOND
257
+
258
+ hour = [@dtstart.hour] if !hour
259
+ min = [@dtstart.min] if !min
260
+ sec = [@dtstart.sec] if !sec
261
+
262
+ # debug days
263
+
264
+ # Generate the yield set so BYSETPOS can be evaluated.
265
+ yset = []
266
+
267
+ days.each do |m,d|
268
+ hour.each do |h|
269
+ min.each do |n|
270
+ sec.each do |s|
271
+ y = Time.zone.local(t.year, m, d, h, n, s, 0)
272
+
273
+ next if y.hour != h
274
+
275
+ yset << y
276
+ end
277
+ end
278
+ end
279
+ end
280
+
281
+ if @by['BYSETPOS']
282
+ bysetpos = @by['BYSETPOS'].split(',')
283
+ yset = bysetpos.map do |i|
284
+ i = i.to_i
285
+ case
286
+ when i < 0
287
+ # yset[-1] is last
288
+ yset[i]
289
+ when i > 0
290
+ # yset[1] is first
291
+ yset[i-1]
292
+ else
293
+ # ignore invalid syntax
294
+ end
295
+ end.compact # set positions out of scope will be nil, RFC says ignore them
296
+ end
297
+
298
+ # Yield the occurrence, if we haven't gone over COUNT, or past UNTIL, or
299
+ # past the end of representable time.
300
+
301
+ yset.each do |y|
302
+ # The generated set can sometimes generate results earlier
303
+ # than the DTSTART, skip them. Also, we already yielded
304
+ # DTSTART, skip it.
305
+ next if y <= @dtstart
306
+
307
+ count += 1
308
+
309
+ # We are done if current count is past @count.
310
+ if(@count && (count > @count))
311
+ return self
312
+ end
313
+
314
+ # We are done if current time is past @until.
315
+ if @until && (y > @until)
316
+ return self
317
+ end
318
+ # We are also done if current time is past the
319
+ # caller-requested until.
320
+ if dountil && (y >= dountil)
321
+ return self
322
+ end
323
+ yield y
324
+ end
325
+
326
+ # Add @interval to @freq component
327
+
328
+ # Note - when we got past representable time, the error is:
329
+ # time out of range (ArgumentError)
330
+ # Finish when we see this.
331
+ begin
332
+ case @freq
333
+ when 'YEARLY' then
334
+ t = t.plus_year(@interval)
335
+
336
+ when 'MONTHLY' then
337
+ t = t.plus_month(@interval)
338
+
339
+ when 'WEEKLY' then
340
+ t = t.plus_day(@interval * 7)
341
+
342
+ when 'DAILY' then
343
+ t = t.plus_day(@interval)
344
+
345
+ when 'HOURLY' then
346
+ t += @interval * 60 * 60
347
+
348
+ when 'MINUTELY' then
349
+ t += @interval * 60
350
+
351
+ when 'SECONDLY' then
352
+ t += @interval
353
+
354
+ when nil
355
+ return self
356
+ end
357
+ rescue ArgumentError
358
+ return self if $!.message =~ /^time out of range$/
359
+
360
+ raise ArgumentError, "#{$!.message} while adding interval to #{t.inspect}"
361
+ end
362
+
363
+ return self if dountil && (t > dountil)
364
+ end
365
+ end
366
+
367
+
368
+
369
+ class DaySet #:nodoc:
370
+
371
+ def initialize(ref)
372
+ @ref = ref # Need to know because leap years have an extra day, and to get
373
+ # our defaults.
374
+ @month = nil
375
+ @week = nil
376
+ end
377
+
378
+ def month=(mon)
379
+ @month = { mon => nil }
380
+ end
381
+
382
+ def week=(week)
383
+ @week = week
384
+ end
385
+
386
+ def mday=(pair)
387
+ @month = { pair[0] => [ pair[1] ] }
388
+ end
389
+
390
+ def intersect_bymon(bymon) #:nodoc:
391
+ if !@month
392
+ @month = {}
393
+ bymon.each do |m|
394
+ @month[m] = nil
395
+ end
396
+ else
397
+ @month.delete_if { |m, days| ! bymon.include? m }
398
+ end
399
+ end
400
+
401
+ def intersect_dates(dates) #:nodoc:
402
+ return unless dates
403
+
404
+ # If no months are in the dayset, add all the ones in dates
405
+ if !@month
406
+ @month = {}
407
+
408
+ dates.each do |d|
409
+ @month[d.mon] = nil
410
+ end
411
+ end
412
+
413
+ # In each month,
414
+ # if there are days,
415
+ # eliminate those not in dates
416
+ # otherwise
417
+ # add all those in dates
418
+ @month.each do |mon, days|
419
+ days_in_mon = dates.find_all { |d| d.mon == mon }
420
+ days_in_mon = days_in_mon.map { |d| d.day }
421
+
422
+ if days
423
+ days_in_mon = days_in_mon & days
424
+ end
425
+ @month[mon] = days_in_mon
426
+ end
427
+ end
428
+
429
+ def each
430
+ @month = { @ref.month => [ @ref.mday ] } if !@month
431
+ @month.each_key do |m|
432
+ @month[m] = [@ref.day] if !@month[m]
433
+ # FIXME - if @ref.day is 31, and the month doesn't have 32 days, we'll
434
+ # generate invalid dates here, check for that, and eliminate them
435
+ end
436
+
437
+ @month.keys.sort.each do |m|
438
+ @month[m].sort.each do |d|
439
+ yield m, d
440
+ end
441
+ end
442
+ end
443
+ end
444
+
445
+ def self.time_from_rfc2425(str) #:nodoc:
446
+ # With ruby1.8 we can use DateTime to do this quick-n-easy:
447
+ # dt = DateTime.parse(str)
448
+ # Time.zone.local(dt.year, dt.month, dt.day, dt.hour, dt.min, dt.sec, 0)
449
+
450
+ # The time can be a DATE or a DATE-TIME, the latter always has a 'T' in it.
451
+
452
+ if str =~ /T/
453
+ d = Vpim.decode_date_time(str)
454
+ # We get [ year, month, day, hour, min, sec, usec, tz ]
455
+ if(d.pop == "Z")
456
+ t = Time.gm(*d)
457
+ else
458
+ t = Time.zone.local(*d)
459
+ end
460
+ else
461
+ d = Vpim.decode_date(str)
462
+ # We get [ year, month, day ]
463
+ # FIXME - I have to choose gm or local, though neither makes much
464
+ # sense. This is a bit of a hack - what we should really do is return
465
+ # an instance of Date, and Time should allow itself to be compared to
466
+ # Date... This hack will give odd results when comparing times, because
467
+ # it will create a Time on the right date but whos time is 00:00:00.
468
+ t = Time.zone.local(*d)
469
+ end
470
+ if t.month != d[1] || t.day != d[2] || (d[3] && t.hour != d[3])
471
+ raise Vpim::InvalidEncodingError, "Error - datetime does not exist"
472
+ end
473
+ t
474
+ end
475
+
476
+ def bymonthday(year, bymday) #:nodoc:
477
+ dates = []
478
+
479
+ bymday.each do |mday|
480
+ dates |= DateGen.bymonthday(year, nil, mday[0].to_i)
481
+ end
482
+ dates.sort!
483
+ dates
484
+ end
485
+
486
+ def byyearday(year, byyday) #:nodoc:
487
+ dates = []
488
+
489
+ byyday.each do |yday|
490
+ dates << Date.ordinal(year, yday[0].to_i)
491
+ end
492
+ dates.sort!
493
+ dates
494
+ end
495
+
496
+ def byday_in_monthly(year, mon, byday) #:nodoc:
497
+ dates = []
498
+
499
+ byday.each do |rule|
500
+ if rule[0].empty?
501
+ n = nil
502
+ else
503
+ n = rule[0].to_i
504
+ end
505
+ dates |= DateGen.bywday(year, mon, Date.str2wday(rule[1]), n)
506
+ end
507
+ dates.sort!
508
+ dates
509
+ end
510
+
511
+ def byday_in_weekly(year, mon, day, wkst, byday) #:nodoc:
512
+ # debug ["day", year,mon,day,wkst,byday]
513
+ days = byday.map{ |_, byday| Date.str2wday(byday) }
514
+ week = DateGen.weekofdate(year, mon, day, wkst)
515
+ # debug [ "week", dates ]
516
+ week.delete_if do |d|
517
+ !days.include?(d.wday)
518
+ end
519
+ week
520
+ end
521
+
522
+ # Help encode an RRULE value.
523
+ #
524
+ # TODO - the Maker is both incomplete, and its a bit cheesy, I'd like to do
525
+ # something that is a kind of programmatic version of the UI that iCal has.
526
+ class Maker
527
+ def initialize(&block) #:yield: self
528
+ @freq = nil
529
+ @until = nil
530
+ @count = nil
531
+ @interval = nil
532
+ @wkst = nil
533
+ @by = {}
534
+
535
+ if block
536
+ yield self
537
+ end
538
+ end
539
+
540
+ FREQ = %w{ YEARLY WEEKLY MONTHLY DAILY } #:nodoc: incomplete!
541
+
542
+ def frequency=(freq)
543
+ freq = freq.to_str.upcase
544
+ unless FREQ.include? freq
545
+ raise ArgumentError, "Frequency #{freq} is not valid"
546
+ end
547
+ @freq = freq
548
+ end
549
+
550
+ # +runtil+ is Time, Date, or DateTime
551
+ def until=(runtil)
552
+ if @count
553
+ raise ArgumentError, "Cannot specify UNTIL if COUNT was specified"
554
+ end
555
+ @until = runtil
556
+ end
557
+
558
+ # +count+ is integral
559
+ def count=(rcount)
560
+ if @until
561
+ raise ArgumentError, "Cannot specify COUNT if UNTIL was specified"
562
+ end
563
+ @count = rcount.to_int
564
+ end
565
+
566
+ # TODO - BY....
567
+
568
+ def encode
569
+ unless @freq
570
+ raise ArgumentError, "Must specify FREQUENCY"
571
+ end
572
+
573
+ rrule = "FREQ=#{@freq}"
574
+
575
+ [
576
+ ["COUNT", @count],
577
+ ["UNTIL", @until],
578
+ # TODO...
579
+ ].each do |k,v|
580
+ if v
581
+ rrule += ";#{k}=#{v}"
582
+ end
583
+ end
584
+ rrule
585
+ end
586
+ end
587
+ end
588
+ end
589
+
data/lib/vpim/time.rb ADDED
@@ -0,0 +1,40 @@
1
+ =begin
2
+ Copyright (C) 2008 Sam Roberts
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
+ require 'date'
10
+
11
+ # Extensions to builtin Time allowing addition to Time by multiples of other
12
+ # intervals than a second.
13
+
14
+ class Time
15
+ # Returns a new Time, +years+ later than this time. Feb 29 of a
16
+ # leap year will be rounded up to Mar 1 if the target date is not a leap
17
+ # year.
18
+ def plus_year(years)
19
+ Time.zone.local(year + years, month, day, hour, min, sec, usec)
20
+ end
21
+
22
+ # Returns a new Time, +months+ later than this time. The day will be
23
+ # rounded down if it is not valid for that month.
24
+ # Jan 31 plus 1 month will be on Feb 28!
25
+ def plus_month(months)
26
+ d = Date.new(year, month, day)
27
+ d >>= months
28
+ Time.zone.local(d.year, d.month, d.day, hour, min, sec, usec)
29
+ end
30
+
31
+ # Returns a new Time, +days+ later than this time.
32
+ # Does this do as I expect over DST? What if the hour doesn't exist
33
+ # in the next day, due to DST changes?
34
+ def plus_day(days)
35
+ d = Date.new(year, month, day)
36
+ d += days
37
+ Time.zone.local(d.year, d.month, d.day, hour, min, sec, usec)
38
+ end
39
+ end
40
+