mhc 1.0.0

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 (127) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +27 -0
  3. data/.rspec +2 -0
  4. data/.travis.yml +3 -0
  5. data/COPYRIGHT +28 -0
  6. data/Gemfile +8 -0
  7. data/README.org +209 -0
  8. data/Rakefile +13 -0
  9. data/bin/mhc +312 -0
  10. data/emacs/Cask +25 -0
  11. data/emacs/Makefile +58 -0
  12. data/emacs/mhc-calendar.el +1723 -0
  13. data/emacs/mhc-calfw.el +135 -0
  14. data/emacs/mhc-compat.el +90 -0
  15. data/emacs/mhc-date.el +642 -0
  16. data/emacs/mhc-day.el +149 -0
  17. data/emacs/mhc-db.el +158 -0
  18. data/emacs/mhc-draft.el +211 -0
  19. data/emacs/mhc-e21.el +167 -0
  20. data/emacs/mhc-face.el +236 -0
  21. data/emacs/mhc-file.el +224 -0
  22. data/emacs/mhc-guess.el +648 -0
  23. data/emacs/mhc-header.el +176 -0
  24. data/emacs/mhc-logic.el +563 -0
  25. data/emacs/mhc-message.el +130 -0
  26. data/emacs/mhc-minibuf.el +466 -0
  27. data/emacs/mhc-misc.el +248 -0
  28. data/emacs/mhc-mua.el +260 -0
  29. data/emacs/mhc-parse.el +286 -0
  30. data/emacs/mhc-process.el +35 -0
  31. data/emacs/mhc-ps.el +1174 -0
  32. data/emacs/mhc-record.el +201 -0
  33. data/emacs/mhc-schedule.el +202 -0
  34. data/emacs/mhc-summary.el +763 -0
  35. data/emacs/mhc-sync.el +158 -0
  36. data/emacs/mhc-vars.el +149 -0
  37. data/emacs/mhc.el +1114 -0
  38. data/icons/Anniversary.xbm +6 -0
  39. data/icons/Anniversary.xpm +27 -0
  40. data/icons/Birthday.xbm +6 -0
  41. data/icons/Birthday.xpm +25 -0
  42. data/icons/Business.xbm +6 -0
  43. data/icons/Business.xpm +24 -0
  44. data/icons/CheckBox.xbm +6 -0
  45. data/icons/CheckBox.xpm +24 -0
  46. data/icons/CheckedBox.xbm +6 -0
  47. data/icons/CheckedBox.xpm +25 -0
  48. data/icons/Conflict.xbm +6 -0
  49. data/icons/Conflict.xpm +22 -0
  50. data/icons/Date.xbm +6 -0
  51. data/icons/Date.xpm +29 -0
  52. data/icons/Holiday.xbm +6 -0
  53. data/icons/Holiday.xpm +25 -0
  54. data/icons/Link.xbm +6 -0
  55. data/icons/Link.xpm +25 -0
  56. data/icons/Other.xbm +6 -0
  57. data/icons/Other.xpm +28 -0
  58. data/icons/Party.xbm +6 -0
  59. data/icons/Party.xpm +23 -0
  60. data/icons/Private.xbm +6 -0
  61. data/icons/Private.xpm +26 -0
  62. data/icons/Recurrence.xbm +6 -0
  63. data/icons/Recurrence.xpm +98 -0
  64. data/icons/Vacation.xbm +6 -0
  65. data/icons/Vacation.xpm +26 -0
  66. data/lib/mhc.rb +45 -0
  67. data/lib/mhc/builder.rb +64 -0
  68. data/lib/mhc/caldav.rb +304 -0
  69. data/lib/mhc/calendar.rb +106 -0
  70. data/lib/mhc/command.rb +13 -0
  71. data/lib/mhc/command/cache.rb +14 -0
  72. data/lib/mhc/command/completions.rb +108 -0
  73. data/lib/mhc/command/init.rb +133 -0
  74. data/lib/mhc/command/scan.rb +33 -0
  75. data/lib/mhc/command/sync.rb +22 -0
  76. data/lib/mhc/config.rb +229 -0
  77. data/lib/mhc/converter.rb +330 -0
  78. data/lib/mhc/datastore.rb +164 -0
  79. data/lib/mhc/date_enumerator.rb +274 -0
  80. data/lib/mhc/date_frame.rb +124 -0
  81. data/lib/mhc/date_helper.rb +49 -0
  82. data/lib/mhc/etag.rb +68 -0
  83. data/lib/mhc/event.rb +396 -0
  84. data/lib/mhc/formatter.rb +312 -0
  85. data/lib/mhc/logger.rb +94 -0
  86. data/lib/mhc/modifier.rb +149 -0
  87. data/lib/mhc/occurrence.rb +94 -0
  88. data/lib/mhc/occurrence_enumerator.rb +113 -0
  89. data/lib/mhc/property_value.rb +33 -0
  90. data/lib/mhc/property_value/date.rb +190 -0
  91. data/lib/mhc/property_value/integer.rb +15 -0
  92. data/lib/mhc/property_value/list.rb +41 -0
  93. data/lib/mhc/property_value/period.rb +49 -0
  94. data/lib/mhc/property_value/range.rb +100 -0
  95. data/lib/mhc/property_value/recurrence_condition.rb +272 -0
  96. data/lib/mhc/property_value/text.rb +11 -0
  97. data/lib/mhc/property_value/time.rb +45 -0
  98. data/lib/mhc/query.rb +210 -0
  99. data/lib/mhc/sync.rb +46 -0
  100. data/lib/mhc/sync/driver.rb +108 -0
  101. data/lib/mhc/sync/status.rb +70 -0
  102. data/lib/mhc/sync/status_manager.rb +142 -0
  103. data/lib/mhc/sync/strategy.rb +233 -0
  104. data/lib/mhc/sync/syncinfo.rb +98 -0
  105. data/lib/mhc/templates/config.yml.erb +142 -0
  106. data/lib/mhc/version.rb +4 -0
  107. data/lib/mhc/webdav.rb +319 -0
  108. data/mhc.gemspec +24 -0
  109. data/samples/DOT.mhc-config.yml +116 -0
  110. data/samples/japanese-holidays.mhcc +153 -0
  111. data/samples/mhc-completions.zsh +11 -0
  112. data/spec/mhc_spec.rb +682 -0
  113. data/spec/spec_helper.rb +9 -0
  114. data/xpm/close.xpm +18 -0
  115. data/xpm/delete.xpm +19 -0
  116. data/xpm/exit.xpm +18 -0
  117. data/xpm/month.xpm +18 -0
  118. data/xpm/next.xpm +18 -0
  119. data/xpm/next2.xpm +18 -0
  120. data/xpm/next_year.xpm +18 -0
  121. data/xpm/open.xpm +19 -0
  122. data/xpm/prev.xpm +18 -0
  123. data/xpm/prev2.xpm +18 -0
  124. data/xpm/prev_year.xpm +18 -0
  125. data/xpm/save.xpm +19 -0
  126. data/xpm/today.xpm +18 -0
  127. metadata +214 -0
