vpim-rails 0.661

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.rb +13 -0
  7. data/lib/vpim/address.rb +219 -0
  8. data/lib/vpim/attachment.rb +102 -0
  9. data/lib/vpim/date.rb +222 -0
  10. data/lib/vpim/dirinfo.rb +277 -0
  11. data/lib/vpim/duration.rb +119 -0
  12. data/lib/vpim/enumerator.rb +32 -0
  13. data/lib/vpim/field.rb +614 -0
  14. data/lib/vpim/icalendar.rb +382 -0
  15. data/lib/vpim/maker/vcard.rb +16 -0
  16. data/lib/vpim/property/base.rb +193 -0
  17. data/lib/vpim/property/common.rb +315 -0
  18. data/lib/vpim/property/location.rb +38 -0
  19. data/lib/vpim/property/priority.rb +43 -0
  20. data/lib/vpim/property/recurrence.rb +69 -0
  21. data/lib/vpim/property/resources.rb +24 -0
  22. data/lib/vpim/repo.rb +181 -0
  23. data/lib/vpim/rfc2425.rb +367 -0
  24. data/lib/vpim/rrule.rb +599 -0
  25. data/lib/vpim/time.rb +40 -0
  26. data/lib/vpim/vcard.rb +1429 -0
  27. data/lib/vpim/version.rb +18 -0
  28. data/lib/vpim/vevent.rb +187 -0
  29. data/lib/vpim/view.rb +90 -0
  30. data/lib/vpim/vjournal.rb +58 -0
  31. data/lib/vpim/vpim.rb +65 -0
  32. data/lib/vpim/vtodo.rb +103 -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 +126 -0
data/lib/vpim/rrule.rb ADDED
@@ -0,0 +1,599 @@
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
+ begin
506
+ dates |= DateGen.bywday(year, mon, Date.str2wday(rule[1]), n)
507
+ rescue ArgumentError => e
508
+ # Catch and ignore a very specific error type:
509
+ # Those caused by rules like FREQ=MONTHLY;BYDAY=5SA, which as far as
510
+ # I can tell are valid, but some months don't e.g., have a 5th Saturday
511
+ unless e.message == 'n is out of bounds of month' && n == 5
512
+ raise
513
+ end
514
+ end
515
+
516
+ end
517
+ dates.sort!
518
+ dates
519
+ end
520
+
521
+ def byday_in_weekly(year, mon, day, wkst, byday) #:nodoc:
522
+ # debug ["day", year,mon,day,wkst,byday]
523
+ days = byday.map{ |_, byday| Date.str2wday(byday) }
524
+ week = DateGen.weekofdate(year, mon, day, wkst)
525
+ # debug [ "week", dates ]
526
+ week.delete_if do |d|
527
+ !days.include?(d.wday)
528
+ end
529
+ week
530
+ end
531
+
532
+ # Help encode an RRULE value.
533
+ #
534
+ # TODO - the Maker is both incomplete, and its a bit cheesy, I'd like to do
535
+ # something that is a kind of programmatic version of the UI that iCal has.
536
+ class Maker
537
+ def initialize(&block) #:yield: self
538
+ @freq = nil
539
+ @until = nil
540
+ @count = nil
541
+ @interval = nil
542
+ @wkst = nil
543
+ @by = {}
544
+
545
+ if block
546
+ yield self
547
+ end
548
+ end
549
+
550
+ FREQ = %w{ YEARLY WEEKLY MONTHLY DAILY } #:nodoc: incomplete!
551
+
552
+ def frequency=(freq)
553
+ freq = freq.to_str.upcase
554
+ unless FREQ.include? freq
555
+ raise ArgumentError, "Frequency #{freq} is not valid"
556
+ end
557
+ @freq = freq
558
+ end
559
+
560
+ # +runtil+ is Time, Date, or DateTime
561
+ def until=(runtil)
562
+ if @count
563
+ raise ArgumentError, "Cannot specify UNTIL if COUNT was specified"
564
+ end
565
+ @until = runtil
566
+ end
567
+
568
+ # +count+ is integral
569
+ def count=(rcount)
570
+ if @until
571
+ raise ArgumentError, "Cannot specify COUNT if UNTIL was specified"
572
+ end
573
+ @count = rcount.to_int
574
+ end
575
+
576
+ # TODO - BY....
577
+
578
+ def encode
579
+ unless @freq
580
+ raise ArgumentError, "Must specify FREQUENCY"
581
+ end
582
+
583
+ rrule = "FREQ=#{@freq}"
584
+
585
+ [
586
+ ["COUNT", @count],
587
+ ["UNTIL", @until],
588
+ # TODO...
589
+ ].each do |k,v|
590
+ if v
591
+ rrule += ";#{k}=#{v}"
592
+ end
593
+ end
594
+ rrule
595
+ end
596
+ end
597
+ end
598
+ end
599
+