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.
- checksums.yaml +7 -0
- data/.gitignore +27 -0
- data/.rspec +2 -0
- data/.travis.yml +3 -0
- data/COPYRIGHT +28 -0
- data/Gemfile +8 -0
- data/README.org +209 -0
- data/Rakefile +13 -0
- data/bin/mhc +312 -0
- data/emacs/Cask +25 -0
- data/emacs/Makefile +58 -0
- data/emacs/mhc-calendar.el +1723 -0
- data/emacs/mhc-calfw.el +135 -0
- data/emacs/mhc-compat.el +90 -0
- data/emacs/mhc-date.el +642 -0
- data/emacs/mhc-day.el +149 -0
- data/emacs/mhc-db.el +158 -0
- data/emacs/mhc-draft.el +211 -0
- data/emacs/mhc-e21.el +167 -0
- data/emacs/mhc-face.el +236 -0
- data/emacs/mhc-file.el +224 -0
- data/emacs/mhc-guess.el +648 -0
- data/emacs/mhc-header.el +176 -0
- data/emacs/mhc-logic.el +563 -0
- data/emacs/mhc-message.el +130 -0
- data/emacs/mhc-minibuf.el +466 -0
- data/emacs/mhc-misc.el +248 -0
- data/emacs/mhc-mua.el +260 -0
- data/emacs/mhc-parse.el +286 -0
- data/emacs/mhc-process.el +35 -0
- data/emacs/mhc-ps.el +1174 -0
- data/emacs/mhc-record.el +201 -0
- data/emacs/mhc-schedule.el +202 -0
- data/emacs/mhc-summary.el +763 -0
- data/emacs/mhc-sync.el +158 -0
- data/emacs/mhc-vars.el +149 -0
- data/emacs/mhc.el +1114 -0
- data/icons/Anniversary.xbm +6 -0
- data/icons/Anniversary.xpm +27 -0
- data/icons/Birthday.xbm +6 -0
- data/icons/Birthday.xpm +25 -0
- data/icons/Business.xbm +6 -0
- data/icons/Business.xpm +24 -0
- data/icons/CheckBox.xbm +6 -0
- data/icons/CheckBox.xpm +24 -0
- data/icons/CheckedBox.xbm +6 -0
- data/icons/CheckedBox.xpm +25 -0
- data/icons/Conflict.xbm +6 -0
- data/icons/Conflict.xpm +22 -0
- data/icons/Date.xbm +6 -0
- data/icons/Date.xpm +29 -0
- data/icons/Holiday.xbm +6 -0
- data/icons/Holiday.xpm +25 -0
- data/icons/Link.xbm +6 -0
- data/icons/Link.xpm +25 -0
- data/icons/Other.xbm +6 -0
- data/icons/Other.xpm +28 -0
- data/icons/Party.xbm +6 -0
- data/icons/Party.xpm +23 -0
- data/icons/Private.xbm +6 -0
- data/icons/Private.xpm +26 -0
- data/icons/Recurrence.xbm +6 -0
- data/icons/Recurrence.xpm +98 -0
- data/icons/Vacation.xbm +6 -0
- data/icons/Vacation.xpm +26 -0
- data/lib/mhc.rb +45 -0
- data/lib/mhc/builder.rb +64 -0
- data/lib/mhc/caldav.rb +304 -0
- data/lib/mhc/calendar.rb +106 -0
- data/lib/mhc/command.rb +13 -0
- data/lib/mhc/command/cache.rb +14 -0
- data/lib/mhc/command/completions.rb +108 -0
- data/lib/mhc/command/init.rb +133 -0
- data/lib/mhc/command/scan.rb +33 -0
- data/lib/mhc/command/sync.rb +22 -0
- data/lib/mhc/config.rb +229 -0
- data/lib/mhc/converter.rb +330 -0
- data/lib/mhc/datastore.rb +164 -0
- data/lib/mhc/date_enumerator.rb +274 -0
- data/lib/mhc/date_frame.rb +124 -0
- data/lib/mhc/date_helper.rb +49 -0
- data/lib/mhc/etag.rb +68 -0
- data/lib/mhc/event.rb +396 -0
- data/lib/mhc/formatter.rb +312 -0
- data/lib/mhc/logger.rb +94 -0
- data/lib/mhc/modifier.rb +149 -0
- data/lib/mhc/occurrence.rb +94 -0
- data/lib/mhc/occurrence_enumerator.rb +113 -0
- data/lib/mhc/property_value.rb +33 -0
- data/lib/mhc/property_value/date.rb +190 -0
- data/lib/mhc/property_value/integer.rb +15 -0
- data/lib/mhc/property_value/list.rb +41 -0
- data/lib/mhc/property_value/period.rb +49 -0
- data/lib/mhc/property_value/range.rb +100 -0
- data/lib/mhc/property_value/recurrence_condition.rb +272 -0
- data/lib/mhc/property_value/text.rb +11 -0
- data/lib/mhc/property_value/time.rb +45 -0
- data/lib/mhc/query.rb +210 -0
- data/lib/mhc/sync.rb +46 -0
- data/lib/mhc/sync/driver.rb +108 -0
- data/lib/mhc/sync/status.rb +70 -0
- data/lib/mhc/sync/status_manager.rb +142 -0
- data/lib/mhc/sync/strategy.rb +233 -0
- data/lib/mhc/sync/syncinfo.rb +98 -0
- data/lib/mhc/templates/config.yml.erb +142 -0
- data/lib/mhc/version.rb +4 -0
- data/lib/mhc/webdav.rb +319 -0
- data/mhc.gemspec +24 -0
- data/samples/DOT.mhc-config.yml +116 -0
- data/samples/japanese-holidays.mhcc +153 -0
- data/samples/mhc-completions.zsh +11 -0
- data/spec/mhc_spec.rb +682 -0
- data/spec/spec_helper.rb +9 -0
- data/xpm/close.xpm +18 -0
- data/xpm/delete.xpm +19 -0
- data/xpm/exit.xpm +18 -0
- data/xpm/month.xpm +18 -0
- data/xpm/next.xpm +18 -0
- data/xpm/next2.xpm +18 -0
- data/xpm/next_year.xpm +18 -0
- data/xpm/open.xpm +19 -0
- data/xpm/prev.xpm +18 -0
- data/xpm/prev2.xpm +18 -0
- data/xpm/prev_year.xpm +18 -0
- data/xpm/save.xpm +19 -0
- data/xpm/today.xpm +18 -0
- 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
|