├── .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 | - Open up the "Student Schedule - Concise" page on UI-Integrate Self-Service. (Registration & Records → Classic Registration → Student Schedule - Concise)
15 | - 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.
19 |
20 | - Click "Convert" and save the file.
21 | - Import it into your favorite calendar program.
22 |
23 |
24 |
28 |
29 |
30 | Importing into Google Calendar
31 |
32 | - Create a calendar for UIUC Courses if you don't already have one.
33 | - Click the down-arrow next to Other calendars.
34 | - Select "Import calendar".
35 | - Click "Choose file" and find the file that you just saved, then click Open.
36 | - Select the calendar where you'd like to import events, then click Import.
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 |
--------------------------------------------------------------------------------