@@ -0,0 +1,33 @@
1
+ module Mhc
2
+ module PropertyValue
3
+ class ParseError < StandardError; end
4
+ class Base
5
+ def self.parse(string)
6
+ return self.new.parse(string)
7
+ end
8
+
9
+ def parse(string)
10
+ @value = string
11
+ return self
12
+ end
13
+
14
+ def to_mhc_string
15
+ return @value.to_s
16
+ end
17
+
18
+ alias_method :to_s, :to_mhc_string
19
+ end
20
+
21
+ dir = File.dirname(__FILE__) + "/property_value"
22
+
23
+ autoload :Date, "#{dir}/date.rb"
24
+ autoload :Integer, "#{dir}/integer.rb"
25
+ autoload :List, "#{dir}/list.rb"
26
+ autoload :Period, "#{dir}/period.rb"
27
+ autoload :Range, "#{dir}/range.rb"
28
+ autoload :RecurrenceCondition, "#{dir}/recurrence_condition.rb"
29
+ autoload :Text, "#{dir}/text.rb"
30
+ autoload :Time, "#{dir}/time.rb"
31
+
32
+ end # modlue PropertyValue
33
+ end # module Mhc
@@ -0,0 +1,190 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require "date"
4
+
5
+ module Mhc
6
+ module PropertyValue
7
+ class Date < ::Date
8
+
9
+ DAYS_OF_MONTH = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
10
+
11
+ def self.parse(string)
12
+ if /^(\d{4})(\d{2})(\d{2})$/ =~ string
13
+ # don't use super(string) because it's slow.
14
+ new($1.to_i, $2.to_i, $3.to_i)
15
+ else
16
+ return nil # raise ParseError
17
+ end
18
+ end
19
+
20
+ def self.parse_relative(date_string)
21
+ case (date_string.downcase)
22
+ when 'today'
23
+ return self.today
24
+
25
+ when 'tomorrow'
26
+ return self.today.succ
27
+
28
+ when /^\d{8}$/
29
+ return self.parse(date_string)
30
+
31
+ when /^\d{6}$/
32
+ return self.parse(date_string + '01')
33
+
34
+ when /^thismonth$/
35
+ return self.today.first_day_of_month
36
+
37
+ when /^nextmonth$/
38
+ return self.today.first_day_of_month.next_month
39
+
40
+ else
41
+ raise ParseError, "invalid date string '#{date_string}'"
42
+ end
43
+ end
44
+
45
+ def self.parse_range(range_string)
46
+ case range_string
47
+ # yyyymmdd-yyyymmdd
48
+ when /^([^+-]+)-([^+-]+)$/
49
+ return parse_relative($1)..parse_relative($2)
50
+
51
+ # yyyymmdd+2w
52
+ when /^([^+-]+)\+(\d+)([dwm])$/
53
+ date = parse_relative($1)
54
+ return date..date.succ_by($3, $2.to_i).prev_day
55
+
56
+ when /^(thismonth|nextmonth|\d{6})$/
57
+ date = parse_relative($1)
58
+ return date..date.last_day_of_month
59
+
60
+ when /^([^+-]+)$/
61
+ date = parse_relative($1)
62
+ return date..date
63
+ else
64
+ raise ParseError, "invalid date range string '#{range_string}'"
65
+ end
66
+ end
67
+
68
+ def succ_by(unit = :d, number = 1)
69
+ case unit.to_sym
70
+ when :d
71
+ return self + number.to_i
72
+ when :w
73
+ return self + (number.to_i * 7)
74
+ when :m
75
+ return self >> number.to_i
76
+ end
77
+ end
78
+
79
+ def parse(string)
80
+ if /^\d{8}$/ =~ string
81
+ self.class.parse(string)
82
+ else
83
+ return nil # raise ParseError
84
+ end
85
+ end
86
+
87
+ def add_time(time = nil)
88
+ if time
89
+ return ::Time.local(year, month, mday, time.hour, time.minute)
90
+ else
91
+ return ::Time.local(year, month, mday, 0, 0)
92
+ end
93
+ end
94
+
95
+ def to_mhc_string
96
+ return strftime("%Y%m%d")
97
+ end
98
+ alias_method :to_s, :to_mhc_string
99
+
100
+ def last_week_of_month?
101
+ return mday > days_of_month - 7
102
+ end
103
+
104
+ def week_number_of_month
105
+ return (mday - 1) / 7 + 1
106
+ end
107
+
108
+ def days_of_month
109
+ return DAYS_OF_MONTH[month] + (month == 2 && leap? ? 1 : 0)
110
+ end
111
+
112
+ def first_day_of_month
113
+ return self.class.new(year, month, 1)
114
+ end
115
+
116
+ def last_day_of_month
117
+ return self.class.new(year, month, -1)
118
+ end
119
+
120
+ def each_day_in_month
121
+ for d in (1 .. days_of_month)
122
+ yield self.class.new(year, month, d)
123
+ end
124
+ end
125
+
126
+ def today?
127
+ return self.class.today == self
128
+ end
129
+
130
+ def absolute_from_epoch
131
+ return (self - Date.new(1970, 1, 1)).to_i
132
+ end
133
+
134
+ #
135
+ # Make a date by DAY like ``1st Wed of Nov, 1999''.
136
+ # caller must make sure:
137
+ # YEAR and MONTH must be valid.
138
+ # NTH must be <0 or >0.
139
+ # WDAY must be 0..6.
140
+ #
141
+ # returns nil if no date was match (for example,
142
+ # no 5th Saturday exists on April 2010).
143
+ #
144
+ def self.new_by_day(year, month, nth, wday)
145
+ return nil if nth < -5 or nth > 5 or nth == 0
146
+ direction = nth > 0 ? 1 : -1
147
+
148
+ edge = Date.new(year, month, direction)
149
+ y_offset = nth - direction
150
+ x_offset = wday_difference(edge.wday, wday, direction)
151
+ mday = edge.mday + y_offset * 7 + x_offset
152
+
153
+ return new(year, month, mday) # May raise ArgumentError
154
+ end
155
+
156
+ def next_monthday(month, mday)
157
+ year = self.year + (month < self.month ? 1 : 0)
158
+ return self.class.new(year, month, mday)
159
+ end
160
+
161
+ def next_day(month, nth, wday)
162
+ year = self.year + (month < self.month ? 1 : 0)
163
+ year += 1 while !(date = self.class.new_by_day(year, month, nth, wday))
164
+ return date
165
+ end
166
+
167
+ def to_ics
168
+ return strftime("%Y%m%d")
169
+ end
170
+
171
+ private
172
+ #
173
+ # Returns diff of days between 2 wdays: FROM and TO.
174
+ # Each FROM and TO is one of 0(=Sun) ... 6(Sat).
175
+ #
176
+ # DIRECTION must be -1 or 1, which represents search direction.
177
+ #
178
+ # Sun Mon Tue Wed Thu Fri Sat Sun Mon Tue ...
179
+ # 0 1 2 3 4 5 6 0 1 2 ...
180
+ #
181
+ # returns 3 if FROM, TO, DIRECTION = 4, 0, 1
182
+ # returns -4 if FROM, TO, DIRECTION = 4, 0, -1
183
+ #
184
+ def wday_difference(from, to, direction)
185
+ return direction * ((direction * (to - from)) % 7)
186
+ end
187
+
188
+ end # class Date
189
+ end # module PropertyValue
190
+ end # module Mhc
@@ -0,0 +1,15 @@
1
+ module Mhc
2
+ module PropertyValue
3
+ class Integer < Base
4
+ def parse(string)
5
+ @value = string.to_i if /^\d+$/ =~ string
6
+ return self
7
+ end
8
+
9
+ def to_i
10
+ @value.to_i
11
+ end
12
+
13
+ end # class Integer
14
+ end # module PropertyValue
15
+ end # module Mhc
@@ -0,0 +1,41 @@
1
+ module Mhc
2
+ module PropertyValue
3
+ class List < Base
4
+ include Enumerable
5
+
6
+ ITEM_SEPARATOR = " "
7
+
8
+ def initialize(item_class)
9
+ @list = []
10
+ @item_class = item_class
11
+ end
12
+
13
+ def each
14
+ @list.each do |value|
15
+ yield value
16
+ end
17
+ end
18
+
19
+ def include?(o)
20
+ @list.include?(o)
21
+ end
22
+
23
+ def empty?
24
+ @list.empty?
25
+ end
26
+
27
+ def parse(string)
28
+ string.strip.split(ITEM_SEPARATOR).each do |str|
29
+ item = @item_class.parse(str)
30
+ @list << item if item
31
+ end
32
+ return self
33
+ end
34
+
35
+ def to_mhc_string
36
+ @list.map{|item| item.to_mhc_string}.join(ITEM_SEPARATOR)
37
+ end
38
+
39
+ end # class List
40
+ end # module PropertyValue
41
+ end # module Mhc
@@ -0,0 +1,49 @@
1
+ module Mhc
2
+ module PropertyValue
3
+ class Period < Base
4
+
5
+ UNIT2MIN = {'minute' => 1, 'hour' => 60, 'day' => 60*24}
6
+ UNITS = UNIT2MIN.keys
7
+ REGEXP = /(\d+)\s*(#{UNITS.join("|")})s?/
8
+
9
+ def self.parse(string)
10
+ return new.parse(string)
11
+ end
12
+
13
+ def parse(string)
14
+ if REGEXP =~ string
15
+ @minutes = (UNIT2MIN[$2] * $1.to_i)
16
+ end
17
+ return self
18
+ end
19
+
20
+ def to_mhc_string
21
+ return "" unless @minutes
22
+
23
+ value, unit_size, unit_name = @minutes, 1, "minute"
24
+
25
+ UNIT2MIN.each do |unit,minutes|
26
+ if @minutes % minutes == 0
27
+ value = @minutes / minutes
28
+ unit_size = minutes
29
+ unit_name = unit
30
+ end
31
+ end
32
+ return "#{value} #{unit_name}" + (value > 1 ? "s" : "")
33
+ end
34
+
35
+ def alarm_trigger
36
+ duration = nil
37
+ if @alarm
38
+ seconds = @alarm
39
+ duration = "-P"
40
+ duration += "#{seconds /= 86400}D" if seconds >= 86400
41
+ duration += "T#{seconds /= 3600}H" if seconds >= 3600
42
+ duration += "T#{seconds /= 60}M" if seconds >= 60
43
+ end
44
+ return duration
45
+ end
46
+
47
+ end # class Period
48
+ end # module PropertyValue
49
+ end # module Mhc
@@ -0,0 +1,100 @@
1
+ module Mhc
2
+ module PropertyValue
3
+ class Range < Base
4
+ include Comparable
5
+ ITEM_SEPARATOR = "-"
6
+
7
+ attr_reader :first, :last
8
+
9
+ def initialize(item_class, prefix = nil, first = nil, last = nil)
10
+ @item_class, @prefix = item_class, prefix
11
+ @first, @last = first, last
12
+ end
13
+
14
+ # our Range acceps these 3 forms:
15
+ # (1) A-B : first, last = A, B
16
+ # (2) A : first, last = A, A
17
+ # (3) A- : first, last = A, nil
18
+ # (4) -B : first, last = nil, B
19
+ #
20
+ # nil means range is open (infinite).
21
+ #
22
+ def parse(string)
23
+ @first, @last = nil, nil
24
+ first, last = string.split(ITEM_SEPARATOR, 2)
25
+ last = first if last.nil? # single "A" means "A-A"
26
+
27
+ @first = @item_class.parse(first) unless first.to_s == ""
28
+ @last = @item_class.parse(last) unless last.to_s == ""
29
+ return self.class.new(@item_class, @prefix, @first, @last)
30
+ end
31
+
32
+ def to_a
33
+ array = []
34
+ i = first
35
+ while i <= last
36
+ array << i
37
+ i = i.succ
38
+ end
39
+ return array
40
+ end
41
+
42
+ def each
43
+ i = first
44
+ while i <= last
45
+ yield(i)
46
+ i = i.succ
47
+ end
48
+ end
49
+
50
+ def narrow(from, to)
51
+ from = @first if from.nil? or (@first and from < @first)
52
+ to = @last if to.nil? or (@last and to > @last)
53
+ self.class.new(@item_class, @prefix, from, to)
54
+ end
55
+
56
+ def <=>(o)
57
+ o = o.first if o.respond_to?(:first)
58
+ safe_comp(self.first, o)
59
+ end
60
+
61
+ def infinit?
62
+ return @first.nil? || @last.nil?
63
+ end
64
+
65
+ def blank?
66
+ @first.nil? && @last.nil?
67
+ end
68
+
69
+ def to_mhc_string
70
+ first = @first.nil? ? "" : @first.to_mhc_string
71
+ last = @last.nil? ? "" : @last.to_mhc_string
72
+
73
+ if first == last
74
+ return @prefix.to_s + first
75
+ else
76
+ return @prefix.to_s + [first, last].join(ITEM_SEPARATOR)
77
+ end
78
+ end
79
+
80
+ alias_method :to_s, :to_mhc_string
81
+
82
+ private
83
+
84
+ def cover?(item)
85
+ return false if @first && item < @first
86
+ return false if @last && item > @last
87
+ return true
88
+ end
89
+
90
+ def safe_comp(a, o)
91
+ # nil is minimum
92
+ return (a <=> o) if a and o
93
+ return -1 if !a and o
94
+ return 1 if a and !o
95
+ return 0 if !a and !o
96
+ end
97
+
98
+ end # class Range
99
+ end # module PropertyValue
100
+ end # module Mhc
@@ -0,0 +1,272 @@
1
+ module Mhc
2
+ module PropertyValue
3
+ class RecurrenceCondition < Base
4
+
5
+ # :stopdoc:
6
+ MON_LABEL = %w(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec)
7
+ MON_VALUE = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
8
+ MON_L2V = Hash[*MON_LABEL.zip(MON_VALUE).flatten]
9
+ MON_V2L = MON_L2V.invert
10
+
11
+ ORD_LABEL = %w(1st 2nd 3rd 4th 5th Last)
12
+ ORD_VALUE = [1, 2, 3, 4, 5, -1]
13
+ ORD_L2V = Hash[*ORD_LABEL.zip(ORD_VALUE).flatten]
14
+ ORD_V2L = ORD_L2V.invert
15
+
16
+ WEK_LABEL = %w(Sun Mon Tue Wed Thu Fri Sat)
17
+ WEK_VALUE = [0, 1, 2, 3, 4, 5, 6]
18
+ WEK_L2V = Hash[*WEK_LABEL.zip(WEK_VALUE).flatten]
19
+ WEK_V2L = WEK_L2V.invert
20
+ WEK_V2I = Hash[*WEK_VALUE.zip(%w(SU MO TU WE TH FR SA)).flatten]
21
+
22
+ MON_REGEXP = /^#{MON_LABEL.join('|')}$/oi
23
+ ORD_REGEXP = /^#{ORD_LABEL.join('|')}$/oi
24
+ WEK_REGEXP = /^#{WEK_LABEL.join('|')}$/oi
25
+ NUM_REGEXP = /^\d+$/oi
26
+ # :startdoc:
27
+
28
+ def cond_mon; return @cond_mon; end
29
+ def cond_ord; return @cond_ord; end
30
+ def cond_wek; return @cond_wek; end
31
+ def cond_num; return @cond_num; end
32
+
33
+ def initialize
34
+ @cond_mon, @cond_ord, @cond_wek, @cond_num = [], [], [], []
35
+ end
36
+
37
+ def parse(string)
38
+ o = self
39
+ string.split.grep(MON_REGEXP) {|mon| o.cond_mon << MON_L2V[mon.capitalize]}
40
+ string.split.grep(ORD_REGEXP) {|ord| o.cond_ord << ORD_L2V[ord.capitalize]}
41
+ string.split.grep(WEK_REGEXP) {|wek| o.cond_wek << WEK_L2V[wek.capitalize]}
42
+ string.split.grep(NUM_REGEXP) {|num| o.cond_num << num.to_i}
43
+ return o
44
+ end
45
+
46
+ #--
47
+ # MON NUM ORD WEK RFC2445-TYPE (!: invalid)
48
+ # ------------------------------------------------------------------
49
+ # - - - - ! EMPTY
50
+ # - - - Y WEEKLY BYDAY=wek
51
+ # - - Y - ! MONTHLY BYDAY=ord*ALL
52
+ # - - Y Y MONTHLY BYDAY=ord*wek
53
+ # - Y - - MONTHLY BYMONTHDAY=num
54
+ # - Y - Y MONTHLY BYMONTHDAY=num,BYDAY=wek
55
+ # - Y Y - ! MONTHLY BYMONTHDAY=num,BYDAY=ord*ALL
56
+ # - Y Y Y MONTHLY BYMONTHDAY=num,BYDAY=ord*wek
57
+ # Y - - - ! YEARLY BYMONTH=mon,BYDAY=ALL
58
+ # Y - - Y YEARLY BYMONTH=mon,BYDAY=wek
59
+ # Y - Y - ! YEARLY BYMONTH=mon,BYDAY=ord*ALL
60
+ # Y - Y Y YEARLY BYMONTH=mon,BYDAY=ord*wek
61
+ # Y Y - - YEARLY BYMONTH=mon,BYMONTHDAY=num
62
+ # Y Y - Y YEARLY BYMONTH=mon,BYMONTHDAY=num,BYDAY=wek
63
+ # Y Y Y - ! YEARLY BYMONTH=mon,BYMONTHDAY=num,BYDAY=ord*ALL
64
+ # Y Y Y Y YEARLY BYMONTH=mon,BYMONTHDAY=num,BYDAY=ord*wek
65
+ #++
66
+ def frequency
67
+ return :none if empty?
68
+ return :daily if daily?
69
+ return :weekly if weekly?
70
+ return :monthly if monthly?
71
+ return :yearly if yearly?
72
+ end
73
+
74
+ def daily?
75
+ false
76
+ end
77
+
78
+ def weekly?
79
+ !yearly? && !monthly? && !cond_wek.empty?
80
+ end
81
+
82
+ def monthly?
83
+ !yearly? && (!cond_num.empty? || !cond_ord.empty?)
84
+ end
85
+
86
+ def yearly?
87
+ !cond_mon.empty?
88
+ end
89
+
90
+ def valid?
91
+ frequency != :none
92
+ end
93
+
94
+ def empty?
95
+ [@cond_mon, @cond_ord, @cond_wek, @cond_num].all?{|cond| cond.empty?}
96
+ end
97
+
98
+ # convert RRULE to X-SC-Cond:
99
+ #
100
+ # Due to the over-killing complexity of iCalendar (RFC5545)
101
+ # format, converting RRULE to X-SC-* format has some restrictions:
102
+ #
103
+ # * Not allowed elements:
104
+ # * BYSECOND
105
+ # * BYMINUTE
106
+ # * BYHOUR
107
+ # * COUNT
108
+ # * BYYEARDAY (-366 to 366)
109
+ # * BYWEEKNO (-53 to 53)
110
+ # * BYSETPOS (-366 to 366)
111
+ # * Recurrence-ID (not part of RRULE)
112
+ #
113
+ # * Restricted elements:
114
+ #
115
+ # * INTERVAL:
116
+ # * it should be 1
117
+ #
118
+ # * BYMONTHDAY:
119
+ # * it should be (1..31)
120
+ #
121
+ # * WKST:
122
+ # * it should be MO
123
+ #
124
+ # * FREQ:
125
+ # * should be one of WEEKLY, MONTHLY, YEARLY
126
+ # * should be MONTHLY if BYDAY has (1|2|3|4|-1)
127
+ # * should be WEEKLY if BYDAY does not have (1|2|3|4|-1)
128
+ #
129
+ # * BYDAY:
130
+ # * should be a list of (1|2|3|4|-1)?(MO|TU|WE|TH|FR|SA|SU)
131
+ #
132
+ # * Every week should have the same number-prefix set:
133
+ # WE,SU is OK => Wed Sun
134
+ # 3WE,3SU is OK => 3rd Wed Sun
135
+ # 2WE,3WE,2SU,3SU is OK => 2nd 3rd Sun Wed
136
+ # 3WE,2SU is NG
137
+ # 3WE,SU is NG
138
+ #
139
+ # * Fully converted elements:
140
+ #
141
+ # * UNTIL
142
+ # * YYYYMMDD should goes to X-SC-Duration: -YYYYMMDD
143
+ #
144
+ # * BYMONTH
145
+ # * (1..12)* => (Jan|Feb|Mar|Jul|Aug|Sep|Oct|Nov|Dec)*
146
+ #
147
+ def validate_rrule(rrule)
148
+ interval = (rrule =~ /INTERVAL=(\d+)/i) ? $1.to_i : 1
149
+ return true if rrule.to_s == ""
150
+ return 1 if rrule =~ /(BYSECOND|BYMINUTE|BYHOUR|COUNT|BYYEARDAY|BYWEEKNO|BYSETPOS)/i
151
+ return 2 unless (rrule =~ /FREQ=MONTHLY/i and interval == 12) || interval == 1
152
+ return 3 if rrule =~ /BYMONTHDAY=([^;]+)/i and $1.split(",").map(&:to_i).any?{|i| i < 1 or i > 31}
153
+ return 4 if rrule =~ /WKST=([^;]+)/i and $1 !~ /MO/
154
+ return 5 if rrule =~ /FREQ=([^;]+)/i and $1 !~ /WEEKLY|MONTHLY|YEARLY/i
155
+ return 6 if rrule =~ /BYDAY=([^;]+)/i and $1 =~ /\d/ and rrule !~ /FREQ=MONTHLY/i
156
+ return 7 if rrule =~ /BYDAY=([^;]+)/i and $1 !~ /\d/ and rrule !~ /FREQ=WEEKLY/i
157
+ return 8 if rrule =~ /BYDAY=([^;]+)/i and $1 !~ /((1|2|3|4|-1)?(MO|TU|WE|TH|FR|SA|SU))+/i
158
+ return true
159
+ end
160
+
161
+ def set_from_ics(rrule, dtstart)
162
+ if (errno = validate_rrule(rrule)) != true
163
+ raise "Unsupported RRULE string (errno=#{errno}): #{rrule}"
164
+ end
165
+
166
+ ################
167
+ ## BYMONTH (cond_mon)
168
+ cond_mon = []
169
+ if rrule =~ /BYMONTH=([^;]+)/
170
+ $1.split(",").each do |mon|
171
+ cond_mon << mon.to_i
172
+ end
173
+ end
174
+
175
+ ################
176
+ ## BYDAY (cond_ord, cond_wek)
177
+ cond_ord = []
178
+ cond_wek = []
179
+ week = {}
180
+ if rrule =~ /BYDAY=([^;]+)/
181
+ $1.scan(/(1|2|3|4|-1)?(MO|TU|WE|TH|FR|SA|SU)/).each do |o,w|
182
+ week[w] ||= []
183
+ week[w] << o.to_i # unpefixed week is replaced as 0
184
+ end
185
+
186
+ # Every week should have the same number-prefix set:
187
+ return 9 unless week.values.all?{|orders| orders.sort == week.values.first.sort}
188
+
189
+ order = week.values.first.sort
190
+ # * Number-prefixed week cannot coexist with unprefixed week
191
+ # WE,SU is OK => Wed Sun
192
+ # WE,3SU is NG
193
+ return 10 if order.length > 1 and order.member?(0) # 0 means non-numberd prefix
194
+
195
+ order.delete(0)
196
+ cond_ord = order
197
+
198
+ week.each do |w, o|
199
+ cond_wek << WEK_V2I.invert[w]
200
+ end
201
+ end
202
+
203
+ ################
204
+ ## BYMONTHDAY (cond_num)
205
+ cond_num = []
206
+ if rrule =~ /BYMONTHDAY=([^;]+)/i
207
+ $1.split(",").each do |n|
208
+ cond_num << n.to_i
209
+ end
210
+ end
211
+
212
+ ################
213
+ # Special cases
214
+
215
+ interval = (rrule =~ /INTERVAL=(\d+)/i) ? $1.to_i : 1
216
+
217
+ # special case of yearly: repeat with 12 months interval
218
+ # BYMONTH should be taken from DTSTART
219
+ if interval == 12 and rrule =~ /FREQ=MONTHLY/i and cond_mon.empty?
220
+ cond_mon << dtstart.month
221
+ end
222
+
223
+ # if RRULE has only FREQ=YEARLY phrase,
224
+ # BYMONTH and BYMONTHDAY should be taken from DTSTART
225
+ #
226
+ if rrule =~ /FREQ=YEARLY/i
227
+ cond_mon << dtstart.month if cond_mon.empty?
228
+ cond_num << dtstart.day if cond_num.empty? and cond_wek.empty?
229
+ end
230
+
231
+ @cond_mon, @cond_ord, @cond_wek, @cond_num = cond_mon, cond_ord, cond_wek, cond_num
232
+ return self
233
+ end
234
+
235
+ def to_mhc_string
236
+ return (cond_mon.map{|mon| MON_V2L[mon]} +
237
+ cond_ord.map{|ord| ORD_V2L[ord]} +
238
+ cond_wek.map{|wek| WEK_V2L[wek]} +
239
+ cond_num.map{|num| num.to_s}
240
+ ).join(" ")
241
+ end
242
+
243
+ def to_ics(dtstart = nil, until_date = nil)
244
+ return nil unless valid?
245
+
246
+ ord_wek = (cond_ord.empty? ? [""] : cond_ord).product(cond_wek)
247
+ day = ord_wek.map {|o,w| o.to_s + WEK_V2I[w] }.join(',')
248
+
249
+ if until_date
250
+ if dtstart.respond_to?(:hour)
251
+ tz = TZInfo::Timezone.get(ENV["MHC_TZID"] || 'UTC')
252
+ localtime = Mhc::PropertyValue::Time.new.parse(dtstart.strftime("%H:%M")).to_datetime(until_date).to_time
253
+ until_str = tz.local_to_utc(localtime).strftime("%Y%m%dT%H%M%SZ")
254
+ # puts "until_str local (tz=#{tz.name}) : #{localtime.strftime("%Y%m%dT%H%M%S")} utc: #{until_str}"
255
+ else
256
+ until_str = until_date.strftime("%Y%m%d")
257
+ end
258
+ end
259
+
260
+ ics = "FREQ=#{frequency.to_s.upcase};INTERVAL=1;WKST=MO"
261
+
262
+ ics += ";BYMONTH=#{cond_mon.join(',')}" unless cond_mon.empty?
263
+ ics += ";BYDAY=#{day}" unless day.empty?
264
+ ics += ";BYMONTHDAY=#{cond_num.join(',')}" unless cond_num.empty?
265
+ ics += ";UNTIL=#{until_str}" unless until_date.nil?
266
+
267
+ return ics
268
+ end
269
+
270
+ end # class RecurrenceCondition
271
+ end # module PropertyValue
272
+ end # module Mhc