Skip to content

Commit 3d70654

Browse files
authored
Merge pull request #1161 from kennethrioja/indico
[ingestor] Extended ical ingestor to .ics and Indico
2 parents 7075be6 + 6290e70 commit 3d70654

10 files changed

Lines changed: 369 additions & 2 deletions

File tree

app/controllers/events_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ def event_params
237237
:timezone, :content_provider_id, { collection_ids: [] }, { node_ids: [] },
238238
{ node_names: [] }, { target_audience: [] }, { eligibility: [] }, :visible,
239239
{ host_institutions: [] }, :capacity, :contact, :recognition, :learning_objectives,
240-
:prerequisites, :tech_requirements, :cost_basis, :cost_value, :cost_currency, :language,
240+
:prerequisites, :tech_requirements, :cost_basis, :cost_value, :cost_currency, :language, :presence,
241241
external_resources_attributes: %i[id url title _destroy],
242242
external_resources: %i[url title], material_ids: [],
243243
llm_interaction_attributes: %i[id scrape_or_process model prompt input output needs_processing _destroy],

config/secrets.example.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ external_api_keys: &external_api_keys
3838
password:
3939
gpt_api_key:
4040
willma_api_key:
41+
indico_api_token: # begins by 'indp_', cf. https://docs.getindico.io/en/stable/http-api/access/#api-token-authentication
4142
orcid:
4243
client_id:
4344
secret:
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 parse_sitemap(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/indico_ingestor.rb

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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 = parse_sitemap(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 event to process it.
36+
# Note: One .ics file can have multiple 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, raise: true, token: @token).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+
# This method never mutates the original URL string.
55+
# Returns the updated URL string or nil if input is blank.
56+
def to_export(url)
57+
return nil if url.blank?
58+
59+
uri = URI.parse(url)
60+
path = uri.path.to_s
61+
62+
if path.match?(%r{/(event|events)\.ics\z})
63+
uri.to_s
64+
elsif indico_page?(uri)
65+
ensure_events_ics_path(uri)
66+
end
67+
end
68+
69+
def indico_page?(uri)
70+
# Either checks in host, e.g., 'indico.myinstitution.com'
71+
return true if uri.host&.include?('indico')
72+
73+
# Or checks in meta tags
74+
html = open_url(uri, raise: true)
75+
doc = Nokogiri::HTML(html)
76+
content = doc.at('meta[property="og:site_name"]')&.[]('content')
77+
content&.match?(/indico/i)
78+
end
79+
80+
# Ensures the Indico URL ends with '/events.ics'
81+
def ensure_events_ics_path(uri)
82+
paths = uri.path.split('/')
83+
uri.path = "#{paths[0..2].join('/')}/"
84+
if paths[1] == 'event'
85+
uri.path = File.join(uri.path, 'event.ics')
86+
elsif paths[1] == 'category'
87+
uri.path = File.join(uri.path, 'events.ics')
88+
end
89+
uri.to_s
90+
end
91+
92+
# Builds the OpenStruct event and adds it in event.
93+
def process_calevent(calevent)
94+
event_to_add = OpenStruct.new.tap do |event|
95+
assign_basic_info(event, calevent)
96+
assign_time_info(event, calevent)
97+
assign_location_info(event, calevent)
98+
end
99+
add_event(event_to_add)
100+
rescue StandardError => e
101+
Rails.logger.error("#{e.class}: #{e.message}")
102+
end
103+
104+
# Assigns to event: url, title, description, keywords.
105+
def assign_basic_info(event, calevent)
106+
event.url = calevent.url.to_s
107+
event.title = calevent.summary.to_s
108+
event.description = calevent.description.to_s
109+
event.keywords = calevent.categories.flatten
110+
event.contact = calevent.contact.join(', ')
111+
end
112+
113+
# Assigns to event: start, end, timezone.
114+
def assign_time_info(event, calevent)
115+
event.start = calevent.dtstart&.to_time unless calevent.dtstart.nil?
116+
event.end = calevent.dtend&.to_time unless calevent.dtend.nil?
117+
event.timezone = get_tzid(calevent.dtstart)
118+
end
119+
120+
# Assigns to event: venue, online, city.
121+
def assign_location_info(event, calevent)
122+
location = calevent.location
123+
return if location.blank?
124+
125+
event.venue = location.to_s
126+
event.online = calevent.description.include?('zoom')
127+
event.presence = calevent.description.include?('zoom') ? :hybrid : :onsite # can do best, but sufficient for now
128+
end
129+
130+
# Extracts the timezone identifier (TZID) from an iCalendar event's dtstart field.
131+
# Handles whether tzid shows up as an Array or a single string
132+
def get_tzid(dtstart)
133+
return nil unless dtstart.respond_to?(:ical_params)
134+
135+
tzid = dtstart.ical_params['tzid']
136+
return nil if tzid.nil?
137+
138+
tzid.is_a?(Array) ? tzid.first.to_s : tzid.to_s
139+
end
140+
end
141+
end

lib/ingestors/ingestor.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,14 @@ def stats_summary(type)
5050
summary
5151
end
5252

53-
def open_url(url, raise: false)
53+
def open_url(url, raise: false, token: nil)
5454
options = {
5555
redirect: false, # We're doing redirects manually below, since open-uri can't handle http -> https redirection
5656
read_timeout: 5
5757
}
5858
options[:ssl_verify_mode] = config[:ssl_verify_mode] if config.key?(:ssl_verify_mode)
5959
redirect_attempts = 5
60+
options['Authorization'] = "Bearer #{token}" unless token.nil?
6061
begin
6162
URI(url).open(options)
6263
rescue OpenURI::HTTPRedirect => e

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,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//CERN//INDICO//EN
4+
BEGIN:VEVENT
5+
SUMMARY:14th HEP C++ Course and Hands-on Training - The Essentials
6+
DTSTART:20260309T080000Z
7+
DTEND:20260313T161500Z
8+
DTSTAMP:20251204T105300Z
9+
UID:indico-event-1617123@indico.cern.ch
10+
CONTACT:name.surname@test.com
11+
DESCRIPTION:speakers and zoom here
12+
LOCATION:CERN
13+
URL:https://indico.cern.ch/event/1617123/
14+
CATEGORIES:TRAINING,EDUCATION
15+
END:VEVENT
16+
END:VCALENDAR
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
BEGIN:VCALENDAR
2+
VERSION:2.0
3+
PRODID:-//CERN//INDICO//EN
4+
BEGIN:VEVENT
5+
SUMMARY:14th HEP C++ Course and Hands-on Training - The Essentials
6+
DTSTART:20260309T080000Z
7+
DTEND:20260313T161500Z
8+
DTSTAMP:20251203T150800Z
9+
UID:indico-event-1617123@indico.cern.ch
10+
CONTACT:name.surname@test.com
11+
DESCRIPTION:speakers and zoom here
12+
LOCATION:CERN
13+
URL:https://indico.cern.ch/event/1617123/
14+
END:VEVENT
15+
BEGIN:VEVENT
16+
SUMMARY:HEP C++ Course and Hands-on Training - Stay Informed
17+
DTSTART:20991231T225800Z
18+
DTEND:20991231T225900Z
19+
DTSTAMP:20251203T150800Z
20+
UID:indico-event-1211412@indico.cern.ch
21+
CONTACT:name.surname@test.com
22+
DESCRIPTION:mockdescription
23+
URL:https://indico.cern.ch/event/1211412/
24+
END:VEVENT
25+
END:VCALENDAR
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<!DOCTYPE html>
2+
3+
<html lang="en"
4+
data-canonical-locale="en-US"
5+
data-static-site="false">
6+
<head>
7+
<title>My Agenda (Indico)</title>
8+
<meta charset="UTF-8">
9+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
10+
<link rel="shortcut icon" type="image/x-icon" href="/images/indico.ico">
11+
12+
<meta property="og:site_name" content="Agenda (Indico)">
13+
<meta property="og:image" content="https://agenda.com/images/indico_square.png">
14+
15+
</head>
16+
</html>

0 commit comments

Comments
 (0)