UMN Schedule to Google Calendar
Updated Fall 2020
Now supports recurring events!
This script ports the class schedule from MyU to your google calendar. To use:
- Go to MyU (myu.umn.edu) and click the Academics tab. Make sure the "My Classes" tab is selected and use the arrows navigate to the first week of classes.
- Right click anywhere on the page and click "Inspect". On Firefox it's called "Inspect Element" and on Safari you can follow these instructions.
- Scroll to the Console tab on the window that pops up. Copy and paste the following code directly into the
console and press enter. Note: Never paste random code from the internet into
your console! Make sure you trust me before continuing. A malicious attacker in my place could, for example,
install malware, un-enroll you from all of your classes and pay your tuition.
// to run: go to myU and to the schedule page, then inspect element and paste this in the console const ICS_HEADER = `BEGIN:VCALENDAR PRODID:Calendar VERSION:2.0 BEGIN:VTIMEZONE TZID:America/Chicago X-LIC-LOCATION:America/Chicago BEGIN:DAYLIGHT TZOFFSETFROM:-0600 TZOFFSETTO:-0500 TZNAME:CDT DTSTART:19700308T020000 RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU END:DAYLIGHT BEGIN:STANDARD TZOFFSETFROM:-0500 TZOFFSETTO:-0600 TZNAME:CST DTSTART:19701101T020000 RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU END:STANDARD END:VTIMEZONE`; const ICS_FOOTER = '\nEND:VCALENDAR'; let idCount = 0; function getICSEntry(name, type, place, startDateStr, endDateStr, rrule){ let result = '\nBEGIN:VEVENT'; result += '\n' + (idCount++) + '@default'; result += '\nDESCRIPTION:' + type; result += rrule ? '\n' + rrule : ''; const now = new Date(); let hours = now.getHours(); if(hours < 10){ hours = '0' + hours; } let minutes = now.getMinutes(); if(minutes < 10){ minutes = '0' + minutes; } let timeStr = hours + '' + minutes; result += '\nDTSTAMP;TZID=America/Chicago:' + getICSDateString(now.getFullYear(), now.getMonth() + 1, now.getDate(), timeStr); result += '\nDTSTART;TZID=America/Chicago:' + startDateStr; result += '\nDTEND;TZID=America/Chicago:' + endDateStr; result += '\nLOCATION:' + place; result += '\nSUMMARY;LANGUAGE=en-us:' + name; result += '\nTRANSP:TRANSPARENT'; result += '\nEND:VEVENT'; return result; } function getWeekDateString(){ let nextWeekButton = document.querySelector('[title=\'Next Week\']'); let dateH = nextWeekButton.parentElement.children[1]; let fullString = dateH.innerText; return fullString.match(/\d\d\/\d\d\/\d\d\d\d/g)[0]; } function getICSDateString(year, month, day, timeStr){ let monthStr = month + ''; while(monthStr.length < 2){ monthStr = '0' + monthStr; } let dayStr = day + ''; while(dayStr.length < 2){ dayStr = '0' + dayStr; } return year + '' + monthStr + dayStr + 'T' + timeStr + '00'; } let days = ['Monday','Tuesday','Wednesday','Thursday','Friday']; function getClassDataForWeek(){ let weekData = {}; for(let d of days){ weekData[d] = []; } for(let day of days){ let dayGroup = document.querySelector('[data-day=\''+day+'\']'); if(!dayGroup){ return weekData; } // each class is an element let classElements = []; for(let el of dayGroup.childNodes){ if(el.nodeName === 'A'){ classElements.push(el); } } for(let c of classElements){ let name = c.children[0].innerText; let details = c.children[1].innerText; let [type, time, place] = details.split('\n'); weekData[day].push({name:name,time:time,place:place,type:type}); } } return weekData; } function isWeekEmpty(data){ for(let key in data){ if(data[key].length > 0){ return false; } } return true; } function nextWeek(){ let nextWeekButton = document.querySelector('[title=\'Next Week\']'); nextWeekButton.click(); } Date.prototype.addDays = function(days) { var date = new Date(this.valueOf()); date.setDate(date.getDate() + days); return date; } function mod(n, m) { return ((n % m) + m) % m; } function toMilitary(hour, isAM){ if(isAM){ if(hour === 12){ return 0; } return hour; } if(hour === 12){ return 12; } return hour + 12; } let weeks = []; let weeksEmpty = 0; function proceed(){ let weekData = getClassDataForWeek(); if(weeksEmpty <= 1){ if(isWeekEmpty(weekData)){ weeksEmpty++; weeks.push({isEmpty: true}); }else{ weeksEmpty = 0; weeks.push({isEmpty: false, weekDate:getWeekDateString(), data:weekData}); } nextWeek(); // wait until it finishes loading setTimeout(proceed, 3000); }else{ let dayDateConversion = {'Monday':0,'Tuesday':1,'Wednesday':2,'Thursday':3,'Friday':4}; for(let week of weeks){ if(week.isEmpty){ continue; } // map days to dates let weekDate = new Date(week.weekDate); for(let d of days){ let classes = week.data[d]; for(let c of classes){ // english/US date format (locale) let classDate = weekDate.addDays(dayDateConversion[d]); let year = classDate.getFullYear(); let month = classDate.getMonth() + 1; let day = classDate.getDate(); let classTime = c.time; // isolate start/end time. myU uses the hh:mm - hh:mm AM/PM format let lastIsAM = !!classTime.match(/AM/g); let [startTime, endTime] = classTime.match(/\d{1,2}:\d{1,2}/g); let [startTimeHour, endTimeHour] = [startTime.split(':')[0], endTime.split(':')[0]].map(x=>parseInt(x)); // we want to know if the first hour occurs in the AM or the PM. // we make the assumption that no class lasts more than 12 hours (apparently the folks // who made myU thought so too), so we take the option (am vs pm) in which the class length is less let militaryAMOption = toMilitary(startTimeHour, true); let militaryPMOption = toMilitary(startTimeHour, false); let militaryEnd = toMilitary(endTimeHour, lastIsAM); let firstIsAM; if(mod(militaryEnd - militaryAMOption, 24) < mod(militaryEnd - militaryPMOption, 24)){ firstIsAM = true; } else { firstIsAM = false; } c.year = year; c.month = month; c.day = day; const toTimeString = (timeStr, isAM) => { let [hr, minute] = timeStr.split(':'); let militaryHr = toMilitary(parseInt(hr), isAM) + ''; while(militaryHr.length < 2){ militaryHr = '0' + militaryHr; } while(minute.length < 2){ minute = '0' + minute; } return militaryHr + minute; }; c.startTime = toTimeString(startTime, firstIsAM); c.endTime = toTimeString(endTime, lastIsAM); // flag so we don't process this event twice c.used = false; } } } // returns null if doesn't exist function getClassOnDay(weekIndex, dayIndex, originalClass){ if(weeks[weekIndex].isEmpty){ return null; } for(let c of weeks[weekIndex].data[days[dayIndex]]){ if(c.name === originalClass.name && c.time === originalClass.time && c.place === originalClass.place && c.type === originalClass.type){ return c; } } return null; } // our ical object let calendar = ICS_HEADER; for(let weekIndex = 0; weekIndex < weeks.length; weekIndex++){ if(weeks[weekIndex].isEmpty){ continue; } for(let dayIndex = 0; dayIndex < 5; dayIndex++){ let classes = weeks[weekIndex].data[days[dayIndex]]; for(let c of classes){ if(c.used){ // already covered this single class instance in a recurring event continue; } c.used = true; // otherwise search for event to go in a recurring event along with this one // first check the next days in this week let classDays = [dayIndex]; for(let i = dayIndex + 1; i < dayIndex + 5; i++){ let w = i >= 5 ? weekIndex + 1 : weekIndex; let c2 = getClassOnDay(w, i % 5, c); if(c2 != null){ classDays.push(i % 5); c2.used = true; } } let numEvents = classDays.length; // now continue to following weeks until we find some day in classDays where this not a matching event let classDaysIndex = 0; let workingWeekIndex = weekIndex + 1; while(true){ const day = classDays[classDaysIndex]; let c2 = getClassOnDay(workingWeekIndex, classDays[classDaysIndex], c); if(c2 != null){ if(!c2.used){ numEvents++; } c2.used = true; }else { break; } classDaysIndex = (classDaysIndex + 1) % classDays.length; if(classDays[classDaysIndex] <= day){ workingWeekIndex++; } } // now add a single event if we only found a single event, otherwise a recurring event let rrule = null; if(numEvents > 1){ rrule = 'RRULE:FREQ=WEEKLY;COUNT=' + numEvents + ';BYDAY=' + classDays.map(x => ['MO', 'TU', 'WE', 'TH', 'FR'][x]).join(','); } console.log(c); calendar += getICSEntry(c.name, c.type, c.place, getICSDateString(c.year, c.month, c.day, c.startTime), getICSDateString(c.year, c.month, c.day, c.endTime), rrule); } } } calendar += ICS_FOOTER; // download file download('umn_calendar_to_google.ics', calendar); } } // see https://stackoverflow.com/questions/3665115/how-to-create-a-file-in-memory-for-user-to-download-but-not-through-server function download(filename, text) { var element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); element.setAttribute('download', filename); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); } proceed();
- Wait patiently while the program scrolls through all the weeks. If you are really impatient you
can change the line
setTimeout(proceed, 3000);
tosetTimeout(proceed, 1000);
or evensetTimeout(proceed, 500);
if you have really fast internet and are feeling lucky. After it finishes it should download a ics file calledumn_calendar_to_google.ics
. - Follow step 2 from Import Events to Google Calendar.
koval048@umn.edu
and I'll give it a look.
Excel Formula Tool
Typing long expressions into Google sheets or MS excel can be a challenge. A friend thought a tool that used an math expression editor to generate an excel-style formula would be helpful. I already had a half-way expression parser sitting around from Koval's 3D Grapher, so I repurposed it here. A few notes
- Be sure to separate adjacent variables with "*". I.e. write "a*b*c" (\(a\cdot b\cdot c\)) instead of "abc" (\(abc\)).
- Both Google Sheets and MS Excel have
very strange behavior when evaluating exponents prefixed with a negative sign. Turns out
that
-x^n = (-x)^n
in excel language. Instead write-1*x^n
.