|
| 1 | +#!/usr/bin/env ruby |
| 2 | +# frozen_string_literal: true |
| 3 | + |
| 4 | +require "date" |
| 5 | +require "fileutils" |
| 6 | +require "yaml" |
| 7 | + |
| 8 | +ROOT = File.expand_path("..", __dir__) |
| 9 | +YEAR = 2026 |
| 10 | +TIMEZONE = "Europe/Paris" |
| 11 | +OUTPUT_PATH = File.join(ROOT, "downloads", "OSRschedule.ics") |
| 12 | +SCHEDULE_PATH = File.join(ROOT, "_data", "osr_schedule.yml") |
| 13 | +SESSIONS_PATH = File.join(ROOT, "_data", "osr_sessions.yml") |
| 14 | +CONFIG_PATH = File.join(ROOT, "_config.yml") |
| 15 | +DTSTAMP = "20260514T000000Z" |
| 16 | + |
| 17 | +def read_yaml(path) |
| 18 | + YAML.safe_load(File.read(path), aliases: true) |
| 19 | +end |
| 20 | + |
| 21 | +def config_url(config) |
| 22 | + site_url = config.fetch("url") |
| 23 | + baseurl = config.fetch("baseurl", "") |
| 24 | + "#{site_url}#{baseurl}" |
| 25 | +end |
| 26 | + |
| 27 | +def event_title(event) |
| 28 | + case event["category"] |
| 29 | + when "panel" |
| 30 | + "Panel #{event.fetch("number")}: #{event.fetch("title")}" |
| 31 | + when "tabletalk" |
| 32 | + "Table Talk #{event.fetch("number")}: #{event.fetch("title")}" |
| 33 | + else |
| 34 | + event.fetch("title") |
| 35 | + end |
| 36 | +end |
| 37 | + |
| 38 | +def local_datetime(day_id, time_text) |
| 39 | + month, day = day_id.split("-").map(&:to_i) |
| 40 | + hour, minute = time_text.split(":").map(&:to_i) |
| 41 | + raise ArgumentError, "Invalid day id: #{day_id.inspect}" unless month && day |
| 42 | + raise ArgumentError, "Invalid time: #{time_text.inspect}" unless hour && minute |
| 43 | + |
| 44 | + DateTime.new(YEAR, month, day, hour, minute, 0) |
| 45 | +end |
| 46 | + |
| 47 | +def ics_datetime(datetime) |
| 48 | + datetime.strftime("%Y%m%dT%H%M%S") |
| 49 | +end |
| 50 | + |
| 51 | +def gcal_datetime(datetime) |
| 52 | + datetime.strftime("%Y%m%dT%H%M%S") |
| 53 | +end |
| 54 | + |
| 55 | +def escape_text(value) |
| 56 | + value.to_s |
| 57 | + .gsub("\\", "\\\\\\") |
| 58 | + .gsub("\n", "\\n") |
| 59 | + .gsub(";", "\\;") |
| 60 | + .gsub(",", "\\,") |
| 61 | +end |
| 62 | + |
| 63 | +def fold_line(line) |
| 64 | + bytes = line.b |
| 65 | + return line if bytes.bytesize <= 75 |
| 66 | + |
| 67 | + folded = bytes.byteslice(0, 75) |
| 68 | + bytes = bytes.byteslice(75..)&.dup || +"" |
| 69 | + |
| 70 | + until bytes.empty? |
| 71 | + chunk = bytes.byteslice(0, 74) |
| 72 | + folded << "\r\n " << chunk |
| 73 | + bytes = bytes.byteslice(74..)&.dup || +"" |
| 74 | + end |
| 75 | + folded |
| 76 | +end |
| 77 | + |
| 78 | +def property(name, value) |
| 79 | + fold_line("#{name}:#{value}") |
| 80 | +end |
| 81 | + |
| 82 | +def event_url(site_base, event) |
| 83 | + path = event.fetch("page_url") |
| 84 | + |
| 85 | + if path.include?("://") |
| 86 | + path |
| 87 | + elsif event["anchor"] |
| 88 | + "#{site_base}#{path}##{event.fetch("anchor")}" |
| 89 | + else |
| 90 | + "#{site_base}#{path}" |
| 91 | + end |
| 92 | +end |
| 93 | + |
| 94 | +schedule = read_yaml(SCHEDULE_PATH) |
| 95 | +sessions = read_yaml(SESSIONS_PATH).each_with_object({}) do |session, index| |
| 96 | + index[session.fetch("id")] = session |
| 97 | +end |
| 98 | +site_base = config_url(read_yaml(CONFIG_PATH)) |
| 99 | + |
| 100 | +lines = [ |
| 101 | + "BEGIN:VCALENDAR", |
| 102 | + "VERSION:2.0", |
| 103 | + "PRODID:-//OHBM//OSR 2026//EN", |
| 104 | + "CALSCALE:GREGORIAN", |
| 105 | + "METHOD:PUBLISH", |
| 106 | + property("X-WR-CALNAME", escape_text("OHBM Open Science Room 2026")), |
| 107 | + property("X-WR-TIMEZONE", TIMEZONE), |
| 108 | + "BEGIN:VTIMEZONE", |
| 109 | + "TZID:Europe/Paris", |
| 110 | + "BEGIN:DAYLIGHT", |
| 111 | + "TZOFFSETFROM:+0100", |
| 112 | + "TZOFFSETTO:+0200", |
| 113 | + "TZNAME:CEST", |
| 114 | + "DTSTART:19700329T020000", |
| 115 | + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU", |
| 116 | + "END:DAYLIGHT", |
| 117 | + "BEGIN:STANDARD", |
| 118 | + "TZOFFSETFROM:+0200", |
| 119 | + "TZOFFSETTO:+0100", |
| 120 | + "TZNAME:CET", |
| 121 | + "DTSTART:19701025T030000", |
| 122 | + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU", |
| 123 | + "END:STANDARD", |
| 124 | + "END:VTIMEZONE" |
| 125 | +] |
| 126 | + |
| 127 | +schedule.fetch("days").each do |day| |
| 128 | + day.fetch("entries").each do |entry| |
| 129 | + event = if entry["session_id"] |
| 130 | + sessions.fetch(entry.fetch("session_id")) do |
| 131 | + raise KeyError, "No session found for #{entry.fetch("session_id").inspect}" |
| 132 | + end |
| 133 | + else |
| 134 | + entry |
| 135 | + end |
| 136 | + |
| 137 | + start_at = local_datetime(day.fetch("id"), event.fetch("start_time")) |
| 138 | + end_at = local_datetime(day.fetch("id"), event.fetch("end_time")) |
| 139 | + raise ArgumentError, "Event ends before it starts: #{event.fetch("id")}" unless end_at > start_at |
| 140 | + if event["gcal_start"] && event["gcal_start"] != gcal_datetime(start_at) |
| 141 | + raise ArgumentError, "gcal_start does not match schedule time for #{event.fetch("id")}" |
| 142 | + end |
| 143 | + if event["gcal_end"] && event["gcal_end"] != gcal_datetime(end_at) |
| 144 | + raise ArgumentError, "gcal_end does not match schedule time for #{event.fetch("id")}" |
| 145 | + end |
| 146 | + |
| 147 | + uid = "osr2026-#{event.fetch("id")}@ohbm.github.io" |
| 148 | + url = event_url(site_base, event) |
| 149 | + description = "OHBM Open Science Room 2026 session. Full schedule: #{site_base}/schedule/" |
| 150 | + |
| 151 | + lines.concat( |
| 152 | + [ |
| 153 | + "BEGIN:VEVENT", |
| 154 | + property("UID", uid), |
| 155 | + property("DTSTAMP", DTSTAMP), |
| 156 | + property("DTSTART;TZID=#{TIMEZONE}", ics_datetime(start_at)), |
| 157 | + property("DTEND;TZID=#{TIMEZONE}", ics_datetime(end_at)), |
| 158 | + property("SUMMARY", escape_text(event_title(event))), |
| 159 | + property("DESCRIPTION", escape_text(description)), |
| 160 | + property("LOCATION", escape_text("OHBM Open Science Room")), |
| 161 | + property("URL", url), |
| 162 | + "END:VEVENT" |
| 163 | + ] |
| 164 | + ) |
| 165 | + end |
| 166 | +end |
| 167 | + |
| 168 | +lines << "END:VCALENDAR" |
| 169 | + |
| 170 | +FileUtils.mkdir_p(File.dirname(OUTPUT_PATH)) |
| 171 | +File.write(OUTPUT_PATH, "#{lines.join("\r\n")}\r\n") |
| 172 | + |
| 173 | +puts "Wrote #{OUTPUT_PATH}" |
0 commit comments