Skip to content

Commit 979e51e

Browse files
committed
feat(indico-ingestor): extended ical ingestor to .ics and Indico
1 parent 559fce3 commit 979e51e

4 files changed

Lines changed: 239 additions & 95 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
module Ingestors
4+
module Concerns
5+
# Gets the proper URL to export an ics or ical
6+
module IcalIngestorExportUrl
7+
private
8+
9+
# 1. If the host includes 'indico', ensures the path ends with '/events.ics'.
10+
# 2. If the path already ends with '/events.ics', return as-is.
11+
# 3. Otherwise, append '?ical=true' query param if not already present.
12+
#
13+
# This method never mutates the original URL string.
14+
# Returns the updated URL string or nil if input is blank.
15+
def to_export(url)
16+
return nil if url.blank?
17+
18+
uri = URI.parse(url)
19+
path = uri.path.to_s
20+
21+
if uri.host&.include?('indico')
22+
ensure_events_ics_path(uri)
23+
elsif path.match?(%r{/(event|events)\.ics\z})
24+
uri.to_s
25+
else
26+
ensure_ical_query(uri)
27+
end
28+
end
29+
30+
# Ensures the Indico URL ends with '/events.ics'
31+
def ensure_events_ics_path(uri)
32+
if uri.path&.include?('event')
33+
uri.path = File.join(uri.path, 'event.ics') unless uri.path.end_with?('/event.ics')
34+
elsif uri.path&.include?('category')
35+
uri.path = File.join(uri.path, 'events.ics') unless uri.path.end_with?('/events.ics')
36+
end
37+
uri.to_s
38+
end
39+
40+
# Ensures the URL has '?ical=true' in its query params
41+
def ensure_ical_query(uri)
42+
query = URI.decode_www_form(uri.query.to_s).to_h
43+
query['ical'] = 'true' unless query['ical'] == 'true'
44+
uri.query = URI.encode_www_form(query)
45+
uri.to_s
46+
end
47+
end
48+
end
49+
end
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# frozen_string_literal: true
2+
3+
module Ingestors
4+
module Concerns
5+
# From a sitemap.{xml|txt} or a single URL, get the list of URLs (= sources)
6+
module SitemapHelpers
7+
private
8+
9+
# Reads either a sitemap.{xml|txt} or a single URL
10+
# Returns a list of URLs from 1 to n URLs
11+
def get_sources(source_url)
12+
case source_url.downcase
13+
when /sitemap(.*)?\.xml\Z/
14+
parse_xml_sitemap(source_url)
15+
when /sitemap(.*)?\.txt\Z/
16+
parse_txt_sitemap(source_url)
17+
else
18+
[source_url]
19+
end
20+
end
21+
22+
def parse_xml_sitemap(url)
23+
urls = SitemapParser.new(
24+
url,
25+
recurse: true,
26+
headers: { 'User-Agent' => config[:user_agent] }
27+
).to_a.uniq.map(&:strip)
28+
29+
log_sitemap('xml', url, urls.count)
30+
urls
31+
rescue StandardError => e
32+
@messages << "Extract from sitemap[#{url}] failed with: #{e.message}"
33+
nil
34+
end
35+
36+
def parse_txt_sitemap(url)
37+
urls = open_url(url).to_a.uniq.map(&:strip)
38+
39+
log_sitemap('txt', url, urls.count)
40+
urls
41+
end
42+
43+
def log_sitemap(ext, url, count)
44+
@messages << "Parsing .#{ext} sitemap: #{url}\n - #{count} URLs found"
45+
end
46+
end
47+
end
48+
end

lib/ingestors/ical_ingestor.rb

Lines changed: 89 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,130 +1,125 @@
1+
# frozen_string_literal: true
2+
13
require 'icalendar'
24
require 'nokogiri'
35
require 'open-uri'
46
require 'tzinfo'
57

68
module Ingestors
9+
# Reads from direct ical / .ics / Indico (event or category) URLs, .xml sitemaps, and .txt sitemaps.
710
class IcalIngestor < Ingestor
11+
include Ingestors::Concerns::SitemapHelpers
12+
include Ingestors::Concerns::IcalIngestorExportUrl
13+
814
def self.config
915
{
1016
key: 'ical',
11-
title: 'iCalendar',
17+
title: 'iCalendar / Indico / .ics File',
1218
category: :events
1319
}
1420
end
1521

16-
def read(url)
17-
unless url.nil?
18-
if url.to_s.downcase.end_with? 'sitemap.xml'
19-
process_sitemap url
20-
else
21-
process_icalendar url
22-
end
22+
def read(source_url)
23+
@verbose = false
24+
sources = get_sources(source_url)
25+
return if sources.nil?
26+
27+
sources.each do |url|
28+
process_url(url)
2329
end
2430
end
2531

2632
private
2733

