├── .gitignore ├── convert.txt ├── static ├── robots.txt ├── favicon.ico └── index.html ├── model.py ├── README.md ├── template.ics ├── app.yaml ├── app.py ├── tests.py └── convert.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | -------------------------------------------------------------------------------- /convert.txt: -------------------------------------------------------------------------------- 1 | convert.py -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rmmh/uiuccalendar/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /model.py: -------------------------------------------------------------------------------- 1 | from google.appengine.ext import db 2 | 3 | 4 | class Schedule(db.Model): 5 | text = db.TextProperty() 6 | date = db.DateTimeProperty(auto_now_add=True) 7 | ip = db.StringProperty() 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | View the [website](https://uiuccalendar.appspot.com). 2 | 3 | This is a small Google App Engine site that takes calendars from course registration websites 4 | and converts them into ICS files for import into Google Calendar, Outlook, iCal, etc. 5 | 6 | It was originally written for University of Illinois at Urbana-Champaign's site, but works for 7 | most colleges that use Ellucian's software with names likes "Banner Student" or "Self Service" 8 | and a "Student Schedule - Concise" page. 9 | -------------------------------------------------------------------------------- /template.ics: -------------------------------------------------------------------------------- 1 | BEGIN:VCALENDAR 2 | PRODID:-//Ryan Hitchman//UIUC Calendar//EN 3 | VERSION:2.0 4 | CALSCALE:GREGORIAN 5 | METHOD:PUBLISH 6 | X-WR-CALNAME:UIUC Classes 7 | X-WR-TIMEZONE:America/Chicago 8 | X-WR-CALDESC:Classes taken at UIUC 9 | {% for c in classes %} 10 | BEGIN:VEVENT 11 | SUMMARY:{{c.course}} - {{c.title}} {{c.section}} 12 | LOCATION:{{c.location}} 13 | DESCRIPTION:{{c.section}} - {{c.instructor}}\nCRN: {{c.crn}} 14 | DTSTART;TZID=America/Chicago:{{c.time_begin}} 15 | DTEND;TZID=America/Chicago:{{c.time_end}} 16 | RRULE:FREQ=WEEKLY;WKST=MO;UNTIL={{c.class_end}};BYDAY={{c.days}} 17 | TRANSP:OPAQUE 18 | END:VEVENT 19 | {% endfor %} 20 | END:VCALENDAR 21 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: python27 2 | api_version: 1 3 | threadsafe: true 4 | 5 | handlers: 6 | - url: /static 7 | static_dir: static 8 | 9 | - url: /convert.py 10 | static_files: convert.txt 11 | upload: convert.txt 12 | mime_type: text/plain 13 | 14 | - url: /favicon.ico 15 | static_files: static/favicon.ico 16 | upload: static/favicon.ico 17 | 18 | - url: /robots.txt 19 | static_files: static/robots.txt 20 | upload: static/robots.txt 21 | 22 | - url: / 23 | static_files: static/index.html 24 | upload: static/index.html 25 | 26 | - url: /uiuc_calendar.ics 27 | script: app.app 28 | 29 | libraries: 30 | - name: webapp2 31 | version: latest 32 | - name: jinja2 33 | version: latest 34 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | from xml.sax.saxutils import unescape 3 | 4 | from google.appengine.ext import ndb 5 | 6 | import webapp2 7 | import jinja2 8 | 9 | import convert 10 | 11 | 12 | JINJA_ENV = jinja2.Environment( 13 | loader=jinja2.FileSystemLoader(os.path.dirname(__file__))) 14 | 15 | 16 | class Schedule(ndb.Model): 17 | text = ndb.TextProperty() 18 | date = ndb.DateTimeProperty(auto_now_add=True) 19 | ip = ndb.StringProperty() 20 | 21 | 22 | class UIUCCalendar(webapp2.RequestHandler): 23 | def get(self): 24 | return self.post() 25 | 26 | def post(self): 27 | schedule = self.request.get('schedule', '') 28 | schedule = unescape(schedule) 29 | 30 | if len(schedule) > 20000: 31 | self.error(413) # error: content too long 32 | return 33 | 34 | sched = Schedule(text=schedule, ip=self.request.remote_addr) 35 | 36 | classes = convert.parse_schedule(schedule) 37 | 38 | if classes == []: 39 | self.response.out.write("error: unable to find any valid class " 40 | "entries. Try re-reading the instructions, and make " 41 | "sure that you've copied the table correctly.") 42 | self.response.set_status(400) 43 | return 44 | 45 | template = JINJA_ENV.get_template('template.ics') 46 | self.response.headers['Content-Type'] = 'application/octet-stream' 47 | self.response.out.write(template.render({"classes": classes})) 48 | 49 | sched.put() 50 | 51 | 52 | app = webapp2.WSGIApplication([('/.*', UIUCCalendar)]) 53 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | UIUC Calendar Converter 5 | 6 | 7 | 8 |

UIUC Calendar Converter

9 | This site converts UIUC course schedules into something you can use with Google Calendar, Outlook, or iCal. 10 |
11 |

Instructions

12 | 13 |
    14 |
  1. Open up the "Student Schedule - Concise" page on UI-Integrate Self-Service. (Registration & Records → Classic Registration → Student Schedule - Concise)
  2. 15 |
  3. Copy the class table and paste it into the form below. 16 | 17 |

    Each line should look something like this: 39725 GER 102 B Beginning German II Urbana-Champaign 4.000 1U Aug 22, 2011 Dec 07, 2011 MTWR 9:00 am - 9:50 am Foreign Languages Building G46 Castle

    18 |

    Any lines that can't be understood, including ones where class times are "TBA", will be ignored.

  4. 19 | 20 |
  5. Click "Convert" and save the file.
  6. 21 |
  7. Import it into your favorite calendar program.
  8. 22 |
23 | 24 |
25 | 26 |
27 |
28 | 29 | 30 |

Importing into Google Calendar

31 |
    32 |
  1. Create a calendar for UIUC Courses if you don't already have one.
  2. 33 |
  3. Click the down-arrow next to Other calendars.
  4. 34 |
  5. Select "Import calendar".
  6. 35 |
  7. Click "Choose file" and find the file that you just saved, then click Open.
  8. 36 |
  9. Select the calendar where you'd like to import events, then click Import.
  10. 37 |
38 | 39 |

(c) 2011 Ryan Hitchman | Contact | Source | GitHub

40 | 41 | 42 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | # execute using nosegae: 2 | # nosetests --with-gae --gae-lib-root=$HOME/google_appengine tests 3 | 4 | import unittest 5 | 6 | import webtest 7 | 8 | import app 9 | 10 | 11 | class HandlerTest(unittest.TestCase): 12 | def setUp(self): 13 | self.testapp = webtest.TestApp(app.app) 14 | self.testbed.activate() 15 | self.testbed.init_memcache_stub() 16 | self.testbed.init_datastore_v3_stub() 17 | 18 | def make_fake_schedule(self, 19 | start='Jan 1, 2014', 20 | end='Jun 1, 2014', 21 | days='M', 22 | time='8:00 am - 9:30 am'): 23 | return ('123\tCS123 A1\tNAME\tUC\t8\t1U\t{start}' 24 | '\t{end}\t{days}\t{time}\tLOC\tTEACHER').format( 25 | start=start, 26 | end=end, 27 | days=days, 28 | time=time) 29 | 30 | def get_calendar_resp(self, schedule): 31 | return self.testapp.post('/uiuc_calendar.ics', {'schedule': schedule}, 32 | expect_errors=True) 33 | 34 | def get_calendar(self, **kwargs): 35 | resp = self.get_calendar_resp(self.make_fake_schedule(**kwargs)) 36 | self.assertEqual(resp.status_int, 200) 37 | return resp.body 38 | 39 | def test_empty(self): 40 | resp = self.testapp.post('/uiuc_calendar.ics', expect_errors=True) 41 | self.assertEqual(resp.status_int, 400) 42 | 43 | def test_blank(self): 44 | resp = self.get_calendar_resp('') 45 | self.assertEqual(resp.status_int, 400) 46 | 47 | def test_normal(self): 48 | schedule = '\n'.join( 49 | [ 50 | '43357\tCS 412 P3\tData Mining\tUrbana-Champaign\t3.000\t1U\tAug 22, 2011' 51 | '\tDec 07, 2011\tWF\t3:30 pm - 4:45 pm\tSiebel Center 1404\tHan', 52 | '30128\tCS 421 D3\tCompilers\tUrbana-Champaign\t3.000\t1U\tAug 22, 2011' 53 | '\tDec 07, 2011\tTR\t2:00 pm - 3:15 pm\tSiebel Center 1404\tGunter', 54 | '45328\tCS 242 AB1\tMixtape\tUrbana-Champaign\t0.000\t1U\tAug 22, 2011' 55 | '\tDec 07, 2011\tTBA\tSiebel Center for Comp Sci ARR\tWoodley' 56 | ]) 57 | resp = self.get_calendar_resp(schedule) 58 | self.assertEqual(resp.status_int, 200) 59 | self.assertIn('Data Mining', resp.body) 60 | self.assertIn('Compilers', resp.body) 61 | self.assertNotIn('Mixtape', resp.body) # TBA class -- skipped 62 | 63 | def test_time_am(self): 64 | ics = self.get_calendar(time='8:00 am - 9:50 am') 65 | self.assertRegexpMatches(ics, r'DTSTART.*T080000') 66 | self.assertRegexpMatches(ics, r'DTEND.*T095000') 67 | 68 | def test_time_pm(self): 69 | ics = self.get_calendar(time='3:00 pm - 4:50 pm') 70 | self.assertRegexpMatches(ics, r'DTSTART.*T150000') 71 | self.assertRegexpMatches(ics, r'DTEND.*T165000') 72 | 73 | def test_byday(self): 74 | ics = self.get_calendar(days='MTWRF') 75 | self.assertIn(';BYDAY=MO,TU,WE,TH,FR\n', ics) 76 | 77 | def test_repeat_span(self): 78 | ics = self.get_calendar(start='Jan 10, 2014', end='May 5, 2014') 79 | self.assertRegexpMatches(ics, r'DTSTART.*:20140110T') 80 | self.assertRegexpMatches(ics, r'RRULE:FREQ=WEEKLY.*UNTIL=20140505T') 81 | 82 | def test_datastore(self): 83 | ics = self.get_calendar() 84 | entries = list(app.Schedule.query()) 85 | self.assertEqual(len(entries), 1) 86 | self.assertIn('CS123', entries[0].text) 87 | -------------------------------------------------------------------------------- /convert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # Ryan Hitchman, 2011 3 | # public domain under CC0 / WTFPL 4 | 5 | from datetime import datetime, timedelta 6 | import logging 7 | 8 | 9 | def hour_from_ampm(hour, ispm): 10 | # why can't python's strptime just implement %p properly? 11 | # working with dates: completely terrible in every language 12 | if hour == 12: 13 | if not ispm: 14 | hour = 0 15 | elif ispm: 16 | return hour + 12 17 | return hour 18 | 19 | 20 | def date_time_to_timestamp(date, clock, weekdays=None): 21 | # ("Jan 27, 2011", "8:00 pm") -> "20110127T200000" 22 | t = datetime.strptime(date.strip(), "%b %d, %Y") 23 | c = datetime.strptime(clock.strip(), "%H:%M %p") 24 | 25 | t = t.replace(hour=hour_from_ampm(c.hour, 'pm' in clock), minute=c.minute) 26 | 27 | if weekdays is not None: 28 | # move forward in time until we're on a class day 29 | while t.weekday() not in weekdays: 30 | t += timedelta(days=1) 31 | 32 | return datetime.strftime(t, "%Y%m%dT%H%M%S") 33 | 34 | 35 | def parse_class(line): 36 | split = line.split('\t') 37 | if len(split) != 12: 38 | return 39 | 40 | split = [s.strip() for s in split] 41 | 42 | (crn, course, title, campus, credits, level, 43 | start, end, days, time, location, instructor) = split 44 | 45 | if time in ('TBA', 'Time') or not time: 46 | # skip classes with no scheduled times 47 | return 48 | 49 | title = title.replace("&", "&") 50 | 51 | # time_begin needs to be on a day that class occurs. 0 is Monday. 52 | day_numbers = ['MTWRF'.index(d) for d in days] 53 | 54 | time_begin, time_end = time.split('-') 55 | time_begin = date_time_to_timestamp(start, time_begin, weekdays=day_numbers) 56 | time_end = date_time_to_timestamp(start, time_end, weekdays=day_numbers) 57 | class_end = date_time_to_timestamp(end, "11:59 pm") 58 | 59 | # convert things like "MWF" -> "MO,WE,FR" 60 | for pair in ('M MO', 'T TU', 'W WE', 'R TH', 'F FR'): 61 | one, two = pair.split() 62 | days = days.replace(one, two + ',') 63 | 64 | days = days.rstrip(' ,') 65 | 66 | if course: 67 | course, section = course.rsplit(' ', 1) 68 | else: 69 | section = '' 70 | 71 | return dict(crn=crn, course=course, section=section, title=title, 72 | campus=campus, credits=credits, level=level, 73 | start=start, end=end, days=days, time=time, 74 | time_begin=time_begin, time_end=time_end, class_end=class_end, 75 | location=location, instructor=instructor) 76 | 77 | 78 | def parse_schedule(text): 79 | classes = [] 80 | for line in text.splitlines(): 81 | try: 82 | cls = parse_class(line) 83 | if cls is not None: 84 | if not cls['course'].strip(): 85 | # for classes with multiple times, subsequent lines don't 86 | # include a lot of the information, so fill it in using the 87 | # previous class. 88 | prevcls = classes[-1] 89 | for key in 'crn course section title campus credits level'.split(): 90 | cls[key] = prevcls[key] 91 | 92 | classes.append(cls) 93 | except: 94 | logging.exception('unable to parse line: %r', line) 95 | 96 | logging.debug('schedule %r => %s', text, classes) 97 | 98 | return classes 99 | 100 | if __name__ == "__main__": 101 | import sys 102 | print(parse_schedule(str(sys.stdin.read()))) 103 | 104 | ''' 105 | Django template for formatting calendars using the output of parse_schedule: 106 | 107 | BEGIN:VCALENDAR 108 | PRODID:-//Ryan Hitchman//UIUC Calendar//EN 109 | VERSION:2.0 110 | CALSCALE:GREGORIAN 111 | METHOD:PUBLISH 112 | X-WR-CALNAME:UIUC Classes 113 | X-WR-TIMEZONE:America/Chicago 114 | X-WR-CALDESC:Classes taken at UIUC 115 | {% for c in classes %} 116 | BEGIN:VEVENT 117 | SUMMARY:{{c.course}} - {{c.title}} {{c.section}} 118 | LOCATION:{{c.location}} 119 | DESCRIPTION:{{c.section}} - {{c.instructor}}\nCRN: {{c.crn}} 120 | DTSTART;TZID=America/Chicago:{{c.time_begin}} 121 | DTEND;TZID=America/Chicago:{{c.time_end}} 122 | RRULE:FREQ=WEEKLY;WKST=MO;UNTIL={{c.class_end}};BYDAY={{c.days}} 123 | TRANSP:OPAQUE 124 | END:VEVENT 125 | {% endfor %} 126 | END:VCALENDAR 127 | ''' 128 | --------------------------------------------------------------------------------