hiccup 0.0.0 → 0.2.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.
- data/Rakefile +9 -1
- data/hiccup.gemspec +8 -2
- data/lib/hiccup.rb +69 -1
- data/lib/hiccup/convenience.rb +28 -0
- data/lib/hiccup/core_ext/date.rb +29 -0
- data/lib/hiccup/core_ext/duration.rb +25 -0
- data/lib/hiccup/core_ext/fixnum.rb +37 -0
- data/lib/hiccup/enumerable.rb +230 -0
- data/lib/hiccup/humanizable.rb +68 -0
- data/lib/hiccup/schedule.rb +42 -0
- data/lib/hiccup/serializable/ical.rb +39 -0
- data/lib/hiccup/serializers/ical.rb +189 -0
- data/lib/hiccup/validatable.rb +110 -0
- data/lib/hiccup/version.rb +1 -1
- data/test/enumerable_test.rb +283 -0
- data/test/humanizable_test.rb +70 -0
- data/test/ical_serializable_test.rb +92 -0
- data/test/test_helper.rb +6 -0
- data/test/validatable_test.rb +81 -0
- metadata +68 -6
@@ -0,0 +1,68 @@
|
|
1
|
+
require "hiccup/convenience"
|
2
|
+
require "hiccup/core_ext/fixnum"
|
3
|
+
|
4
|
+
|
5
|
+
module Hiccup
|
6
|
+
module Humanizable
|
7
|
+
include Convenience
|
8
|
+
|
9
|
+
|
10
|
+
|
11
|
+
def humanize
|
12
|
+
case kind
|
13
|
+
when :never; ""
|
14
|
+
when :weekly; weekly_humanize
|
15
|
+
when :monthly; monthly_humanize
|
16
|
+
when :annually; yearly_humanize
|
17
|
+
else; "Invalid"
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
|
26
|
+
|
27
|
+
def weekly_humanize
|
28
|
+
weekdays = pattern.map(&:humanize).to_sentence
|
29
|
+
if skip == 1 || pattern.length == 1
|
30
|
+
sentence("Every", ordinal, weekdays)
|
31
|
+
else
|
32
|
+
sentence(weekdays, "of every", ordinal, "week")
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def monthly_humanize
|
37
|
+
monthly_occurrences = pattern.map(&method(:monthly_occurrence_to_s)).to_sentence
|
38
|
+
sentence("The", monthly_occurrences, "of every", ordinal, "month")
|
39
|
+
end
|
40
|
+
|
41
|
+
def yearly_humanize
|
42
|
+
sentence("Every", ordinal, "year on", self.start_date.strftime('%b %d'))
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
|
47
|
+
def monthly_occurrence_to_s(monthly_occurrence)
|
48
|
+
if monthly_occurrence.is_a?(Array)
|
49
|
+
_skip, weekday = monthly_occurrence
|
50
|
+
ordinal = _skip.human_ordinalize
|
51
|
+
sentence(ordinal, weekday.humanize)
|
52
|
+
else
|
53
|
+
monthly_occurrence.ordinalize
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def ordinal
|
58
|
+
skip && skip.human_ordinalize(1 => nil, 2 => "other")
|
59
|
+
end
|
60
|
+
|
61
|
+
def sentence(*array)
|
62
|
+
array.compact.join(" ")
|
63
|
+
end
|
64
|
+
|
65
|
+
|
66
|
+
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require "hiccup"
|
2
|
+
require "active_model/validations"
|
3
|
+
|
4
|
+
|
5
|
+
module Hiccup
|
6
|
+
class Schedule
|
7
|
+
extend Hiccup
|
8
|
+
include ActiveModel::Validations
|
9
|
+
|
10
|
+
|
11
|
+
hiccup :enumerable,
|
12
|
+
:validatable,
|
13
|
+
:humanizable,
|
14
|
+
:serializable => [:ical]
|
15
|
+
|
16
|
+
|
17
|
+
def initialize(options={})
|
18
|
+
@kind = options[:kind] || :never
|
19
|
+
@start_date = options[:start_date] || Date.today
|
20
|
+
@ends = options.key?(:ends) ? options[:ends] : false
|
21
|
+
@end_date = options[:end_date]
|
22
|
+
@skip = options[:skip] || options[:interval] || 1
|
23
|
+
@pattern = options[:pattern] || []
|
24
|
+
end
|
25
|
+
|
26
|
+
|
27
|
+
attr_accessor :kind, :start_date, :ends, :end_date, :skip, :pattern
|
28
|
+
|
29
|
+
|
30
|
+
def to_hash
|
31
|
+
{
|
32
|
+
:kind => kind,
|
33
|
+
:start_date => start_date,
|
34
|
+
:ends => ends,
|
35
|
+
:end_date => end_date,
|
36
|
+
:pattern => pattern
|
37
|
+
}
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
require "hiccup/serializers/ical"
|
2
|
+
require "active_support/concern"
|
3
|
+
|
4
|
+
|
5
|
+
module Hiccup
|
6
|
+
module Serializable
|
7
|
+
module Ical
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
|
11
|
+
def to_ical
|
12
|
+
ical_serializer.dump(self)
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
module ClassMethods
|
17
|
+
|
18
|
+
def from_ical(ics)
|
19
|
+
ical_serializer.load(ics)
|
20
|
+
end
|
21
|
+
|
22
|
+
def ical_serializer
|
23
|
+
@ical_serializer ||= Serializers::Ical.new(self)
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
|
32
|
+
def ical_serializer
|
33
|
+
self.class.ical_serializer
|
34
|
+
end
|
35
|
+
|
36
|
+
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,189 @@
|
|
1
|
+
require "ri_cal"
|
2
|
+
|
3
|
+
|
4
|
+
module Hiccup
|
5
|
+
module Serializers
|
6
|
+
class Ical
|
7
|
+
|
8
|
+
def initialize(klass)
|
9
|
+
@klass = klass
|
10
|
+
end
|
11
|
+
|
12
|
+
|
13
|
+
|
14
|
+
def dump(obj)
|
15
|
+
@component = RiCal::Component::Event.new
|
16
|
+
@component.dtstart = obj.start_date.to_time
|
17
|
+
@obj = obj
|
18
|
+
|
19
|
+
case obj.kind
|
20
|
+
when :weekly; add_weekly_rule
|
21
|
+
when :monthly; add_monthly_rule
|
22
|
+
when :annually; add_yearly_rule
|
23
|
+
end
|
24
|
+
|
25
|
+
StringIO.new.tap {|io|
|
26
|
+
@component.export_properties_to(io)
|
27
|
+
@component.export_x_properties_to(io)
|
28
|
+
}.string
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
|
33
|
+
def load(ics)
|
34
|
+
return unless ics # Required for now, for ActiveRecord
|
35
|
+
|
36
|
+
component_ics = "BEGIN:VEVENT\n#{ics}\nEND:VEVENT"
|
37
|
+
component = RiCal.parse_string(component_ics).first
|
38
|
+
|
39
|
+
@obj = @klass.new
|
40
|
+
@obj.start_date = component.dtstart_property.to_datetime
|
41
|
+
component.rrule_property.each do |rule|
|
42
|
+
case rule.freq
|
43
|
+
when "WEEKLY"; parse_weekly_rule(rule)
|
44
|
+
when "MONTHLY"; parse_monthly_rule(rule)
|
45
|
+
when "YEARLY"; parse_yearly_rule(rule)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
@obj
|
50
|
+
end
|
51
|
+
|
52
|
+
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
|
57
|
+
|
58
|
+
def add_weekly_rule
|
59
|
+
add_rule("WEEKLY", :byday => abbreviate_weekdays(@obj.pattern))
|
60
|
+
end
|
61
|
+
|
62
|
+
|
63
|
+
|
64
|
+
def add_monthly_rule
|
65
|
+
byday = []
|
66
|
+
bymonthday = []
|
67
|
+
@obj.pattern.each do |occurrence|
|
68
|
+
if occurrence.is_a?(Array)
|
69
|
+
i, weekday = occurrence
|
70
|
+
byday << "#{i}#{abbreviate_weekday(weekday)}"
|
71
|
+
else
|
72
|
+
bymonthday << occurrence
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
add_rule("MONTHLY", :bymonthday => bymonthday) if bymonthday.any?
|
77
|
+
add_rule("MONTHLY", :byday => byday) if byday.any?
|
78
|
+
end
|
79
|
+
|
80
|
+
|
81
|
+
|
82
|
+
def add_yearly_rule
|
83
|
+
add_rule("YEARLY")
|
84
|
+
end
|
85
|
+
|
86
|
+
|
87
|
+
|
88
|
+
def add_rule(freq, options={})
|
89
|
+
merge_default_options_for_new_rule!(freq, options)
|
90
|
+
parent = options.delete(:parent)
|
91
|
+
# puts "[add_rule] (#{parent.inspect}, #{options.inspect})"
|
92
|
+
rrule = RiCal::PropertyValue::RecurrenceRule.new(parent, options)
|
93
|
+
@component.rrule_property.push(rrule)
|
94
|
+
end
|
95
|
+
|
96
|
+
def merge_default_options_for_new_rule!(freq, options)
|
97
|
+
options.merge!({
|
98
|
+
:freq => freq,
|
99
|
+
:interval => @obj.skip,
|
100
|
+
:until => @obj.ends? && @obj.end_date && @obj.end_date.to_time
|
101
|
+
})
|
102
|
+
end
|
103
|
+
|
104
|
+
|
105
|
+
|
106
|
+
|
107
|
+
|
108
|
+
def parse_weekly_rule(rule)
|
109
|
+
@obj.kind = :weekly
|
110
|
+
@obj.pattern = backmap_weekdays(rule.by_list[:byday])
|
111
|
+
parse_rule(rule)
|
112
|
+
end
|
113
|
+
|
114
|
+
|
115
|
+
|
116
|
+
def parse_monthly_rule(rule)
|
117
|
+
@obj.kind = :monthly
|
118
|
+
parse_monthly_bymonthyday(rule.by_list[:bymonthday])
|
119
|
+
parse_monthly_byday(rule.by_list[:byday])
|
120
|
+
parse_rule(rule)
|
121
|
+
end
|
122
|
+
|
123
|
+
def parse_monthly_bymonthyday(bymonthday)
|
124
|
+
(bymonthday || []).each do |bymonthday|
|
125
|
+
@obj.pattern = @obj.pattern + [bymonthday.ordinal]
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def parse_monthly_byday(byday)
|
130
|
+
(byday || []).each do |byday|
|
131
|
+
@obj.pattern = @obj.pattern + [[byday.index, backmap_weekday(byday)]]
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
|
136
|
+
|
137
|
+
def parse_yearly_rule(rule)
|
138
|
+
@obj.kind = :annually
|
139
|
+
parse_rule(rule)
|
140
|
+
end
|
141
|
+
|
142
|
+
|
143
|
+
|
144
|
+
def parse_rule(rule)
|
145
|
+
@obj.skip = rule.interval
|
146
|
+
if rule.until
|
147
|
+
@obj.ends = true
|
148
|
+
@obj.end_date = rule.until.to_datetime
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
|
153
|
+
|
154
|
+
|
155
|
+
|
156
|
+
def abbreviate_weekdays(weekdays)
|
157
|
+
weekdays.map(&method(:abbreviate_weekday)).compact
|
158
|
+
end
|
159
|
+
|
160
|
+
def abbreviate_weekday(weekday)
|
161
|
+
WEEKDAY_MAP[weekday.to_s.downcase]
|
162
|
+
end
|
163
|
+
|
164
|
+
def backmap_weekdays(byday)
|
165
|
+
byday ||= []
|
166
|
+
byday.map(&method(:backmap_weekday)).compact
|
167
|
+
end
|
168
|
+
|
169
|
+
def backmap_weekday(byday)
|
170
|
+
Date::DAYNAMES[byday.wday]
|
171
|
+
end
|
172
|
+
|
173
|
+
|
174
|
+
|
175
|
+
WEEKDAY_MAP = {
|
176
|
+
"sunday" => "SU",
|
177
|
+
"monday" => "MO",
|
178
|
+
"tuesday" => "TU",
|
179
|
+
"wednesday" => "WE",
|
180
|
+
"thursday" => "TH",
|
181
|
+
"friday" => "FR",
|
182
|
+
"saturday" => "SA"
|
183
|
+
}
|
184
|
+
|
185
|
+
|
186
|
+
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
@@ -0,0 +1,110 @@
|
|
1
|
+
require "hiccup/convenience"
|
2
|
+
require "active_support/concern"
|
3
|
+
|
4
|
+
|
5
|
+
module Hiccup
|
6
|
+
module Validatable
|
7
|
+
include Convenience
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
|
11
|
+
Kinds = [:never, :weekly, :monthly, :annually]
|
12
|
+
|
13
|
+
|
14
|
+
# !todo: use ActiveModel:Validation rather than a custom method
|
15
|
+
included do
|
16
|
+
validate :validate_recurrence
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
|
23
|
+
# !todo: use i18n to let clients of this library supply their own wording
|
24
|
+
def validate_recurrence
|
25
|
+
case kind
|
26
|
+
when :never;
|
27
|
+
when :weekly; validate_weekly_recurrence
|
28
|
+
when :monthly; validate_monthly_recurrence
|
29
|
+
when :annually;
|
30
|
+
else; invalid_kind!
|
31
|
+
end
|
32
|
+
|
33
|
+
errors.add(:start_date, "is a #{self.start_date.class} not a Date") unless self.start_date.is_a?(Date)
|
34
|
+
errors.add(:skip, "is not a positive integer") unless (skip.is_a? Fixnum) and (skip > 0)
|
35
|
+
if ends?
|
36
|
+
if self.end_date.is_a? Date
|
37
|
+
errors.add(:end_date, "cannot be before start") if (self.end_date < self.start_date)
|
38
|
+
else
|
39
|
+
errors.add(:end_date, "is a #{self.end_date.class} not a Date")
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
|
45
|
+
def validate_weekly_recurrence
|
46
|
+
if !pattern.is_a?(Array)
|
47
|
+
errors.add(:pattern, "is a #{pattern.class}. It should be an array")
|
48
|
+
elsif pattern.empty?
|
49
|
+
errors.add(:pattern, "is empty. It should contain a list of weekdays")
|
50
|
+
elsif (invalid_names = pattern - Date::DAYNAMES).any?
|
51
|
+
errors.add(:pattern, "should contain only weekdays. (#{invalid_names.to_sentence} are invalid)")
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
|
56
|
+
def validate_monthly_recurrence
|
57
|
+
if !pattern.is_a?(Array)
|
58
|
+
errors.add(:pattern, "is a #{pattern.class}. It should be an array")
|
59
|
+
elsif pattern.empty?
|
60
|
+
errors.add(:pattern, "is empty. It should contain a list of monthly occurrences")
|
61
|
+
elsif pattern.select(&method(:invalid_occurrence?)).any?
|
62
|
+
errors.add(:pattern, "contains invalid monthly occurrences")
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
|
67
|
+
def invalid_occurrence?(occurrence)
|
68
|
+
!valid_occurrence?(occurrence)
|
69
|
+
end
|
70
|
+
|
71
|
+
def valid_occurrence?(occurrence)
|
72
|
+
if occurrence.is_a?(Array)
|
73
|
+
i, wd = occurrence
|
74
|
+
Date::DAYNAMES.member?(wd) && i.is_a?(Fixnum) && ((i == -1) || (1..6).include?(i))
|
75
|
+
else
|
76
|
+
i = occurrence
|
77
|
+
i.is_a?(Fixnum) && (1..31).include?(i)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
def invalid_kind!
|
83
|
+
errors.add(:kind, "is not recognized. It must be one of #{Kinds.collect{|kind| "'#{kind}'"}.to_sentence(:two_words_connector => " or ", :last_word_connector => ", or ")}.")
|
84
|
+
end
|
85
|
+
|
86
|
+
|
87
|
+
# def valid_occurrence?(occurrence)
|
88
|
+
# if occurrence.is_a?(Array)
|
89
|
+
# ordinal, kind = occurrence
|
90
|
+
#
|
91
|
+
# errors.add(:kind, "is not a valid monthly occurrence kind") unless Date::DAYNAMES.member?(kind)
|
92
|
+
# if ordinal.is_a?(Fixnum)
|
93
|
+
# errors.add(:ordinal, "is not a valid integer") unless (ordinal==-1) or (1..6).include?(ordinal)
|
94
|
+
# else
|
95
|
+
# errors.add(:ordinal, "is not an integer")
|
96
|
+
# end
|
97
|
+
# else
|
98
|
+
# ordinal = occurrence
|
99
|
+
#
|
100
|
+
# if ordinal.is_a?(Fixnum)
|
101
|
+
# errors.add(:ordinal, "is not an integer between 1 and 31") unless (1..31).include?(ordinal)
|
102
|
+
# else
|
103
|
+
# errors.add(:ordinal, "is not an integer")
|
104
|
+
# end
|
105
|
+
# end
|
106
|
+
# end
|
107
|
+
|
108
|
+
|
109
|
+
end
|
110
|
+
end
|