UMN Schedule to Google Calendar
Updated Fall 2024
Thanks to Haroms Terfassa for alerting me to remote errors.
Thanks to Rohan Dogra for help debugging the "no class" issue.
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; } // In some instances (e.g. Thanksgiving), myU will have classes for the // day as normal, but enclose them in a display='none' tag and // include a message with a ".no-class" class saying something like "No classes today" if(dayGroup.querySelector('.no-class')){ continue; } // 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 splitDetails = details.split('\n'); if(splitDetails[1].toLowerCase() === 'remote'){ let [type, rem, time, place] = splitDetails; weekData[day].push({name:name,time:time,place:'Remote',type:type}); } else { let [type, time, place] = splitDetails; 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.
alank399@gmail.com
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
.