ess 0.9.3 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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