ess 0.9.3 → 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.
data/README.md CHANGED
@@ -3,13 +3,13 @@ ruby-ess [![ESS Feed Standard](http://essfeed.org/images/8/87/ESS_logo_32x32.png
3
3
 
4
4
  [![Build Status](https://travis-ci.org/essfeed/ruby-ess.png)](https://travis-ci.org/essfeed/ruby-ess)
5
5
 
6
- Generate ESS XML feeds with Ruby
6
+ Generate and parse ESS XML feeds with Ruby
7
7
 
8
8
  ## Installation
9
9
 
10
10
  Add this line to your application's Gemfile:
11
11
 
12
- gem 'ess', '~> 0.9.3'
12
+ gem 'ess', '~> 1.0.0'
13
13
 
14
14
  And then execute:
15
15
 
@@ -17,12 +17,18 @@ And then execute:
17
17
 
18
18
  Or install it yourself as:
19
19
 
20
- $ gem install ess -v 0.9.3
20
+ $ gem install ess -v 1.0.0
21
+
22
+ ## Information
23
+
24
+ * RDoc documentation [available on RubyDoc.info](http://rubydoc.info/gems/ess/frames)
25
+ * Source code [available on GitHub](http://github.com/essfeed/ruby-ess)
21
26
 
22
27
  ## Usage
23
28
 
24
- Producing your own ESS feeds is easy. Here is an example about how
25
- it's done, with most of the available tags available in ESS:
29
+ Producing your own ESS feeds is easy. Here is a rather extensive
30
+ example about how it's done, with most of the available tags
31
+ available in ESS:
26
32
 
27
33
  ```ruby
28
34
 
@@ -411,6 +417,73 @@ can be assigned to the ID tag of a channel or feed, and if it doesn't
411
417
  start with "ESSID:" or "EVENTID:", it will be regenerated using that
412
418
  string as the key.
413
419
 
420
+ ### Parsing ESS files
421
+
422
+ There is also an ESS::Parser class, which has one method "#parse"
423
+ which accepts a string containing the ESS/XML document, or a File
424
+ object. It parses the document and returns an ESS::ESS object,
425
+ which represents the root elements of an ESS document.
426
+
427
+ This object has methods which correspond to child tags, and each
428
+ method retrieves another object which represents a child tag with
429
+ that name, and which again has methods corresponding to its child
430
+ elements. I believe it's all easier to understand from an example:
431
+
432
+ ```ruby
433
+
434
+ ess = ESS::Parser.parse(xml_doc)
435
+
436
+ # To retrieve a child tag, just use a method with the same name
437
+ title_tag = ess.channel.title
438
+
439
+ # To retrieve a tags text, use the #text! method
440
+ title_text = title_tag.text!
441
+
442
+ # Some tags car be repeated more then once. For that case the tag
443
+ # objects accept tag_name_list methods, which return a list of child
444
+ # tags named "tag_name", like this:
445
+ feeds = ess.channel.feed_list
446
+ feeds.each do |feed|
447
+ puts feed.title.text!
448
+ end
449
+
450
+ # For reading attribute values, the objects accept attr_name_attr
451
+ # methods, for example, to retrieve the value of attribute "type" of
452
+ # a "item" tag:
453
+ item = feeds[0].dates.item_list[0]
454
+ date_item_type = item.type_attr
455
+
456
+ ```
457
+
458
+ #### find_coming and find_between
459
+
460
+ To aid web developers who want to display events in a list or a
461
+ calendar, the ESS::ESS objects returned by the parser have two
462
+ additional methods: find_coming and find_between.
463
+
464
+ find_coming tries to find the next N events for your event list.
465
+ It returns a list of hashes, each hash representing one item for
466
+ the list. The hash contains the :time and :feed keys, the former
467
+ being the start time of the event, and the later the complete
468
+ feed describing the whole event.
469
+
470
+ find_coming accepts two parameters. The first parameter is the
471
+ number of events it should return and the default is 10. The second
472
+ parameter defaults to Time.now, and the method with return N events
473
+ starting from the moment specified.
474
+
475
+ find_between returns a list of all events between the two moments
476
+ in time, which as specified as parameters with Time objects. For
477
+ example, to retrieve all events in June 2013, run this:
478
+
479
+ ```ruby
480
+
481
+ start_time = "2013-06-01 00:00"
482
+ end_time = "2013-06-30 24:00"
483
+ events = ess.find_between(start_time, end_time)
484
+
485
+ ```
486
+
414
487
  ## Contributing
415
488
 
416
489
  1. Fork it
@@ -3,6 +3,10 @@ require 'ess/validation'
3
3
 
4
4
  module ESS
5
5
  module DTD
6
+ ##
7
+ # The information in this module should not be used directly, it is
8
+ # intended for the document builder and parser.
9
+ #
6
10
  include ESS::Postprocessing
7
11
  include ESS::Validation
8
12
 
@@ -327,7 +331,7 @@ module ESS
327
331
  :country_code => { :dtd => COUNTRY_CODE,
328
332
  :mandatory => false,
329
333
  :max_occurs => 1 },
330
- :email => { :dtd => BASIC_ELEMENT,
334
+ :email => { :dtd => EMAIL,
331
335
  :mandatory => false,
332
336
  :max_occurs => 1 },
333
337
  :phone => { :dtd => BASIC_ELEMENT,
@@ -480,6 +484,12 @@ module ESS
480
484
  }
481
485
 
482
486
  class InvalidValueError < RuntimeError
487
+ ##
488
+ # This exception is generated when the builder or parser
489
+ # receive a value which is not valid for a tag or attribute.
490
+ # It should contain a message describing what was the value which
491
+ # caused it to be raised.
492
+ #
483
493
  end
484
494
  end
485
495
  end
@@ -4,27 +4,61 @@ require 'ess/pusher'
4
4
 
5
5
  module ESS
6
6
  class Element
7
+ ##
8
+ # Objects of this class represent the various tags available in ESS
9
+ #
10
+
11
+
7
12
  include ESS::Helpers
8
13
 
9
- attr_reader :dtd
14
+ ##
15
+ # Returns the dictionary describing this tag.
16
+ #
17
+ def dtd
18
+ return @dtd
19
+ end
10
20
 
21
+ ##
22
+ # There should never be a need to create an Element object yourself,
23
+ # except if you're the developer of this library. Use ESS::Maker.make
24
+ # if you need to create a new ESS document.
25
+ #
26
+ # === Parameters
27
+ #
28
+ # [name] a symbol, the name of the tag being created
29
+ # [dtd] a hash describing the tag
30
+ #
11
31
  def initialize name, dtd
12
32
  @name, @dtd = name, dtd
13
33
  end
14
34
 
35
+ ##
36
+ # Return the name of the tag as a symbol.
37
+ #
15
38
  def name!
16
39
  @name
17
40
  end
18
41
 
42
+ ##
43
+ # Returns or sets the text contained in this tag
44
+ #
19
45
  def text! text=nil
20
46
  return @text ||= "" if text.nil?
21
47
  @text = do_text_postprocessing_of text
22
48
  end
23
49
 
50
+ ##
51
+ # Returns a short description of this object.
52
+ #
24
53
  def inspect
25
54
  "#<#{self.class}:#{object_id} text=\"#{@text}\">"
26
55
  end
27
56
 
57
+ ##
58
+ # Validates the tag according to its DTD and all child tags. Throws an
59
+ # ESS::Validation::ValidationError exception in case the document is
60
+ # incomplete or an invalid value if found.
61
+ #
28
62
  def validate
29
63
  run_tag_validators
30
64
  check_attributes
@@ -32,6 +66,10 @@ module ESS
32
66
  return nil # if no errors found, i.e. no exceptions have been raised
33
67
  end
34
68
 
69
+ ##
70
+ # Same as #validate, but returns false if an error is found, instead of
71
+ # throwing exceptions.
72
+ #
35
73
  def valid?
36
74
  begin
37
75
  validate
@@ -41,6 +79,11 @@ module ESS
41
79
  return true
42
80
  end
43
81
 
82
+ ##
83
+ # Returns the feed as an XML document in a string. An Builder::XmlMarkup
84
+ # object can be passed as an argument and used as output, instead of
85
+ # generating a string object.
86
+ #
44
87
  def to_xml! xml=nil
45
88
  convert_to_string = true if xml.nil?
46
89
  xml = Builder::XmlMarkup.new if xml.nil?
@@ -61,16 +104,48 @@ module ESS
61
104
  xml.target! if convert_to_string
62
105
  end
63
106
 
107
+ ##
108
+ # A convenience method for pushing the current document to aggregators.
109
+ # It calls the ESS::Pusher.push_to_aggregators method and passes all
110
+ # options to it.
111
+ #
64
112
  def push_to_aggregators options={}
65
113
  raise RuntimeError, "only ESS root element can be pushed to aggregators" if @name != :ess
66
114
  options[:data] = self.to_xml!
67
115
  Pusher::push_to_aggregators options
68
116
  end
69
117
 
118
+ ##
119
+ # Same as #to_xml!, but accepts no arguments.
120
+ #
70
121
  def to_s
71
122
  to_xml!
72
123
  end
73
124
 
125
+ ##
126
+ # Disables postprocessing of tag values.
127
+ #
128
+ def disable_postprocessing
129
+ @@postprocessing_disabled = true
130
+ end
131
+
132
+ ##
133
+ # Enables postprocessing of tag values.
134
+ def enable_postprocessing
135
+ @@postprocessing_disabled = false
136
+ end
137
+
138
+ ##
139
+ # Returns true if postprocessing has been disabled.
140
+ #
141
+ def postprocessing_disabled?
142
+ @@postprocessing_disabled ||= false
143
+ end
144
+
145
+ ##
146
+ # Handles methods corresponding to a tag name, ending with either
147
+ # _list or _attr, or starting with add_ .
148
+ #
74
149
  def method_missing m, *args, &block
75
150
  if method_name_is_tag_name? m
76
151
  return assign_tag(m, args, &block)
@@ -156,8 +231,10 @@ module ESS
156
231
  end
157
232
 
158
233
  def run_post_processing tag
159
- if @dtd[:tags][tag.name!].keys.include? :postprocessing
160
- @dtd[:tags][tag.name!][:postprocessing].each { |processor| processor.process(self, tag) }
234
+ unless postprocessing_disabled?
235
+ if @dtd[:tags][tag.name!].keys.include? :postprocessing
236
+ @dtd[:tags][tag.name!][:postprocessing].each { |processor| processor.process(self, tag) }
237
+ end
161
238
  end
162
239
  end
163
240
 
@@ -170,10 +247,12 @@ module ESS
170
247
  end
171
248
 
172
249
  def do_text_postprocessing_of text
173
- text = text.to_s if text.class != String
174
- if @dtd.include? :postprocessing_text
175
- @dtd[:postprocessing_text].each do |processor|
176
- text = processor.process text
250
+ unless postprocessing_disabled?
251
+ text = text.to_s if text.class != String
252
+ if @dtd.include? :postprocessing_text
253
+ @dtd[:postprocessing_text].each do |processor|
254
+ text = processor.process text
255
+ end
177
256
  end
178
257
  end
179
258
  text
@@ -1,8 +1,306 @@
1
+ require 'time'
2
+
1
3
  module ESS
2
4
  class ESS < Element
3
5
  def initialize
4
6
  super :ess, DTD::ESS
5
7
  end
8
+
9
+ ##
10
+ # Returns the next n events from the moment specified in the second
11
+ # optional argument.
12
+ #
13
+ # === Parameters
14
+ #
15
+ # [n = 10] how many coming events should be returned
16
+ # [start_time] only events hapenning after this time will be considered
17
+ #
18
+ # === Returns
19
+ #
20
+ # A list of hashes, sorted by event start time, each hash having
21
+ # two keys:
22
+ #
23
+ # [:time] start time of the event
24
+ # [:feed] feed describing the event
25
+ #
26
+ def find_coming n=10, start_time=nil
27
+ start_time = Time.now if start_time.nil?
28
+ feeds = []
29
+ channel.feed_list.each do |feed|
30
+ feed.dates.item_list.each do |item|
31
+ if item.type_attr == "standalone"
32
+ feeds << { :time => Time.parse(item.start.text!), :feed => feed }
33
+ elsif item.type_attr == "recurrent"
34
+ moments = parse_recurrent_date_item(item, n, start_time)
35
+ moments.each do |moment|
36
+ feeds << { :time => moment, :feed => feed }
37
+ end
38
+ elsif item.type_attr == "permanent"
39
+ start = Time.parse(item.start.text!)
40
+ if start > start_time
41
+ feeds << { :time => start, :feed => feed }
42
+ else
43
+ feeds << { :time => start_time, :feed => feed }
44
+ end
45
+ else
46
+ raise DTD::InvalidValueError, "the \"#{item.type_attr}\" is not valid for a date item type attribute"
47
+ end
48
+ end
49
+ end
50
+ feeds = feeds.delete_if { |x| x[:time] < start_time }
51
+ feeds.sort! { |x, y| x[:time] <=> y[:time] }
52
+ return feeds[0..n-1]
53
+ end
54
+
55
+ ##
56
+ # Returns all events starting after the time specified by the first
57
+ # parameter and before the time specified by the second parameter,
58
+ # which accept regular Time objects.
59
+ #
60
+ # === Parameters
61
+ #
62
+ # [start_time] will return only events starting after this moment
63
+ # [end_time] will return only events starting before this moment
64
+ #
65
+ # === Returns
66
+ #
67
+ # A list of hashes, sorted by event start time, each hash having
68
+ # two keys:
69
+ #
70
+ # [:time] start time of the event
71
+ # [:feed] feed describing the event
72
+ #
73
+ def find_between start_time, end_time
74
+ feeds = []
75
+ channel.feed_list.each do |feed|
76
+ feed.dates.item_list.each do |item|
77
+ if item.type_attr == "standalone"
78
+ feed_start_time = Time.parse(item.start.text!)
79
+ if feed_start_time.between?(start_time, end_time)
80
+ feeds << { :time => feed_start_time, :feed => feed }
81
+ end
82
+ elsif item.type_attr == "recurrent"
83
+ moments = parse_recurrent_date_item(item, end_time, start_time)
84
+ moments.each do |moment|
85
+ if moment.between?(start_time, end_time)
86
+ feeds << { :time => moment, :feed => feed }
87
+ end
88
+ end
89
+ elsif item.type_attr == "permanent"
90
+ start = Time.parse(item.start.text!)
91
+ unless start > end_time
92
+ if start > start_time
93
+ feeds << { :time => start, :feed => feed }
94
+ else
95
+ feeds << { :time => start_time, :feed => feed }
96
+ end
97
+ end
98
+ else
99
+ raise DTD::InvalidValueError, "the \"#{item.type_attr}\" is not valid for a date item type attribute"
100
+ end
101
+ end
102
+ end
103
+ feeds.sort! { |x, y| x[:time] <=> y[:time] }
104
+ end
105
+
106
+ private
107
+
108
+ WEEK_DAYS = {
109
+ 'monday' => 1,
110
+ 'tuesday' => 2,
111
+ 'wednesday' => 3,
112
+ 'thursday' => 4,
113
+ 'friday' => 5,
114
+ 'saturday' => 6,
115
+ 'sunday' => 7
116
+ }
117
+
118
+ INC_FUNCS = {
119
+ "year" => lambda { |time| inc_year(time) },
120
+ "month" => lambda { |time| inc_month(time) },
121
+ "week" => lambda { |time| inc_week(time) },
122
+ "day" => lambda { |time| inc_day(time) },
123
+ "hour" => lambda { |time| inc_hour(time) }
124
+ }
125
+
126
+ def parse_recurrent_date_item item, n_or_end_date, start_time
127
+ current = first = Time.parse(item.start.text!)
128
+ inc_period_func = INC_FUNCS[item.unit_attr || "hour"]
129
+ interval = (item.interval_attr == "") ? 1 : item.interval_attr.to_i
130
+ all = []
131
+ if item.limit_attr.length == 0
132
+ break_func = (n.class == FixNum) ? lambda { all.length >= n_or_end_date } : lambda { current > n_or_end_date }
133
+ while true
134
+ parse_unit(item, current, all)
135
+ interval.times do current = inc_period_func.call(current) end
136
+ all = all.delete_if { |x| x[:time] < start_time }
137
+ break if break_func.call
138
+ end
139
+ else
140
+ item.limit_attr.to_i.times do
141
+ parse_unit(item, current, all)
142
+ interval.times do current = inc_period_func.call(current) end
143
+ end
144
+ end
145
+ all = all.delete_if { |time| time < start_time || time < first }
146
+ end
147
+
148
+ def parse_unit item, current, all
149
+ case item.unit_attr.downcase
150
+ when "year"
151
+ all << current
152
+ when "month"
153
+ weeks = item.selected_week_attr.downcase.split(",")
154
+ days = item.selected_day_attr.downcase.split(",")
155
+ if weeks.any?
156
+ if days.none?
157
+ days = WEEK_DAYS.keys
158
+ end
159
+ end
160
+ if weeks.none?
161
+ if days.any?
162
+ weeks = ["first", "second", "third", "fourth", "last"]
163
+ end
164
+ end
165
+ if days.any?
166
+ days.each do |day|
167
+ if WEEK_DAYS.include? day
168
+ parse_month_week_day(day, current, weeks, all)
169
+ elsif day.to_i.to_s == day
170
+ parse_month_day(day, current, all)
171
+ else
172
+ raise InvalidValueError, "the \"#{day}\" value is not valid for a date item selected_day attribute"
173
+ end
174
+ end
175
+ else
176
+ all << current
177
+ end
178
+ when "week"
179
+ days = item.selected_day_attr.split(",")
180
+ if days.none?
181
+ all << current
182
+ else
183
+ days.each { |day| parse_week_day(day, current, all) }
184
+ end
185
+ when "day"
186
+ all << current
187
+ when "hour"
188
+ all << current
189
+ else
190
+ raise InvalidValueError, "the \"#{item.unit_attr}\" is not valid for a date item unit attribute"
191
+ end
192
+ end
193
+
194
+ def parse_month_week_day day, current, weeks, all
195
+ weeks.each do |week|
196
+ case week
197
+ when "first"
198
+ current = change_time(current, :day => 1)
199
+ when "second"
200
+ current = change_time(current, :day => 8)
201
+ when "third"
202
+ current = change_time(current, :day => 15)
203
+ when "fourth"
204
+ current = change_time(current, :day => 22)
205
+ when "last"
206
+ current = change_time(current, :day => (days_in_month(current) - 6))
207
+ else
208
+ raise InvalidValueError, "the \"#{item.unit_attr}\" is not valid for a date item unit attribute"
209
+ end
210
+ all << change_time(current, :day => ((7+ WEEK_DAYS[day] - current.wday) % 7 + current.day))
211
+ end
212
+ end
213
+
214
+ def change_time time, options
215
+ sec = options[:sec] || time.sec
216
+ min = options[:min] || time.min
217
+ hour = options[:hour] || time.hour
218
+ day = options[:day] || time.day
219
+ month = options[:month] || time.month
220
+ year = options[:year] || time.year
221
+ time.class.new(year, month, day, hour, min, sec, time.utc_offset)
222
+ end
223
+
224
+ def days_in_month time
225
+ self.class.days_in_month time
226
+ end
227
+
228
+ def self.days_in_month time
229
+ case time.month
230
+ when 1, 3, 5, 7, 8, 10, 12
231
+ 31
232
+ when 2
233
+ if time.year % 4 == 0
234
+ 29
235
+ else
236
+ 28
237
+ end
238
+ when 4, 6, 9, 11
239
+ 30
240
+ end
241
+ end
242
+
243
+ def parse_month_day day, current, all
244
+ all << change_time(current, :day => day)
245
+ end
246
+
247
+ def parse_week_day day, current, all
248
+ day = day.downcase
249
+ current_wday = current.wday
250
+ current_wday = 7 if current_wday == 0
251
+ if WEEK_DAYS.keys.include? day
252
+ next_day = current.day + WEEK_DAYS[day] - current_wday
253
+ elsif day.to_i.to_s == day
254
+ next_day = current.day + day.to_i - current_wday
255
+ else
256
+ raise InvalidValueError, "the \"#{day}\" value is not valid for a date item selected_day attribute"
257
+ end
258
+ month = current.month
259
+ if next_day > days_in_month(current)
260
+ next_day -= days_in_month(current)
261
+ month += 1
262
+ end
263
+ year = current.year
264
+ if month > 12
265
+ month -= 12
266
+ year += 1
267
+ end
268
+ all << change_time(current, :year => year, :month => month, :day => next_day)
269
+ end
270
+
271
+ def self.inc_year time
272
+ if time.year % 4 == 0
273
+ time + 60*60*24*366
274
+ else
275
+ time + 60*60*24*365
276
+ end
277
+ end
278
+
279
+ def self.inc_month time
280
+ increment = nil
281
+ if time.month == 2
282
+ if time.year % 4 == 0
283
+ increment = 60*60*24*29
284
+ else
285
+ increment = 60*60*24*28
286
+ end
287
+ else
288
+ increment = 60*60*24*days_in_month(time)
289
+ end
290
+ time + increment
291
+ end
292
+
293
+ def self.inc_week time
294
+ time + 60*60*24*7
295
+ end
296
+
297
+ def self.inc_day
298
+ time + 60*60*24
299
+ end
300
+
301
+ def self.inc_hour
302
+ kime + 3600
303
+ end
6
304
  end
7
305
  end
8
306