28-
def process_sitemap(url)
29-
# find urls for individual icalendar files
30-
begin
31-
sitemap = Nokogiri::XML.parse(open_url(url, raise: true))
32-
locs = sitemap.xpath('/ns:urlset/ns:url/ns:loc', {
33-
'ns' => 'http://www.sitemaps.org/schemas/sitemap/0.9'
34-
})
35-
locs.each do |loc|
36-
process_icalendar(loc.text)
37-
end
38-
rescue Exception => e
39-
@messages << "Extract from sitemap[#{url}] failed with: #{e.message}"
34+
# Modifies the given URL to the ics or ical export.
35+
# Loops into each Ical event to process it.
36+
# Note: One .ics file can have multiple Ical events.
37+
def process_url(url)
38+
export_url = to_export(url)
39+
events = Icalendar::Event.parse(open_url(export_url, raise: true).set_encoding('utf-8'))
40+
events.each do |e|
41+
process_calevent(e)
4042
end
43+
rescue StandardError => e
44+
@messages << "Process file url[#{export_url}] failed with: #{e.message}"
45+
end
4146

42-
# finished
43-
nil
47+
# Builds the OpenStruct event and adds it in event.
48+
def process_calevent(calevent)
49+
event_to_add = OpenStruct.new.tap do |event|
50+
assign_basic_info(event, calevent)
51+
assign_time_info(event, calevent)
52+
assign_location_info(event, calevent.location)
53+
end
54+
add_event(event_to_add)
55+
rescue StandardError => e
56+
@messages << "Process iCalendar failed with: #{e.message}"
4457
end
4558

46-
def process_icalendar(url)
47-
# process individual ics file
48-
query = '?ical=true'
59+
# Assigns to event: url, title, description, keywords.
60+
def assign_basic_info(event, calevent)
61+
event.url = calevent.url.to_s
62+
event.title = calevent.summary.to_s
63+
event.description = process_description calevent.description
64+
event.keywords = process_keywords(calevent.categories)
65+
end
4966

50-
begin
51-
# append query (if required)
52-
file_url = url
53-
file_url << query unless url.to_s.downcase.ends_with? query
67+
# Assigns to event: start, end, timezone.
68+
def assign_time_info(event, calevent)
69+
event.start = calevent.dtstart&.to_time unless calevent.dtstart.nil?
70+
event.end = calevent.dtend&.to_time unless calevent.dtend.nil?
71+
event.timezone = get_tzid(calevent.dtstart)
72+
end
5473

55-
# process file
56-
events = Icalendar::Event.parse(open_url(file_url, raise: true).set_encoding('utf-8'))
74+
# Assigns to event: venue, online, city.
75+
def assign_location_info(event, location)
76+
return if location.blank? || !location.present?
5777

58-
# process each event
59-
events.each do |e|
60-
process_event(e)
61-
end
62-
rescue Exception => e
63-
@messages << "Process file url[#{file_url}] failed with: #{e.message}"
64-
end
78+
event.venue = location.to_s
79+
event.online = location.downcase.include?('online')
80+
event.city, event.postcode, event.country = process_location(location)
81+
end
6582

66-
# finished
67-
nil
83+
# Removes all `<br />` tags and converts HTML to MD.
84+
def process_description(input)
85+
return input if input.nil?
86+
87+
desc = input.to_s.gsub('', '<br />')
88+
convert_description(desc)
6889
end
6990

70-
def process_event(calevent)
71-
# puts "calevent: #{calevent.inspect}"
72-
begin
73-
# set fields
74-
event = OpenStruct.new
75-
event.url = calevent.url.to_s
76-
event.title = calevent.summary.to_s
77-
event.description = process_description calevent.description
78-
79-
# puts "\n\ncalevent.description = #{calevent.description}"
80-
# puts "\n\n... converted = #{event.description}"
81-
82-
event.end = calevent.dtend&.to_time
83-
unless calevent.dtstart.nil?
84-
dtstart = calevent.dtstart
85-
event.start = dtstart&.to_time
86-
tzid = dtstart.ical_params['tzid']
87-
event.timezone = tzid.first.to_s if !tzid.nil? and tzid.size > 0
88-
end
89-
90-
event.venue = calevent.location.to_s
91-
if calevent.location.downcase.include?('online')
92-
event.online = true
93-
event.city = nil
94-
event.postcode = nil
95-
event.country = nil
96-
else
97-
location = convert_location(calevent.location)
98-
event.city = location['suburb'] unless location['suburb'].nil?
99-
event.country = location['country'] unless location['country'].nil?
100-
event.postcode = location['postcode'] unless location['postcode'].nil?
101-
end
102-
event.keywords = []
103-
unless calevent.categories.nil? or calevent.categories.first.nil?
104-
cats = calevent.categories.first
105-
if cats.is_a?(Icalendar::Values::Array)
106-
cats.each do |item|
107-
event.keywords << item.to_s.lstrip
108-
end
109-
else
110-
event.keywords << cats.to_s.strip
111-
end
112-
end
113-
114-
# store event
115-
@events << event
116-
rescue Exception => e
117-
@messages << "Process iCalendar failed with: #{e.message}"
118-
end
91+
# Extracts the timezone identifier (TZID) from an iCalendar event's dtstart field.
92+
# Handles whether tzid shows up as an Array or a single string
93+
def get_tzid(dtstart)
94+
return nil unless dtstart.respond_to?(:ical_params)
11995

