Skip to content

Commit af243e3

Browse files
committed
review(#1161): Indico ingestor as a separate class
1 parent 5b9ab3f commit af243e3

3 files changed

Lines changed: 416 additions & 0 deletions

File tree

lib/ingestors/indico_ingestor.rb

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# frozen_string_literal: true
2+
3+
require 'icalendar'
4+
require 'nokogiri'
5+
require 'open-uri'
6+
require 'tzinfo'
7+
8+
module Ingestors
9+
# Reads from direct .ics or Indico (event or category) URLs, .xml sitemaps, and .txt sitemaps.
10+
class IndicoIngestor < Ingestor
11+
include Ingestors::Concerns::SitemapHelpers
12+
13+
def self.config
14+
{
15+
key: 'indico',
16+
title: 'Indico / .ics file',
17+
category: :events
18+
}
19+
end
20+
21+
def read(source_url)
22+
@token = Rails.application.config.secrets.indico_api_token
23+
@verbose = false
24+
sources = get_sources(source_url)
25+
return if sources.nil?
26+
27+
sources.each do |url|
28+
process_url(url)
29+
end
30+
end
31+
32+
private
33+
34+
# Modifies the given URL to the ics 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+
raise 'Not an indico link' if export_url.nil?
40+
41+
content = open_url(export_url, token: @token, raise: true).set_encoding('utf-8')
42+
events = Icalendar::Event.parse(content)
43+
raise 'Not found' if events.nil? || events.empty?
44+
45+
events.each do |e|
46+
process_calevent(e)
47+
end
48+
rescue StandardError => e
49+
@messages << "Process file url[#{export_url}] failed with: #{e.message}"
50+
end
51+
52+
# 1. If the path already ends with '/events.ics', return as-is.
53+
# 2. If the host includes 'indico', ensures the path ends with '/events.ics'.
54+
# 3. Otherwise, append '?ical=true' query param if not already present.
55+
#
56+
# This method never mutates the original URL string.
57+
# Returns the updated URL string or nil if input is blank.
58+
def to_export(url)
59+
return nil if url.blank?
60+
61+
uri = URI.parse(url)
62+
path = uri.path.to_s
63+
64+
if path.match?(%r{/(event|events)\.ics\z})
65+
uri.to_s
66+
elsif indico_page?(uri)
67+
ensure_events_ics_path(uri)
68+
else
69+
nil
70+
end
71+
end
72+
73+
def indico_page?(uri)
74+
# Either checks in host, e.g., 'indico.myinstitution.com'
75+
return true if uri.host&.include?('indico')
76+
77+
# Or checks in meta tags
78+
html = open_url(uri, raise: true)
79+
doc = Nokogiri::HTML(html)
80+
content = doc.at('meta[property="og:site_name"]')&.[]('content')
81+
content&.match?(/indico/i)
82+
end
83+
84+
# Ensures the Indico URL ends with '/events.ics'
85+
def ensure_events_ics_path(uri)
86+
paths = uri.path.split('/')
87+
uri.path = "#{paths[0..2].join('/')}/"
88+
if paths[1] == 'event'
89+
uri.path = File.join(uri.path, 'event.ics')
90+
elsif paths[1] == 'category'
91+
uri.path = File.join(uri.path, 'events.ics')
92+
end
93+
uri.to_s
94+
end
95+
96+
# Ensures the URL has '?ical=true' in its query params
97+
def ensure_ical_query(uri)
98+
query = URI.decode_www_form(uri.query.to_s).to_h
99+
query['ical'] = 'true' unless query['ical'] == 'true'
100+
uri.query = URI.encode_www_form(query)
101+
uri.to_s
102+
end
103+
104+
# Builds the OpenStruct event and adds it in event.
105+
def process_calevent(calevent)
106+
event_to_add = OpenStruct.new.tap do |event|
107+
assign_basic_info(event, calevent)
108+
assign_time_info(event, calevent)
109+
assign_location_info(event, calevent.location)
110+
end
111+
add_event(event_to_add)
112+
rescue StandardError => e
113+
@messages << "Process iCalendar failed with: #{e.message}"
114+
end
115+
116+
# Assigns to event: url, title, description, keywords.
117+
def assign_basic_info(event, calevent)
118+
event.url = calevent.url.to_s
119+
event.title = calevent.summary.to_s
120+
event.description = calevent.description.to_s
121+
event.keywords = process_keywords(calevent.categories)
122+
event.contact = calevent.contact.join(', ')
123+
end
124+
125+
# Assigns to event: start, end, timezone.
126+
def assign_time_info(event, calevent)
127+
event.start = calevent.dtstart&.to_time unless calevent.dtstart.nil?
128+
event.end = calevent.dtend&.to_time unless calevent.dtend.nil?
129+
event.timezone = get_tzid(calevent.dtstart)
130+
end
131+
132+
# Assigns to event: venue, online, city.
133+
def assign_location_info(event, location)
134+
return if location.blank?
135+
136+
event.venue = location.to_s
137+
event.online = location.downcase.include?('online')
138+
event.city, event.postcode, event.country = process_location(location)
139+
end
140+
141+
# Extracts the timezone identifier (TZID) from an iCalendar event's dtstart field.
142+
# Handles whether tzid shows up as an Array or a single string
143+
def get_tzid(dtstart)
144+
return nil unless dtstart.respond_to?(:ical_params)
145+
146+
tzid = dtstart.ical_params['tzid']
147+
return nil if tzid.nil?
148+
149+
tzid.is_a?(Array) ? tzid.first.to_s : tzid.to_s
150+
end
151+
152+
# Returns an array of 3 location characteristics: suburb, postcode, country
153+
# Everything is nil if location.blank or location is online
154+
def process_location(location)
155+
return [location['suburb'], location['postcode'], location['country']] if location.is_a?(Array)
156+
157+
[nil, nil, nil]
158+
end
159+
160+
# Returns keywords from the `CATEGORIES` ICal field
161+
def process_keywords(categories)
162+
return [] if categories.blank?
163+
164+
categories.flatten.compact.map { |cat| cat.to_s.strip }
165+
end
166+
end
167+
end

lib/ingestors/ingestor_factory.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ def self.ingestors
66
Ingestors::EventbriteIngestor,
77
Ingestors::EventCsvIngestor,
88
Ingestors::IcalIngestor,
9+
Ingestors::IndicoIngestor,
910
Ingestors::LibcalIngestor,
1011
Ingestors::MaterialCsvIngestor,
1112
Ingestors::TessEventIngestor,

0 commit comments

Comments
 (0)