Greasy Fork

UBC Workday ICal Generator

Adds a 'Download Calendar (.ics)' button to the Workday popup list and generates ICS file on click

目前为 2024-09-06 提交的版本。查看 最新版本

// ==UserScript==
// @name         UBC Workday ICal Generator
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  Adds a 'Download Calendar (.ics)' button to the Workday popup list and generates ICS file on click
// @match        *://*.myworkday.com/ubc*
// @grant        none
// @author       TU
// @license      TU
// ==/UserScript==

(function() {
    'use strict'; // Enforce strict mode to catch common coding mistakes

    // Function to format a single event into ICS format
    function formatToICS(event, courseNameCal, courseTypeCal, uniqueId) {
        const crlf = '\r\n'; // Define CRLF as line separator for ICS files

        // Helper function to convert 12-hour time format to 24-hour format
        function convertTime(time) {
            const match = time.match(/(\d+):(\d+)\s*(a\.m\.|p\.m\.)/i);
            if (!match) return ''; // Return empty string if time format is invalid

            let [_, hours, minutes, period] = match;
            let hours24 = parseInt(hours, 10);
            if (period.toLowerCase() === 'p.m.' && hours24 !== 12) {
                hours24 += 12; // Convert PM to 24-hour format
            } else if (period.toLowerCase() === 'a.m.' && hours24 === 12) {
                hours24 = 0; // Convert midnight to 00:00 hours
            }
            return `${hours24.toString().padStart(2, '0')}${minutes.padStart(2, '0')}00`; // Return time in HHMMSS format
        }

        const startTime = convertTime(event.startTime);
        const endTime = convertTime(event.endTime);

        const dtstamp = new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; // Generate current timestamp in ICS format

        // Create a unique UID for each event
        const uid = `${uniqueId}-${event.date}@yourapp.com`;

        // Construct the ICS event with CRLF line endings
        const icsEvent = `BEGIN:VEVENT${crlf}` +
                         `UID:${uid}${crlf}` +
                         `DTSTAMP:${dtstamp}${crlf}` +
                         `DTSTART:${event.date}T${startTime}${crlf}` +
                         `DTEND:${event.date}T${endTime}${crlf}` +
                         `SUMMARY:${courseNameCal || 'No Title'}${crlf}` +
                         `DESCRIPTION:${courseTypeCal || 'No Description'}${crlf}` +
                         `LOCATION:${event.location || 'No Location'}${crlf}` +
                         `END:VEVENT`;

        return icsEvent;
    }

    // Function to generate the ICS file from a list of events
    function generateICSFile(events) {
        const crlf = '\r\n'; // Define CRLF as line separator for ICS files
        // Construct the ICS file content
        const icsContent = `BEGIN:VCALENDAR${crlf}` +
                           `VERSION:2.0${crlf}` +
                           `PRODID:-//TUpreti//UBC Workday ICal Generator//EN${crlf}` +
                           events.join(crlf) + // Join events with CRLF line endings
                           crlf + `END:VCALENDAR`;

        const blob = new Blob([icsContent], { type: 'text/calendar' }); // Create a Blob object for the ICS content
        const url = URL.createObjectURL(blob); // Create a URL for the Blob object

        const a = document.createElement('a'); // Create a new anchor element
        a.href = url; // Set the href to the Blob URL
        a.download = 'UBC Course Schedule.ics'; // Set the filename for the download
        document.body.appendChild(a); // Append the anchor to the document
        a.click(); // Trigger a click on the anchor to start the download
        document.body.removeChild(a); // Remove the anchor from the document
        URL.revokeObjectURL(url); // Release the Blob URL
    }

    // Function to process and parse meeting patterns
    function processMeetingPattern(meetingPattern) {
        let [dateRange, daysAndWeeks, timeRange, location] = meetingPattern.split(" | ");
        let [startDate, endDate] = dateRange.split(" - ");
        let alternateWeeks = daysAndWeeks.includes("(Alternate weeks)");
        let days = alternateWeeks ? daysAndWeeks.replace("(Alternate weeks)", "").trim() : daysAndWeeks.trim();
        let [startTime, endTime] = timeRange.split(" - ");

        // Convert dates to format YYYYMMDD
        startDate = startDate.replace(/-/g, '');
        endDate = endDate.replace(/-/g, '');

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

    // Function to get all dates between two dates for specific days of the week, with optional alternate weeks
    function getDatesBetween(startDate, endDate, daysOfWeek, alternateWeeks = false) {
        const start = new Date(startDate.slice(0, 4), startDate.slice(4, 6) - 1, startDate.slice(6, 8));
        const end = new Date(endDate.slice(0, 4), endDate.slice(4, 6) - 1, endDate.slice(6, 8));
        const dayMap = { 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6, 'Sun': 0 };
        const days = daysOfWeek.split(' ').map(day => dayMap[day]);

        const dates = [];
        let weekCount = 0;
        for (let d = start; d <= end; d.setDate(d.getDate() + 1)) {
            if (days.includes(d.getDay())) {
                // If it's alternate weeks, only push dates every other week
                if (!alternateWeeks || (alternateWeeks && weekCount % 2 === 0)) {
                    dates.push(new Date(d).toISOString().slice(0, 10).replace(/-/g, ''));
                }
            }
            if (d.getDay() === 0) { // Increment weekCount at the start of each week
                weekCount++;
            }
        }
        return dates;
    }

    // Function to extract all course names and generate the ICS file
    function extractAllCourseNamesAndGenerateICS() {
        console.log("ICS generation triggered");

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

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

                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];
                    if (pattern.test(currentItem)) {
                        const course = currentItem;
                        const instructionalFormat = courseNames[i + 1];

                        // Collect all meeting patterns for this course
                        let j = i + 3;
                        while (j < courseNames.length && courseNames[j].includes('|')) {
                            const meetingPatterns = courseNames[j];
                            const parsed = processMeetingPattern(meetingPatterns);
                            const occurrenceDates = getDatesBetween(parsed.startDate, parsed.endDate, parsed.days, parsed.alternateWeeks);

                            occurrenceDates.forEach((date, index) => {
                                const event = formatToICS({ ...parsed, date }, course, instructionalFormat, i + '-' + index);
                                events.push(event);
                            });

                            j++;
                        }
                        // Skip processed patterns
                        i = j - 1;
                    }
                }

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

    // Function to add the 'Download Calendar' button to the Workday popup list
    function addCalendarDownloadButton() {
        // Select the currently selected tab
        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"]')) {
                    const newListItem = document.createElement('li');
                    newListItem.classList.add('WJTQ');
                    newListItem.setAttribute('role', 'presentation');

                    const newButtonDiv = document.createElement('div');
                    newButtonDiv.classList.add('WPSQ', 'WLSQ', 'WASQ', 'WITQ', 'WGTQ');
                    newButtonDiv.setAttribute('aria-disabled', 'false');
                    newButtonDiv.setAttribute('tabindex', '-2');
                    newButtonDiv.setAttribute('data-automation-id', 'calendarDownloadButton');
                    newButtonDiv.setAttribute('role', 'option');
                    newButtonDiv.setAttribute('aria-setsize', (menuList.children.length + 1).toString()); // Adjust according to the number of items
                    newButtonDiv.setAttribute('aria-posinset', (menuList.children.length + 1).toString()); // Position at the end
                    newButtonDiv.textContent = 'Download Calendar (.ics)';

                    newButtonDiv.addEventListener('click', extractAllCourseNamesAndGenerateICS);

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

    // Initial button setup
    addCalendarDownloadButton();

    // Set up MutationObserver to add the button when the popup is loaded or updated
    const observer = new MutationObserver(() => {
        addCalendarDownloadButton();
    });

    // Observe the body for changes
    observer.observe(document.body, { childList: true, subtree: true });
})();