UBC Workday Calendar Generator

Adds a 'Download Calendar (.ics)' button to Workday and generates ICS with proper location/description

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         UBC Workday Calendar Generator
// @namespace    http://tampermonkey.net/
// @version      1.9
// @description  Adds a 'Download Calendar (.ics)' button to Workday and generates ICS with proper location/description
// @match        *://*.myworkday.com/ubc*
// @author       TU
// @license      TU
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    const timeZone = 'America/Vancouver';

    function formatToICS(event, courseNameCal, courseTypeCal, uniqueId) {
        const crlf = '\r\n';

        function convertTime(time) {
            if (!time || typeof time !== 'string') {
                console.warn("Invalid time input:", time);
                return '000000';
            }
            const match = time.match(/(\d+):(\d+)\s*(a\.m\.|p\.m\.)/i);
            if (!match) {
                console.warn("Time parsing failed:", time);
                return '000000';
            }

            let [_, hours, minutes, period] = match;
            let hours24 = parseInt(hours, 10);
            if (isNaN(hours24)) hours24 = 0;
            if (period.toLowerCase() === 'p.m.' && hours24 !== 12) hours24 += 12;
            if (period.toLowerCase() === 'a.m.' && hours24 === 12) hours24 = 0;
            return `${hours24.toString().padStart(2, '0')}${minutes.padStart(2, '0')}00`;
        }

        if (!event || !event.startDate || !event.endDate) {
            console.error("Invalid event object:", event);
            return '';
        }

        const startTime = convertTime(event.startTime);
        const endTime = convertTime(event.endTime);
        const dtstamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
        const uid = `${uniqueId || 'X'}-${event.startDate}@tupreti.com`;
        const startDate = event.startDate.replace(/-/g, '');

        const dayMap = { Mon: 'MO', Tue: 'TU', Wed: 'WE', Thu: 'TH', Fri: 'FR', Sat: 'SA', Sun: 'SU' };
        let days = '';
        if (event.days) {
            days = event.days.split(' ')
                .map(day => dayMap[day] || '')
                .filter(Boolean)
                .join(',');
        }
        const frequency = days ? `FREQ=WEEKLY;BYDAY=${days}` : `FREQ=WEEKLY`;

        const until = event.endDate ? `${event.endDate.replace(/-/g, '')}T235959Z` : '';
        const recurrenceRule = until
            ? (event.alternateWeeks
                ? `${frequency};INTERVAL=2;UNTIL=${until}`
                : `${frequency};UNTIL=${until}`)
            : frequency;

        const icsEvent = `BEGIN:VEVENT${crlf}` +
                         `UID:${uid}${crlf}` +
                         `DTSTAMP:${dtstamp}${crlf}` +
                         (startDate && startTime ? `DTSTART;TZID=${timeZone}:${startDate}T${startTime}${crlf}` : '') +
                         (startDate && endTime ? `DTEND;TZID=${timeZone}:${startDate}T${endTime}${crlf}` : '') +
                         (recurrenceRule ? `RRULE:${recurrenceRule}${crlf}` : '') +
                         `SUMMARY:${courseNameCal || 'No Title'}${crlf}` +
                         `DESCRIPTION:${courseTypeCal || ''}${crlf}` +
                         `LOCATION:${event.location || 'No Location'}${crlf}` +
                         `END:VEVENT`;

        return icsEvent;
    }

    function generateICSFile(events) {
        if (!Array.isArray(events) || events.length === 0) {
            console.warn("No events passed to generateICSFile");
            return;
        }

        const crlf = '\r\n';
        const icsContent = `BEGIN:VCALENDAR${crlf}` +
                           `VERSION:2.0${crlf}` +
                           `PRODID:-//Tanish Upreti//UBC Workday Calendar Generator//EN${crlf}` +
                           `CALSCALE:GREGORIAN${crlf}` +
                           `METHOD:PUBLISH${crlf}` +
                           `X-WR-TIMEZONE:${timeZone}${crlf}` +
                           events.join(crlf) +
                           crlf + `END:VCALENDAR`;

        try {
            const blob = new Blob([icsContent], { type: 'text/calendar' });
            const url = URL.createObjectURL(blob);

            const a = document.createElement('a');
            a.href = url;
            a.download = 'UBC Course Schedule.ics';
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        } catch (err) {
            console.error("Failed to generate ICS file:", err);
        }
    }

    function processMeetingPattern(meetingPattern) {
        if (!meetingPattern || typeof meetingPattern !== 'string') {
            console.warn("Invalid meeting pattern:", meetingPattern);
            return { startDate: '', endDate: '', days: '', alternateWeeks: false, startTime: '', endTime: '', location: '' };
        }

        const parts = meetingPattern.split(" | ");
        if (parts.length < 3) {
            console.warn("Unexpected meeting pattern format:", meetingPattern);
            return { startDate: '', endDate: '', days: '', alternateWeeks: false, startTime: '', endTime: '', location: '' };
        }

        let [dateRange, daysAndWeeks, timeRange, ...locationParts] = parts;
        let location = locationParts.join(" | ") || 'No Location';

        let startDate = '', endDate = '';
        if (dateRange.includes(" - ")) {
            [startDate, endDate] = dateRange.split(" - ");
        }

        let alternateWeeks = daysAndWeeks.includes("(Alternate weeks)");
        let days = alternateWeeks ? daysAndWeeks.replace("(Alternate weeks)", "").trim() : daysAndWeeks.trim();

        let startTime = '', endTime = '';
        if (timeRange.includes(" - ")) {
            [startTime, endTime] = timeRange.split(" - ");
        }

        return { startDate, endDate, days, alternateWeeks, startTime, endTime, location };
    }

    function extractUBCO() {
        const divs = document.querySelectorAll('div[data-automation-label]');
        if (!divs.length) {
            console.info("No UBCO-style divs found.");
            return [];
        }
        const courseNames = [];
        divs.forEach(div => {
            const label = div.getAttribute('data-automation-label');
            if (label) courseNames.push(label);
        });
        return courseNames;
    }

    function extractUBCV() {
        const rows = document.querySelectorAll("table tbody tr");
        if (!rows.length) {
            console.info("No UBCV-style table rows found.");
            return [];
        }
        const patterns = [];
        rows.forEach(row => {
            const cells = row.querySelectorAll("td");
            if (cells.length < 5) return;

            const section = cells[1]?.innerText.trim();
            const meetings = cells[4]?.innerText.trim();
            const instructionalFormat = cells[2]?.innerText.trim() || '';

            if (section && meetings) {
                const sessions = meetings.split(/\n/).map(s => s.trim()).filter(Boolean);
                sessions.forEach(session => {
                    patterns.push({ course: section, instructionalFormat, session });
                });
            }
        });
        return patterns;
    }

    function extractAllCourseNamesAndGenerateICS() {
        console.log("ICS generation triggered");

        requestIdleCallback(() => {
            try {
                const pattern = /\b[A-Z]{3,4}_[OV]\s\d{3}-[A-Z0-9_]+ - .+/;
                const events = [];

                // ---------- UBCO ----------
                const courseNames = extractUBCO();
                for (let i = 0; i < courseNames.length; i++) {
                    const currentItem = courseNames[i];
                    if (pattern.test(currentItem)) {
                        const course = currentItem;
                        const instructionalFormat = courseNames[i + 1] || '';

                        let j = i + 3;
                        while (j < courseNames.length && typeof courseNames[j] === 'string' && courseNames[j].includes('|')) {
                            const parsed = processMeetingPattern(courseNames[j]);
                            const event = formatToICS(parsed, course, instructionalFormat, i);
                            if (event) events.push(event);
                            j++;
                        }
                        i = j - 1;
                    }
                }

                // ---------- UBCV ----------
                if (events.length === 0) {
                    const ubcvPatterns = extractUBCV();
                    ubcvPatterns.forEach((item, idx) => {
                        if (!item || !item.course || !pattern.test(item.course)) return;
                        const parsed = processMeetingPattern(item.session);
                        const event = formatToICS(parsed, item.course, item.instructionalFormat, idx);
                        if (event) events.push(event);
                    });
                }

                if (events.length > 0) {
                    generateICSFile(events);
                    console.log("Events generated:", events.length);
                } else {
                    console.log("No events found to generate.");
                }
            } catch (error) {
                console.error("Error while extracting course names and generating ICS:", error);
            }
        });
    }

    function addCalendarDownloadButton() {
        try {
            const selectedTab = document.querySelector('li[data-automation-id="selectedTab"]');
            if (selectedTab && selectedTab.querySelector('div[data-automation-id="tabLabel"]')?.textContent.trim() === 'Registration & Courses') {
                const popups = document.querySelectorAll('div[data-automation-id="workletPopup"]');
                popups.forEach(popup => {
                    const menuList = popup.querySelector('ul[data-automation-id="menuList"]');
                    if (menuList && !menuList.querySelector('div[data-automation-id="calendarDownloadButton"]')) {
                        console.log("Menu list detected, adding 'Download Calendar (.ics)' button.");

                        const existingListItem = menuList.querySelector('li');
                        if (existingListItem) {
                            const newListItem = existingListItem.cloneNode(true);
                            const newButtonDiv = newListItem.querySelector('div')?.cloneNode(true);
                            if (!newButtonDiv) return;

                            newListItem.replaceChild(newButtonDiv, newListItem.querySelector('div'));

                            newButtonDiv.textContent = 'Download Calendar (.ics)';
                            newButtonDiv.setAttribute('data-automation-id', 'calendarDownloadButton');
                            newButtonDiv.setAttribute('aria-label', 'Download Calendar');
                            newButtonDiv.addEventListener('click', extractAllCourseNamesAndGenerateICS);

                            menuList.appendChild(newListItem);
                        }
                    }
                });
            }
        } catch (err) {
            console.error("Error while adding calendar button:", err);
        }
    }

    const observer = new MutationObserver(() => addCalendarDownloadButton());
    observer.observe(document, { childList: true, subtree: true });

})();