120-
# finished
121-
nil
96+
tzid = dtstart.ical_params['tzid']
97+
return nil if tzid.nil?
98+
99+
tzid.is_a?(Array) ? tzid.first.to_s : tzid.to_s
122100
end
123101

124-
def process_description(input)
125-
return input if input.nil?
102+
# Returns an array of 3 location characteristics: suburb, postcode, country
103+
# Everything is nil if location.blank or location is online
104+
def process_location(location)
105+
return [nil, nil, nil] if location.blank?
106+
107+
if location.to_s.downcase.include?('online')
108+
[nil, nil, nil]
109+
else
110+
[
111+
location['suburb'],
112+
location['postcode'],
113+
location['country']
114+
]
115+
end
116+
end
117+
118+
# Returns keywords from the `CATEGORIES` ICal field
119+
def process_keywords(categories)
120+
return [] if categories.blank?
126121

127-
convert_description(input.to_s.gsub(/\R/, '<br />'))
122+
categories.flatten.compact.map { |cat| cat.to_s.strip }
128123
end
129124
end
130125
end

test/unit/ingestors/ical_ingestor_test.rb

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ class IcalIngestorTest < ActiveSupport::TestCase
2626

2727
assert ingestor.events.empty?
2828
assert ingestor.materials.empty?
29-
assert_includes ingestor.messages, 'Extract from sitemap[https://missing.org/sitemap.xml] failed with: 404 '
29+
assert_includes ingestor.messages[0], 'Extract from sitemap[https://missing.org/sitemap.xml] failed with:'
3030
end
3131

3232
test 'ingest valid sitemap' do
@@ -187,6 +187,58 @@ class IcalIngestorTest < ActiveSupport::TestCase
187187
end
188188
end
189189

190+
test 'process_calevent logs error when exception is raised' do
191+
ingestor = Ingestors::IcalIngestor.new
192+
calevent = Object.new # fake calevent
193+
194+
# Stub a method that will raise an error
195+
def ingestor.assign_basic_info(*)
196+
raise StandardError, 'test failure'
197+
end
198+
199+
ingestor.send(:process_calevent, calevent)
200+
201+
assert_includes ingestor.messages.last, 'Process iCalendar failed with: test failure'
202+
end
203+
204+
test 'to_export method' do
205+
ingestor = Ingestors::IcalIngestor.new
206+
indico_url_event = 'https://indico.cern.ch/event/1588342/'
207+
indico_url_event_with_ics = 'https://indico.cern.ch/event/1588342/event.ics' # ! when '/event', event.ics is singular
208+
indico_url_event_with_query = 'https://indico.cern.ch/event/1588342/?somerandom=urlparams&an=otherone'
209+
indico_url_event_with_query_with_ics = 'https://indico.cern.ch/event/1588342/event.ics?somerandom=urlparams&an=otherone'
210+
indico_url_category = 'https://indico.cern.ch/category/19377/'
211+
indico_url_category_with_ics = 'https://indico.cern.ch/category/19377/events.ics' # ! when '/category', eventS.ics is plural
212+
indico_url_category_with_query = 'https://indico.cern.ch/category/19377/?a=b&c=d'
213+
indico_url_category_with_query_with_ics = 'https://indico.cern.ch/category/19377/events.ics?a=b&c=d'
214+
url_with_ics = 'https://mywebsite.com/event/blabla/events.ics'
215+
url_with_query_with_ics = 'https://mywebsite.com/event/blabla/events.ics?john=doe&isstub=born'
216+
url_no_ical = 'https://mywebsite.com/event/blabla'
217+
url_with_ical = 'https://mywebsite.com/event/blabla?ical=true'
218+
219+
# When indico link – event
220+
assert_equal ingestor.send(:to_export, indico_url_event), indico_url_event_with_ics # adds ics
221+
assert_equal ingestor.send(:to_export, indico_url_event_with_query), indico_url_event_with_query_with_ics # adds ics
222+
223+
# When indico link – category
224+
assert_equal ingestor.send(:to_export, indico_url_category), indico_url_category_with_ics # adds ics
225+
assert_equal ingestor.send(:to_export, indico_url_category_with_query), indico_url_category_with_query_with_ics # adds ics
226+
227+
# When non-indico link
228+
assert_equal ingestor.send(:to_export, url_with_ics), url_with_ics # keeps same
229+
assert_equal ingestor.send(:to_export, url_with_query_with_ics), url_with_query_with_ics # keeps same
230+
231+
# When indico link which already has the /events.ics
232+
assert_equal ingestor.send(:to_export, indico_url_event_with_ics), indico_url_event_with_ics # keeps it as-is
233+
assert_equal ingestor.send(:to_export, indico_url_event_with_query_with_ics), indico_url_event_with_query_with_ics # keeps it as-is
234+
235+
# When other url, adds the ical query param
236+
assert_equal ingestor.send(:to_export, url_no_ical), url_with_ical
237+
238+
# When other url with ical query param, keep it as-is
239+
assert_equal ingestor.send(:to_export, url_with_ical), url_with_ical
240+
end
241+
190242
private
191243

192244
def check_event_exists(title, url)

0 commit comments

Comments
 (0)