SJTU Grad student classtable to iCalendar

Export the SJTU graduate school schedule to an iCalendar file. Initial support for iOS calendar locations is currently available.

// ==UserScript==
// @name         SJTU Grad student classtable to iCalendar
// @name:zh-CN   SJTU研究生课表导出到iCalendar
// @namespace    http://tampermonkey.net/
// @version      0.2.0
// @description  Export the SJTU graduate school schedule to an iCalendar file. Initial support for iOS calendar locations is currently available.
// @description:zh-CN  将研究生课表导出到iCalendar文件。目前初步支持iOS日历位置。
// @author       Victrid
// @match        http://yjs.sjtu.edu.cn/gsapp/sys/wdkbapp*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @run-at       document-end
// @license      GPL-3.0
// @grant        GM_xmlhttpRequest
// @connect      plus.sjtu.edu.cn
// @require      https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js
// ==/UserScript==

/*jshint esversion: 8 */
(function () {
    'use strict';

    // This part is modified from https://github.com/nwcell/ics.js, licensed under MIT.
    // MIT License

    // Copyright (c) 2018 Travis Krause

    // Permission is hereby granted, free of charge, to any person obtaining a copy
    // of this software and associated documentation files (the "Software"), to deal
    // in the Software without restriction, including without limitation the rights
    // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    // copies of the Software, and to permit persons to whom the Software is
    // furnished to do so, subject to the following conditions:

    // The above copyright notice and this permission notice shall be included in all
    // copies or substantial portions of the Software.

    // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    // SOFTWARE.
    // -------------------------------ICS.js start-----------------------------------
    const ics = function (prodId, uidDomain) {
        var SEPARATOR = '\n';
        var calendarEvents = [];
        var calendarStart = [
            'BEGIN:VCALENDAR',
            'PRODID:' + prodId,
            'VERSION:2.0'
        ].join(SEPARATOR);
        var calendarEnd = SEPARATOR + 'END:VCALENDAR';

        function GetTimeDate(date_object) {
            // Return the UTC ICS compatible TIME-DATE of a Date object. UTC part here is critical
            const yy = ("0000" + (date_object.getUTCFullYear().toString())).slice(-4);
            const MM = ("00" + ((date_object.getUTCMonth() + 1).toString())).slice(-2);
            const dd = ("00" + ((date_object.getUTCDate()).toString())).slice(-2);
            const hh = ("00" + (date_object.getUTCHours().toString())).slice(-2);
            const mm = ("00" + (date_object.getUTCMinutes().toString())).slice(-2);
            const ss = ("00" + (date_object.getUTCSeconds().toString())).slice(-2);
            return `${yy}${MM}${dd}T${hh}${mm}${ss}Z`;
        }

        // DTSTAMP
        const now = GetTimeDate(new Date());

        const geolocation = {
            // ios map related private annotations
            "东上院": {
                handle: `CAES8AIIl8QDEK/e9djnpP4BGhIJnrRwWYUFP0ARTaCIRQxc
            XkAiogEKBuS4reWbvRICQ04yCeS4iua1t+W4gkIJ6Ze16KGM5Yy6Uj7kuJzlt53ot684MDDl
            j7fkuIrmtbfkuqTpgJrlpKflrabpl7XooYzmoKHljLrlhoUo6L+R5Lic5LiL6ZmiKWI+5Lic
            5bed6LevODAw5Y+35LiK5rW35Lqk6YCa5aSn5a2m6Ze16KGM5qCh5Yy65YaFKOi/keS4nOS4
            i+mZoikqJ+S4iua1t+S6pOmAmuWkp+WtpumXteihjOagoeWMuuS4nOS4iumZojJW5Lit5Zu9
            5LiK5rW35biC6Ze16KGM5Yy65Lic5bed6LevODAw5Y+35LiK5rW35Lqk6YCa5aSn5a2m6Ze1
            6KGM5qCh5Yy65YaFKOi/keS4nOS4i+mZoik4L1ABWiMKIQiv3vXY56T+ARISCZ60cFmFBT9A
            EU2giEUMXF5AGJfEAw==`.replace(/\s/g, ""),
                radius: "95.46661055183932",
                geo: "31.021566,121.438249"
            },
            "东中院": {
                handle: `CAESvQIIl8QDEKuT1MbnpP4BGhIJpkboZ+oFP0ARO/4LBAFcXkAigAE
            KBuS4reWbvRICQ04yCeS4iua1t+W4gkIJ6Ze16KGM5Yy6Ui3kuJzlt53ot684MDDlj7fkuIr
            mtbfkuqTpgJrlpKflrabpl7XooYzmoKHljLpiLeS4nOW3nei3rzgwMOWPt+S4iua1t+S6pOm
            AmuWkp+WtpumXteihjOagoeWMuion5LiK5rW35Lqk6YCa5aSn5a2m6Ze16KGM5qCh5Yy65Li
            c5Lit6ZmiMkXkuK3lm73kuIrmtbfluILpl7XooYzljLrkuJzlt53ot684MDDlj7fkuIrmtbf
            kuqTpgJrlpKflrabpl7XooYzmoKHljLo4L1ABWiMKIQirk9TG56T+ARISCaZG6GfqBT9AETv
            +CwQBXF5AGJfEAw==`.replace(/\s/g, ""),
                radius: "116.1443983019585",
                geo: "31.023108,121.437562"
            },
            "东下院": {
                handle: `CAESzAIIl8QDEOvX+8HnpP4BGhIJjx1U4joGP0ARFokJavhbXkAi
            igEKBuS4reWbvRICQ04yCeS4iua1t+W4gkIJ6Ze16KGM5Yy6UjLkuJzlt53ot684MDDlj7fk
            uqTpgJrlpKflrablhoUo6L+R5Lqk5aSn5Zu+5Lmm6aaGKWIy5Lic5bed6LevODAw5Y+35Lqk
            6YCa5aSn5a2m5YaFKOi/keS6pOWkp+WbvuS5pummhikqJ+S4iua1t+S6pOmAmuWkp+WtpumX
            teihjOagoeWMuuS4nOS4i+mZojJK5Lit5Zu95LiK5rW35biC6Ze16KGM5Yy65Lic5bed6Lev
            ODAw5Y+35Lqk6YCa5aSn5a2m5YaFKOi/keS6pOWkp+WbvuS5pummhik4L1ABWiMKIQjr1/vB
            56T+ARISCY8dVOI6Bj9AERaJCWr4W15AGJfEAw==`.replace(/\s/g, ""),
                radius: "86.83563332157695",
                geo: "31.024336,121.437"
            },
            "上院": {
                handle: `CAESwwIIl8QDEJfTiujYgf4BGhIJokW28/0EP0ARDHVY4ZZbXkAihg
            EKBuS4reWbvRICQ04yCeS4iua1t+W4gkIJ6Ze16KGM5Yy6UjDkuJzlt53ot684MDDlj7fkuI
            rmtbfkuqTpgJrlpKflrabpl7XooYzmoKHljLrlhoViMOS4nOW3nei3rzgwMOWPt+S4iua1t+
            S6pOmAmuWkp+WtpumXteihjOagoeWMuuWGhSok5LiK5rW35Lqk6YCa5aSn5a2m6Ze16KGM5q
            Ch5Yy65LiK6ZmiMkjkuK3lm73kuIrmtbfluILpl7XooYzljLrkuJzlt53ot684MDDlj7fkuI
            rmtbfkuqTpgJrlpKflrabpl7XooYzmoKHljLrlhoU4L1ABWiMKIQiX04ro2IH+ARISCaJFtv
            P9BD9AEQx1WOGWW15AGJfEAw==`.replace(/\s/g, ""),
                radius: "79.62744974845103",
                geo: "31.019500,121.431084"
            },
            "中院": {
                handle: `CAESsAIIl8QDELDRiujYgf4BGhIJ8pTVdD0FP0ARgSOBBptbXkAiegoG
            5Lit5Zu9EgJDTjIJ5LiK5rW35biCQgnpl7XooYzljLpSKuS4nOW3nei3rzgwMOS4iua1t+S6
            pOmAmuWkp+WtpumXteihjOagoeWMumIq5Lic5bed6LevODAw5LiK5rW35Lqk6YCa5aSn5a2m
            6Ze16KGM5qCh5Yy6KiTkuIrmtbfkuqTpgJrlpKflrabpl7XooYzmoKHljLrkuK3pmaIyQuS4
            reWbveS4iua1t+W4gumXteihjOWMuuS4nOW3nei3rzgwMOS4iua1t+S6pOmAmuWkp+WtpumX
            teihjOagoeWMujgvUAFaIwohCLDRiujYgf4BEhIJ8pTVdD0FP0ARgSOBBptbXkAYl8QD`.replace(/\s/g, ""),
                radius: "389.6329066623545",
                geo: "31.020469,121.431337"
            },
            "下院": {
                handle: `CAESsAIIl8QDEJrTiujYgf4BGhIJ7rPKTGkFP0ARdxGmKJdbXkAiegoG
            5Lit5Zu9EgJDTjIJ5LiK5rW35biCQgnpl7XooYzljLpSKuS4nOW3nei3rzgwMOS4iua1t+S6
            pOmAmuWkp+WtpumXteihjOagoeWMumIq5Lic5bed6LevODAw5LiK5rW35Lqk6YCa5aSn5a2m
            6Ze16KGM5qCh5Yy6KiTkuIrmtbfkuqTpgJrlpKflrabpl7XooYzmoKHljLrkuIvpmaIyQuS4
            reWbveS4iua1t+W4gumXteihjOWMuuS4nOW3nei3rzgwMOS4iua1t+S6pOmAmuWkp+WtpumX
            teihjOagoeWMujgvUAFaIwohCJrTiujYgf4BEhIJ7rPKTGkFP0ARdxGmKJdbXkAYl8QD`.replace(/\s/g, ""),
                radius: "371.4202884635783",
                geo: "31.021138,121.431101"
            },
            "陈瑞球楼": {
                handle: `CAEStgIIl8QDEOCelMTnpP4BGhIJ6e3PRUMGP0ARjnVxGw1cXkAiegoG5Lit5Zu9E
            gJDTjIJ5LiK5rW35biCQgnpl7XooYzljLpSKuS4iua1t+S6pOmAmuWkp+WtpumXteihjOago
            eWMuumZiOeRnueQg+alvGIq5LiK5rW35Lqk6YCa5aSn5a2m6Ze16KGM5qCh5Yy66ZmI55Ge5
            5CD5qW8KirkuIrmtbfkuqTpgJrlpKflrabpl7XooYzmoKHljLrpmYjnkZ7nkIPmpbwyQuS4r
            eWbveS4iua1t+W4gumXteihjOWMuuS4iua1t+S6pOmAmuWkp+WtpumXteihjOagoeWMuumZi
            OeRnueQg+alvDgvUAFaIwohCOCelMTnpP4BEhIJ6e3PRUMGP0ARjnVxGw1cXkAYl8QD`.replace(/\s/g, ""),
                radius: "371.4202884635783",
                geo: "31.021138,121.431101"
            },

        };

        function FormatXAppleStructuredLocation(building, room) {
            function fxa(str, len) {
                const size = Math.ceil(str.length / len);
                const r = Array(size);
                let offset = 0;

                for (let i = 0; i < size; i++) {
                    r[i] = str.substr(offset, len);
                    offset += len;
                }

                return r;
            }

            var t;
            if (building in geolocation) {
                t = `-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-ADDRESS="闵行校区${building}";
                X-APPLE-MAPKIT-HANDLE=${geolocation[building].handle};X-APPLE-RADIUS=${geolocation[building].radius};
                X-APPLE-REFERENCEFRAME=2;X-TITLE="${room}":geo:${geolocation[building].geo}`.replace(/\s/g, "");
            } else {
                // default to 陈瑞球楼
                t = `-APPLE-STRUCTURED-LOCATION;VALUE=URI;X-ADDRESS="闵行校区${building}";
                X-APPLE-MAPKIT-HANDLE=${geolocation["陈瑞球楼"].handle};X-APPLE-RADIUS=${geolocation["陈瑞球楼"].radius};
                X-APPLE-REFERENCEFRAME=2;X-TITLE="${room}":geo:${geolocation["陈瑞球楼"].geo}`.replace(/\s/g, "");
            }

            // icloud exported calendar have them.
            var chunk = fxa(t, 72);
            chunk[0] = "X" + chunk[0];
            for (var i = 1; i < chunk.length; i++) {
                chunk[i] = " " + chunk[i];
            }

            return chunk.join(SEPARATOR);
        }

        function FormatGEO(building, room) {
            var geo;
            if (building in geolocation) {
                geo = geolocation[building].geo.split(",")
            } else {
                geo = geolocation["陈瑞球楼"].geo.split(",")
            }
            return `GEO:${geo[0]};${geo[1]}`
        }

        return {
            'addPeriods': function (subject, description, building, room, location, start_date, period_list, ios) {
                // Utilize RDATE periods. Best option if your calendar supports this.
                // Note: Apple calendar cannot understand RDATE.

                if (ios) {
                    console.error("iOS calendar does not support RDATE");
                    return;
                }

                const start = GetTimeDate(new Date(start_date.getTime() + period_list[0].start));
                const end = GetTimeDate(new Date(start_date.getTime() + period_list[0].end));

                var period_strings = [];
                for (const period of period_list) {
                    const start_time = GetTimeDate(new Date(start_date.getTime() + period.start));
                    const end_time = GetTimeDate(new Date(start_date.getTime() + period.end));

                    period_strings.push(`${start_time}/${end_time}`);
                }

                const RDATE_PERIOD = period_strings.join(",");

                var calendarEvent = [
                    'BEGIN:VEVENT',
                    'UID:' + calendarEvents.length + "@" + uidDomain,
                    'CLASS:PUBLIC',
                    'DESCRIPTION:' + description,
                    'DTSTAMP;VALUE=DATE-TIME:' + now,
                    'DTSTART;VALUE=DATE-TIME:' + start,
                    'DTEND;VALUE=DATE-TIME:' + end,
                    'LOCATION:' + location,
                    FormatGEO(building, room),
                    "RDATE;VALUE=PERIOD:" + RDATE_PERIOD,
                    'SUMMARY;LANGUAGE=zh-CN:' + subject,
                    'TRANSP:OPAQUE',
                    'END:VEVENT'
                ];

                calendarEvent = calendarEvent.join(SEPARATOR);

                calendarEvents.push(calendarEvent);
                return;
            },

            'addSingle': function (subject, description, building, room, location, start_date, period, ios) {
                // Compatible option

                const start = GetTimeDate(new Date(start_date.getTime() + period.start));
                const end = GetTimeDate(new Date(start_date.getTime() + period.end));

                var calendarEvent = [
                    'BEGIN:VEVENT',
                    'UID:' + calendarEvents.length + "@" + uidDomain,
                    'CLASS:PUBLIC',
                    'DESCRIPTION:' + description,
                    'DTSTAMP;VALUE=DATE-TIME:' + now,
                    'DTSTART;VALUE=DATE-TIME:' + start,
                    'DTEND;VALUE=DATE-TIME:' + end,
                    'LOCATION:' + location + (ios ? SEPARATOR + FormatXAppleStructuredLocation(building, room) : SEPARATOR + FormatGEO(building, room)),
                    'SUMMARY;LANGUAGE=zh-CN:' + subject,
                    'TRANSP:OPAQUE',
                    'END:VEVENT'
                ];

                calendarEvent = calendarEvent.join(SEPARATOR);

                calendarEvents.push(calendarEvent);
                return calendarEvent;
            },

            'download': function (filename) {
                if (calendarEvents.length < 1) {
                    return;
                }

                var calendar = calendarStart + SEPARATOR + calendarEvents.join(SEPARATOR) + calendarEnd;

                var blob = new Blob([calendar]);
                saveAs(blob, filename + ".ics");
                // clear after saving
                calendarEvents = [];
            },
        };
    }(`SJTUGraduateiCalendar ${GM_info.script.version}`, `SGics${GM_info.script.version}`);

    // -------------------------------ICS.js end-----------------------------------


    function ParseWeek(week_notation) {
        var weeks = [];
        for (var i = 0; i < week_notation.length; i++) {
            weeks.push(week_notation.charAt(i) == "1");
        }

        return weeks;
    }

    async function GetStartDay(semester) {
        // get the start date from sjtu plus
        const sjtu_plus_url = "https://plus.sjtu.edu.cn/course-plus-data/lessonData_index.json";
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET",
                url: sjtu_plus_url,
                headers: { "Content-Type": "application/application/json" },
                onload: function (req) {
                    var result = new Date(0);
                    if (req.status != 200) {
                        console.log(req.status);
                        return new Date(0);
                    }
                    const data = JSON.parse(req.responseText);
                    const semester_start_month = parseInt(semester) % 100;
                    var semester_year = ((parseInt(semester) - semester_start_month) / 100) | 0;
                    var year_json, semester_json;
                    switch (semester_start_month) {
                        case 8:
                        case 9:
                        case 10:
                            year_json = semester_year.toString() + "-" + (semester_year + 1).toString();
                            semester_json = 1;
                            break;
                        case 1:
                        case 2:
                        case 3:
                            year_json = (semester_year - 1).toString() + "-" + semester_year.toString();
                            semester_json = 2;
                            break;
                        case 5:
                        case 6:
                        case 7:
                            year_json = (semester_year - 1).toString() + "-" + semester_year.toString();
                            semester_json = 3;
                            break;
                        default:
                            console.log(`Error finding semester: how can a semester starts in ${semester_start_month}?`);
                            result = new Date(0);
                    }

                    for (const item of data) {
                        if (item.year == year_json && item.semester == semester_json) {
                            if ("first_day" in item) {
                                result = new Date(Date.parse(item.first_day + " 00:00:00 GMT+0800"));
                                resolve(result);
                                return;
                            } else {
                                console.log("Error finding semester: first_day not defined by SJTU-plus, maybe too old or too new?");
                                result = new Date(0);
                                resolve(result);
                                return;
                            }
                        }
                    }

                    console.log("Error finding semester: semester not defined by SJTU-plus, maybe too old or too new?");
                    result = new Date(0);

                    resolve(result);
                }
            });
        });

    }

    function ExpandSchedInfoAndCombine(schedinfo, start_date) {
        var full_timetable = [];
        for (const sched of schedinfo) {
            for (var week = 0; week < sched.weeks.length; week++) {
                if (sched.weeks[week]) {
                    const si = {
                        week: week,
                        day: sched.weekday,
                        start: sched.timeslot,
                        end: sched.timeslot + 1,
                        building: sched.building,
                        room: sched.room,
                        // Content shown at iCalendar
                        locatable_loc: `${sched.room}\\n闵行校区${sched.building}`,

                        // Unique location stored in course scheduling system
                        _location_cmp: sched._location_cmp,
                        // Days after Day 0 (1st week's monday)
                        _daydelta: week * 7 + sched.weekday,
                        // Comparable value to check whether two time slot are continuous
                        _tsstart: (week * 7 + sched.weekday) * 24 + sched.timeslot,
                        _tsend: (week * 7 + sched.weekday) * 24 + sched.timeslot + 1,
                    };

                    full_timetable.push(si);
                }
            }
        }

        // Sort the table, make sure continuous slot finds themselves

        function cont(si1, si2) {
            return si1._tsstart - si2._tsstart;
        }

        full_timetable.sort(cont);

        //condensed timetable groups continuous slot together

        var condensed_timetable = [];
        var current;
        for (const si of full_timetable) {
            if (current != undefined && current._tsend == si._tsstart && current._location_cmp == si._location_cmp) {
                current._tsend = si._tsend;
                current.end = si.end;
            } else {
                if (current != undefined) {
                    condensed_timetable.push(current);
                }

                current = si;
            }
        }
        if (current != undefined) {
            condensed_timetable.push(current);
        }

        // vevent dict groups them by location
        var vevent_dict = {};
        for (const si of condensed_timetable) {
            if (!(si._location_cmp in vevent_dict)) {
                vevent_dict[si._location_cmp] = {
                    locatable_loc: si.locatable_loc,
                    building: si.building,
                    room: si.room,
                    timeslots: []
                };
            }
            const absolute_days = si.week * 7 + si.day;
            const timeslot = GetHM(si.start, si.end);

            vevent_dict[si._location_cmp].timeslots.push({
                start: absolute_days * 24 * 60 * 60 * 1000 + timeslot.start * 60 * 1000,
                end: absolute_days * 24 * 60 * 60 * 1000 + timeslot.end * 60 * 1000
            });
        }

        var vevent_list = [];
        for (const key in vevent_dict) {
            vevent_list.push(vevent_dict[key]);
        }

        return vevent_list;
    }

    const timeStart = [
        '08:00',
        '08:55',
        '10:00',
        '10:55',
        '12:00',
        '12:55',
        '14:00',
        '14:55',
        '16:00',
        '16:55',
        '18:00',
        '18:55',
        '19:41',
        '20:25',
        "21:15"
    ];


    const timeEnd = [
        '08:45',
        "09:40",
        "10:45",
        "11:40",
        "12:45",
        "13:40",
        "14:45",
        "15:40",
        "16:45",
        "17:40",
        "18:45",
        "19:40",
        "20:20",
        "21:10",
        "22:00"
    ];


    function GetHM(start, end) {
        var start_info = timeStart[start - 1].split(":");
        var start_m = parseInt(start_info[0]) * 60 + parseInt(start_info[1]);

        var end_info = timeEnd[end - 2].split(":");
        var end_m = parseInt(end_info[0]) * 60 + parseInt(end_info[1]);

        return {
            start: start_m,
            end: end_m,
        };
    }

    async function GetCourseInfo() {
        // Dirty thingy, use their JS to do
        var course_info = {};
        const bld_regex = /[-0-9]*$/i;
        const semester = $('#myXnxqSelect').val();
        return new Promise((resolve, reject) => {
            requirejs(["/gsapp/sys/wdkbapp/*default/modules/xskcb/xskcbBS.js"], function (bs) {
                bs.getXspkjgList(semester, "").done(function (pkjgList) {
                    //console.log(pkjgList);
                    for (const course_raw of pkjgList) {
                        //console.log(course_raw);
                        if (course_raw["XNXQDM"] != semester) {
                            console.info("Received erranous value", course_raw["XNXQDM"], semester);
                            continue;
                        }

                        const schedinfo = {
                            weeks: ParseWeek(course_raw["ZCBH"]),
                            weekday: course_raw["XQ"] - 1,
                            timeslot: course_raw["JSJCDM"],
                            building: course_raw["JASMC"].replace(bld_regex, ""),
                            room: course_raw["JASMC"],
                            _location_cmp: course_raw["JASDM"],
                        };

                        if (!(course_raw["BJDM"] in course_info)) {
                            course_info[course_raw["BJDM"]] = {
                                name: course_raw["KCMC"],
                                course_code: course_raw["KCDM"],
                                class_code: course_raw["BJMC"],
                                teacher: course_raw["JSXM"],
                                scheds: []
                            };
                        }

                        course_info[course_raw["BJDM"]]["scheds"].push(schedinfo);

                    }

                    for (let idx in course_info) {
                        let ci = course_info[idx];
                        let newscheds = ExpandSchedInfoAndCombine(ci.scheds);
                        ci.scheds = newscheds;
                    }
                    resolve(course_info);
                });
            });
        });
    }

    function RDATE_method(info, start_date, ios) {
        for (const key in info) {
            const course = info[key];
            for (const sched of course.scheds) {
                ics.addPeriods(course.name, `${course.course_code} ${course.name}\\n教师:${course.teacher}\\n班级号:${course.class_code}`, sched.building, sched.room, sched.locatable_loc, start_date, sched.timeslots, ios);
            }
        }
    }

    function SINGLE_method(info, start_date, ios) {
        for (const key in info) {
            const course = info[key];
            for (const sched of course.scheds) {
                for (const time of sched.timeslots) {
                    ics.addSingle(course.name, `${course.course_code} ${course.name}\\n教师:${course.teacher}\\n班级号:${course.class_code}`, sched.building, sched.room, sched.locatable_loc, start_date, time, ios);
                }

            }
        }
    }

    async function GenerateIOS() {
        // This function generates calendar
        const info = await GetCourseInfo();
        const semester = $('#myXnxqSelect').val();
        const start_date = await GetStartDay(semester);
        SINGLE_method(info, start_date, true);
        ics.download("iOS");
    }

    async function GenerateNormal() {
        // This function generates calendar
        const info = await GetCourseInfo();
        const semester = $('#myXnxqSelect').val();
        const start_date = await GetStartDay(semester);
        SINGLE_method(info, start_date, false);
        ics.download("Normal");
    }

    async function GenerateRDATE() {
        // This function generates calendar
        const info = await GetCourseInfo();
        const semester = $('#myXnxqSelect').val();
        const start_date = await GetStartDay(semester);
        RDATE_method(info, start_date, false);
        ics.download("RDATE");
    }

    function delayedRegistration() {
        $(`<a href="javascript:void(0);" class="bh-btn bh-btn-default" id="genios" title="iOS日历,含苹果特定地图信息。每次课均作为单独事件。">导出含iOS位置日历
           </a>
           <a href="javascript:void(0);" class="bh-btn bh-btn-default" id="gennormal" title="常规日历,含GPS信息。每次课均作为单独事件。">导出常规日历
           </a>
           <a href="javascript:void(0);" class="bh-btn bh-btn-default" id="genrdate" title="日历条目设置为重复项,支持关联批量编辑,含GPS信息。大多数日历程序无法正确解析RDATE,请测试后使用。">导出RDATE日历
           </a>`).insertAfter($("#xsXx").children().find("a"));
        $("#genios").click(GenerateIOS);
        $("#gennormal").click(GenerateNormal);
        $("#genrdate").click(GenerateRDATE);
    }

    let retries = 50;

    const intervalID = setInterval(_ => {
        // TODO: Try to check if the label is loaded, not very effective
        const match = ($("#myXnxqSelect").length != 0);
        if (match != 0) {
            delayedRegistration();
        }
        retries--;
        if (retries == 0 || (match != 0)) clearInterval(intervalID);
    }, 100);

})();