vpim2 0.0.1

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