(function() {
    'use strict';

    const timeZone = 'America/Vancouver';

    function formatToICS(event, courseNameCal, courseTypeCal, uniqueId) {
        const crlf = '\r\n';

        function convertTime(time) {
            const match = time.match(/(\d+):(\d+)\s*(a\.m\.|p\.m\.)/i);
            if (!match) return '';

            let [_, hours, minutes, period] = match;
            let hours24 = parseInt(hours, 10);
            if (period.toLowerCase() === 'p.m.' && hours24 !== 12) hours24 += 12;
            if (period.toLowerCase() === 'a.m.' && hours24 === 12) hours24 = 0;
            return `${hours24.toString().padStart(2, '0')}${minutes.padStart(2, '0')}00`;
        }

        const startTime = convertTime(event.startTime);
        const endTime = convertTime(event.endTime);
        const dtstamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
        const uid = `${uniqueId}-${event.startDate}@tupreti.com`;
        const startDate = event.startDate.replace(/-/g, '');

        const dayMap = { Mon: 'MO', Tue: 'TU', Wed: 'WE', Thu: 'TH', Fri: 'FR', Sat: 'SA', Sun: 'SU' };
        const days = event.days.split(' ').map(day => dayMap[day]).join(',');
        const frequency = `FREQ=WEEKLY;BYDAY=${days}`;
        const recurrenceRule = event.alternateWeeks
            ? `${frequency};INTERVAL=2;UNTIL=${event.endDate.replace(/-/g, '')}T235959Z`
            : `${frequency};UNTIL=${event.endDate.replace(/-/g, '')}T235959Z`;

        const icsEvent = `BEGIN:VEVENT${crlf}` +
                         `UID:${uid}${crlf}` +
                         `DTSTAMP:${dtstamp}${crlf}` +
                         `DTSTART;TZID=${timeZone}:${startDate}T${startTime}${crlf}` +
                         `DTEND;TZID=${timeZone}:${startDate}T${endTime}${crlf}` +
                         `RRULE:${recurrenceRule}${crlf}` +
                         `SUMMARY:${courseNameCal || 'No Title'}${crlf}` +
                         `DESCRIPTION:${courseTypeCal || 'Lecture'}${crlf}` +
                         `LOCATION:${event.location || 'No Location'}${crlf}` +
                         `END:VEVENT`;

        return icsEvent;
    }

    function generateICSFile(events) {
        const crlf = '\r\n';
        const icsContent = `BEGIN:VCALENDAR${crlf}` +
                           `VERSION:2.0${crlf}` +
                           `PRODID:-//Tanish Upreti//UBC Workday Calendar Generator//EN${crlf}` +
                           `CALSCALE:GREGORIAN${crlf}` +
                           `METHOD:PUBLISH${crlf}` +
                           `X-WR-TIMEZONE:${timeZone}${crlf}` +
                           events.join(crlf) +
                           crlf + `END:VCALENDAR`;

        const blob = new Blob([icsContent], { type: 'text/calendar' });
        const url = URL.createObjectURL(blob);

        const a = document.createElement('a');
        a.href = url;
        a.download = 'UBC Course Schedule.ics';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }

    function processMeetingPattern(meetingPattern) {
        const parts = meetingPattern.split(" | ");
        if (parts.length < 4) return {};

        const [dateRange, daysAndWeeks, timeRange] = parts;
        let locationFull = parts.slice(3).join(" | ");

        const [startDate, endDate] = dateRange.split(" - ");
        const alternateWeeks = daysAndWeeks.includes("(Alternate weeks)");
        const days = alternateWeeks ? daysAndWeeks.replace("(Alternate weeks)", "").trim() : daysAndWeeks.trim();
        const [startTime, endTime] = timeRange.split(" - ");

        return {
            startDate,
            endDate,
            days,
            alternateWeeks,
            startTime,
            endTime,
            location: locationFull
        };
    }

    function extractAllCourseNamesAndGenerateICS() {
        console.log("ICS generation triggered");

        requestIdleCallback(() => {
            try {
                const divs = document.querySelectorAll('div[data-automation-label]');
                const courseNames = [];

                divs.forEach(div => {
                    const label = div.getAttribute('data-automation-label');
                    if (label) courseNames.push(label);
                });

                console.log("Total data-automation-label divs found:", courseNames.length);

                const pattern = /\b[A-Z]{3,4}_[OV] \d{3}-[A-Z0-9]{1,4} - .+/;
                const events = [];

                for (let i = 0; i < courseNames.length; i++) {
                    const currentItem = courseNames[i];
                    console.log(`Checking courseNames[${i}]:`, currentItem);

                    if (pattern.test(currentItem)) {
                        console.log("Matched course regex:", currentItem);
                        const course = currentItem;
                        const instructionalFormat = courseNames[i + 1] || 'Lecture';
                        console.log("Instructional format:", instructionalFormat);

                        let j = i + 3;
                        while (j < courseNames.length && courseNames[j].includes('|')) {
                            const meetingPattern = courseNames[j];
                            console.log("Processing meeting pattern:", meetingPattern);

                            const parsed = processMeetingPattern(meetingPattern);
                            console.log("Parsed meeting pattern:", parsed);

                            if (parsed.startDate) {
                                const event = formatToICS(parsed, course, instructionalFormat, i);
                                events.push(event);
                            }

                            j++;
                        }

                        i = j - 1;
                    }
                }

                if (events.length > 0) {
                    console.log("Events generated:", events.length);
                    generateICSFile(events);
                } else {
                    console.log("No events found to generate. Check regex or meeting pattern format.");
                }
            } catch (error) {
                console.error("Error while extracting course names and generating ICS:", error);
            }
        });
    }

    function addCalendarDownloadButton() {
        const selectedTab = document.querySelector('li[data-automation-id="selectedTab"]');
        if (selectedTab && selectedTab.querySelector('div[data-automation-id="tabLabel"]').textContent.trim() === 'Registration & Courses') {
            const popups = document.querySelectorAll('div[data-automation-id="workletPopup"]');
            popups.forEach(popup => {
                const menuList = popup.querySelector('ul[data-automation-id="menuList"]');
                if (menuList && !menuList.querySelector('div[data-automation-id="calendarDownloadButton"]')) {
                    console.log("Menu list detected, adding 'Download Calendar (.ics)' button.");

                    const existingListItem = menuList.querySelector('li');
                    if (existingListItem) {
                        const newListItem = existingListItem.cloneNode(true);
                        const newButtonDiv = newListItem.querySelector('div').cloneNode(true);
                        newListItem.replaceChild(newButtonDiv, newListItem.querySelector('div'));

                        newButtonDiv.textContent = 'Download Calendar (.ics)';
                        newButtonDiv.setAttribute('data-automation-id', 'calendarDownloadButton');
                        newButtonDiv.setAttribute('aria-label', 'Download Calendar');
                        newButtonDiv.addEventListener('click', extractAllCourseNamesAndGenerateICS);

                        menuList.appendChild(newListItem);
                    }
                }
            });
        }
    }

    const observer = new MutationObserver(() => {
        addCalendarDownloadButton();
    });

    observer.observe(document, { childList: true, subtree: true });
})();