UIUC Banner Registration → Export ICS

Export UIUC course schedule from Registration History to an .ics calendar file.

// ==UserScript==
// @name         UIUC Banner Registration → Export ICS
// @namespace    https://yingzifan.me/
// @version      1.1.0
// @description  Export UIUC course schedule from Registration History to an .ics calendar file.
// @author       you
// @match        https://banner.apps.uillinois.edu/StudentRegistrationSSB/ssb/registrationHistory/registrationHistory
// @run-at       document-idle
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  // -------------------- Constants --------------------
  const TZID = "America/Chicago"; // UIUC timezone
  const DAY_MAP = {
    Sunday: "SU",
    Monday: "MO",
    Tuesday: "TU",
    Wednesday: "WE",
    Thursday: "TH",
    Friday: "FR",
    Saturday: "SA",
  };
  const NAME_TO_NUM = {
    Sunday: 0,
    Monday: 1,
    Tuesday: 2,
    Wednesday: 3,
    Thursday: 4,
    Friday: 5,
    Saturday: 6,
  };

  // -------------------- Helpers --------------------
  const pad = (n) => String(n).padStart(2, "0");

  function parseMDY(s) {
    // "08/25/2025" -> Date (local)
    const m = s.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/);
    if (!m) return null;
    const [_, mm, dd, yyyy] = m;
    return new Date(Number(yyyy), Number(mm) - 1, Number(dd));
  }

  function parseTimeRange(text) {
    // "09:30 AM - 10:45 AM" -> {start:{h,m}, end:{h,m}}
    const m = text.match(
      /(\d{1,2})\s*:\s*(\d{2})\s*(AM|PM)\s*-\s*(\d{1,2})\s*:\s*(\d{2})\s*(AM|PM)/i
    );
    if (!m) return null;
    const to24 = (h12, ampm) => {
      let h = Number(h12) % 12;
      if (/PM/i.test(ampm)) h += 12;
      return h;
    };
    return {
      start: { h: to24(m[1], m[3]), m: Number(m[2]) },
      end: { h: to24(m[4], m[6]), m: Number(m[5]) },
    };
  }

  function formatICSDateLocal(dateObj, h, m) {
    // Local time (no trailing Z), used with TZID
    const dt = new Date(
      dateObj.getFullYear(),
      dateObj.getMonth(),
      dateObj.getDate(),
      h,
      m,
      0
    );
    return (
      dt.getFullYear().toString() +
      pad(dt.getMonth() + 1) +
      pad(dt.getDate()) +
      "T" +
      pad(dt.getHours()) +
      pad(dt.getMinutes()) +
      "00"
    );
  }

  function lastDateWithTimeLocal(dateObj) {
    // 23:59:59 local — used for RRULE UNTIL
    const dt = new Date(
      dateObj.getFullYear(),
      dateObj.getMonth(),
      dateObj.getDate(),
      23,
      59,
      59
    );
    return (
      dt.getFullYear().toString() +
      pad(dt.getMonth() + 1) +
      pad(dt.getDate()) +
      "T" +
      pad(dt.getHours()) +
      pad(dt.getMinutes()) +
      pad(dt.getSeconds())
    );
  }

  function escapeICS(text) {
    if (!text) return "";
    return text
      .replace(/\\/g, "\\\\")
      .replace(/\n/g, "\\n")
      .replace(/,/g, "\\,")
      .replace(/;/g, "\\;");
  }

  function foldLine(s) {
    // Simple 74-char folding
    const out = [];
    for (let i = 0; i < s.length; i += 74) {
      out.push(i === 0 ? s.slice(i, i + 74) : " " + s.slice(i, i + 74));
    }
    return out.join("\r\n");
  }

  function download(filename, text) {
    const blob = new Blob([text], { type: "text/calendar;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => {
      URL.revokeObjectURL(url);
      a.remove();
    }, 0);
  }

  function firstOccurrenceOnOrAfter(startDate, allowedWeekdayNums) {
    // Return the first date >= startDate whose weekday is in allowedWeekdayNums
    const d = new Date(startDate.getTime());
    for (let i = 0; i < 7; i++) {
      if (allowedWeekdayNums.has(d.getDay())) return d;
      d.setDate(d.getDate() + 1);
    }
    return startDate; // fallback (shouldn't happen)
  }

  // -------------------- Parse one course block --------------------
  function parseCourse(wrapper) {
    try {
      const titleA = wrapper.querySelector(
        ".list-view-course-title a.section-details-link"
      );
      const title = titleA
        ? titleA.textContent.trim()
        : (wrapper.querySelector(".list-view-course-title")?.textContent || "")
            .trim();

      const subjSec = (
        wrapper.querySelector(".list-view-subj-course-section")?.textContent ||
        ""
      ).trim();

      // Dates
      const dateSpan = wrapper.querySelector(
        ".listViewMeetingInformation .meetingTimes"
      );
      const dateText = dateSpan ? dateSpan.textContent.trim() : "";
      const [startStr, endStr] = dateText
        .split("--")
        .map((s) => s && s.trim());
      const termStart = parseMDY(startStr || "");
      const termEnd = parseMDY(endStr || "");

      // Days of week
      const dayLis = wrapper.querySelectorAll(
        ".ui-pillbox ul li[aria-checked='true']"
      );
      const bydayList = Array.from(dayLis)
        .map((li) => li.getAttribute("data-name"))
        .filter(Boolean);
      const bydayICS = bydayList
        .map((name) => DAY_MAP[name] || "")
        .filter(Boolean)
        .join(",");

      const bydayNums = new Set(
        bydayList.map((name) => NAME_TO_NUM[name]).filter((x) => x != null)
      );

      // Time range
      const meet = wrapper.querySelector(".listViewMeetingInformation");
      const timeMatch = parseTimeRange(meet ? meet.textContent : "");
      if (!timeMatch) return null;

      // Location (Campus / Building / Room)
      const raw = meet ? meet.textContent : "";
      const locMatch = raw.match(
        /Location:\s*([^\|]+?)\s*Building:\s*([^\|]+?)\s*Room:\s*([^\|\n]+)\b/i
      );
      const campusLoc = locMatch ? locMatch[1].trim() : "";
      const building = locMatch ? locMatch[2].trim() : "";
      const room = locMatch ? locMatch[3].trim() : "";
      const location = [campusLoc, building, room].filter(Boolean).join(", ");

      // Instructor & CRN
      const instructor = (
        wrapper.querySelector(".listViewInstructorInformation a.email")
          ?.textContent || ""
      ).trim();
      const crn = (
        wrapper.querySelector(
          ".listViewInstructorInformation .list-view-crn-schedule"
        )?.textContent || ""
      ).trim();

      // --- IMPORTANT BUG FIX ---
      // DTSTART must be the actual first meeting date (matching BYDAY),
      // not the term start date. Otherwise, some calendar apps will add
      // an extra occurrence on the term start date (e.g., Monday).
      const firstDate = firstOccurrenceOnOrAfter(termStart, bydayNums);

      const dtstartLocal = formatICSDateLocal(
        firstDate,
        timeMatch.start.h,
        timeMatch.start.m
      );
      const dtendLocal = formatICSDateLocal(
        firstDate,
        timeMatch.end.h,
        timeMatch.end.m
      );
      const untilLocal = lastDateWithTimeLocal(termEnd); // local UNTIL

      const titleFull = subjSec ? `${title} | ${subjSec}` : title;
      const description = `CRN: ${crn}\nInstructor: ${instructor}\nFrom ${startStr} to ${endStr}\nGenerated by UIUC Banner exporter`;

      return {
        SUMMARY: titleFull,
        DESCRIPTION: description,
        LOCATION: location,
        DTSTART: dtstartLocal,
        DTEND: dtendLocal,
        BYDAY: bydayICS,
        UNTIL: untilLocal,
      };
    } catch (e) {
      console.warn("parseCourse error", e);
      return null;
    }
  }

  // -------------------- Build ICS --------------------
  function buildICS(events) {
    const lines = [];
    lines.push("BEGIN:VCALENDAR");
    lines.push("PRODID:-//UIUC Banner Exporter//EN");
    lines.push("VERSION:2.0");
    lines.push("CALSCALE:GREGORIAN");
    lines.push("METHOD:PUBLISH");
    lines.push(`X-WR-CALNAME:UIUC Courses`);
    lines.push(`X-WR-TIMEZONE:${TZID}`);
    // Not embedding VTIMEZONE. Most clients understand common TZIDs.

    const stamp = new Date();
    const dtstamp =
      stamp.getUTCFullYear().toString() +
      pad(stamp.getUTCMonth() + 1) +
      pad(stamp.getUTCDate()) +
      "T" +
      pad(stamp.getUTCHours()) +
      pad(stamp.getUTCMinutes()) +
      pad(stamp.getUTCSeconds()) +
      "Z";

    for (const e of events) {
      const uid = `${Math.random().toString(36).slice(2)}-${Date.now()}@uiuc-banner`;
      lines.push("BEGIN:VEVENT");
      lines.push(`UID:${uid}`);
      lines.push(`DTSTAMP:${dtstamp}`);
      lines.push(`SUMMARY:${escapeICS(e.SUMMARY)}`);
      if (e.DESCRIPTION)
        lines.push(...foldLine(`DESCRIPTION:${escapeICS(e.DESCRIPTION)}`).split("\r\n"));
      if (e.LOCATION) lines.push(`LOCATION:${escapeICS(e.LOCATION)}`);
      lines.push(`DTSTART;TZID=${TZID}:${e.DTSTART}`);
      lines.push(`DTEND;TZID=${TZID}:${e.DTEND}`);
      if (e.BYDAY) {
        lines.push(`RRULE:FREQ=WEEKLY;BYDAY=${e.BYDAY};UNTIL=${e.UNTIL}`);
      }
      lines.push("END:VEVENT");
    }

    lines.push("END:VCALENDAR");
    return lines.join("\r\n");
  }

  // -------------------- UI --------------------
  function commonBtnStyle() {
    return {
      font: "12px system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
      padding: "6px 10px",
      border: "1px solid #357edd",
      background: "#4f8ef7",
      color: "#fff",
      borderRadius: "6px",
      cursor: "pointer",
      boxShadow: "0 1px 2px rgba(0,0,0,0.15)",
    };
  }

  function insertButtons() {
    const bar = document.createElement("div");
    bar.style.position = "fixed";
    bar.style.top = "12px";
    bar.style.right = "12px";
    bar.style.zIndex = "99999";
    bar.style.display = "flex";
    bar.style.gap = "8px";

    const btnExport = document.createElement("button");
    btnExport.textContent = "Export .ics (All)";
    Object.assign(btnExport.style, commonBtnStyle());

    const btnHelp = document.createElement("button");
    btnHelp.textContent = "ℹ︎";
    Object.assign(btnHelp.style, commonBtnStyle());
    btnHelp.title = "Export all parsed courses on this page into a single .ics file.";

    bar.appendChild(btnExport);
    bar.appendChild(btnHelp);
    document.body.appendChild(bar);

    btnExport.addEventListener("click", onExportICS);
  }

  // -------------------- Actions --------------------
  function collectAllCourses() {
    const wrappers = document.querySelectorAll(
      "#scheduleListView .listViewWrapper"
    );
    const events = [];
    wrappers.forEach((w) => {
      const evt = parseCourse(w);
      if (evt) events.push(evt);
    });
    return events;
  }

  function onExportICS() {
    const events = collectAllCourses();
    if (!events.length) {
      alert(
        "No courses were parsed. Make sure you are on the Registration History page and the list has loaded."
      );
      return;
    }
    const ics = buildICS(events);
    download("UIUC_Courses.ics", ics);
  }

  // -------------------- Init --------------------
  function ready(fn) {
    if (
      document.readyState === "complete" ||
      document.readyState === "interactive"
    ) {
      setTimeout(fn, 0);
    } else {
      document.addEventListener("DOMContentLoaded", fn);
    }
  }

  ready(() => {
    const tryInit = () => {
      const root = document.querySelector("#scheduleListView");
      if (
        root &&
        document.querySelectorAll("#scheduleListView .listViewWrapper").length
      ) {
        insertButtons();
      } else {
        setTimeout(tryInit, 700);
      }
    };
    tryInit();
  });
})();