fraser-vpim-rails 0.658

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.
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
+