From 7ae95417b31a89b5d7a6a57e390ffda48200c524 Mon Sep 17 00:00:00 2001 From: Derek Lucas Date: Mon, 27 Jan 2020 16:42:39 -0500 Subject: [PATCH] Support multiple calendars We can now add multiple Google calendar ids at `events.js#138` so that not everything needs to be maintained by us. But! Even better, we can now add any iCal URL to the `_data/icals.yml` list! That means we can now pull directly from Meetup without having to copy anything into our calendar. (I've definitely missed some meetups that we want listed. Please comment with them, or open a PR. Also, to deploy this, we'll need to remove a few events from our google calendar.) --- .bundle/config | 2 +- _data/icals.yml | 11 ++ assets/js/events.js | 282 ++++++++++++++++------------ assets/js/ical.js | 445 ++++++++++++++++++++++++++++++++++++++++++++ events.html | 8 +- 5 files changed, 623 insertions(+), 125 deletions(-) create mode 100644 _data/icals.yml create mode 100644 assets/js/ical.js diff --git a/.bundle/config b/.bundle/config index df3c2f2..942410f 100644 --- a/.bundle/config +++ b/.bundle/config @@ -1,2 +1,2 @@ --- -BUNDLE_JOBS: 8 +BUNDLE_JOBS: "8" diff --git a/_data/icals.yml b/_data/icals.yml new file mode 100644 index 0000000..9896384 --- /dev/null +++ b/_data/icals.yml @@ -0,0 +1,11 @@ +- https://www.meetup.com/chadevs/events/ical/ +- https://www.meetup.com/Carbon-Five-Chattanooga-Hack-Nights/events/ical/ +- https://www.meetup.com/Programming-Interview-Practice/events/ical/ +- https://www.meetup.com/ChattanoogaJS/events/ical/ +- https://www.meetup.com/Chattanooga-Python-User-Group/events/ical/ +- https://www.meetup.com/Papers-We-Love-Chattanooga/events/ical/ +- https://www.meetup.com/CHA-Art-Dev/events/ical/ +- https://www.meetup.com/Chattanooga-Elixir/events/ical/ +- https://www.meetup.com/Chattanooga-Game-Development-Meetup/events/ical/ +- https://www.meetup.com/chattanoogaphp/events/ical/ +- https://www.meetup.com/Chattanooga-Drupal-Users-Group/events/ical/ diff --git a/assets/js/events.js b/assets/js/events.js index 95f8fbe..4a6368e 100644 --- a/assets/js/events.js +++ b/assets/js/events.js @@ -1,32 +1,130 @@ +let days = [ + "Sun", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat" +]; +let months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' +]; + +var arrangedEvents = {}; + +class Event { + constructor(title, content, location, start) { + let address = ''; + try { + address = location.replace(', United States', ''); + } catch (e) { + // Do nothing + } + + this.title = title; + this.address = address; + this.content = this.autolink(content); + this.start = this.startDate(start); + this.time = this.prettyTime(this.start); + this.meridian = this.meridian(this.start); + } + + prettyTime(start) { + return hour(start) + ":" + minutes(start); + + function hour(time) { + var h = time.getHours(); + return h > 12 ? h - 12 : h; + } + + function minutes(time) { + var m = time.getUTCMinutes(); + return m < 10 ? "0" + m : m; + } + } + + meridian(time) { + return time.getHours() >= 12 ? 'PM' : 'AM'; + } + + startDate(start) { + var date = start.dateTime ? start.dateTime : start.date; + date = date ? date : start; + return new Date(date); + } + + autolink(text) { + if (typeof (text) == 'undefined') { return text; } + + // http://jsfiddle.net/kachibito/hEgvc/1/light/ + return text.replace(/((http|https|ftp):\/\/[\w?=&.\/-;#~%-]+(?![\w\s?&.\/;#~%"=-]*>))/g, "$1"); + } +} + +Event.prettyDate = function (start) { + var day = days[start.getDay()], + month = months[start.getMonth()], + date = start.getDate(); + return day + ", " + month + " " + date; +} + +function dateKey(date) { + return date.getFullYear() + '-' + date.getMonth() + '-' + date.getDate(); +} + +function addEvent(obj) { + var key = dateKey(obj.start); + var date = arrangedEvents[key] || { events: [], prettyDate: '' }; + date.prettyDate = date.prettyDate || Event.prettyDate(obj.start); + date.events.push(obj); + arrangedEvents[key] = date; +} + +const loadRSS = () => + icalURLs.forEach(fetchRSS); + +function fetchRSS(url) { + fetch('https://cors-anywhere.herokuapp.com/' + url) + .then(response => response.text()) + .then(cal => ical.parseICS(cal)) + .then(data => { + for (let k in data) { + if (data.hasOwnProperty(k)) { + var item = data[k]; + if (data[k].type == 'VEVENT') { + addEvent(new Event( + item.summary.replace('/', '/'), + item.description, + item.location, + item.start, + )); + } + } + } + }) + .then(render); +} + function init() { window.body = document.body; - window.cal = document.getElementById('calendar'); - window.days = [ - "Sun", - "Mon", - "Tue", - "Wed", - "Thu", - "Fri", - "Sat" - ]; - window.months = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December' - ]; + window.cal = document.getElementById('calendar'); window.tmpl = document.getElementById('template').innerHTML; window.cal.innerHTML = ''; window.body.classList.add('loading'); + + loadRSS(); gapi.client.setApiKey('AIzaSyCp_IflIV150pu3Quu-XDIaM7tMYlfO4DQ'); gapi.client.load('calendar', 'v3').then(execute); } @@ -34,65 +132,54 @@ function init() { function execute() { var start = new Date(); start.setTime(start.getTime() - (60 * 60 * 24 * 5)); - displayEventsFor(start); + const end = getEndOfMonth(start); + + [ + '4qc3thgj9ocunpfist563utr6g@group.calendar.google.com', // Chadev + ].forEach(function (calendar) { + displayEventsFor(start, end, calendar); + }) } -function displayEventsFor(start, end) { - end = end || getEndOfMonth(start); - var currentStart = start; - var request = gapi.client.calendar.events.list({ - "calendarId": "4qc3thgj9ocunpfist563utr6g@group.calendar.google.com", +function displayEventsFor(start, end, calendar) { + gapi.client.calendar.events.list({ + "calendarId": calendar, "singleEvents": "True", "orderBy": "startTime", "timeMin": start.toISOString(), - "timeMax": end.toISOString() - - }); - request.then(displayEvents); - - function getEndOfMonth(start) { - var end = new Date(start.getTime()); - end.setMonth(start.getMonth() + 1); - end = new Date(end - (24 * 60 * 60 * 1000)); - end.setHours(23); - end.setMinutes(59); - end.setSeconds(59); - end.setMilliseconds(999); - return end; - } + "timeMax": end.toISOString(), + }) + .then(addEvents) + .then(render); } -function displayEvents(data) { - var events = [], - arrangedEvents = {}, - today = Date.now(); - - data.result.items.forEach(function(item) { - var obj = {}; - obj.title = item.summary.replace('/', '/'); - obj.content = autolink(item.description); - obj.start = startDate(item.start); - obj.time = prettyTime(obj.start); - obj.meridian = meridian(obj.start); - console.log(item); - - try { - obj.address = item.location.replace(', United States', ''); - } catch(e) { - // Do nothing - } +function getEndOfMonth(start) { + var end = new Date(start.getTime()); + end.setMonth(start.getMonth() + 1); + end = new Date(end - (24 * 60 * 60 * 1000)); + end.setHours(23); + end.setMinutes(59); + end.setSeconds(59); + end.setMilliseconds(999); + return end; +} - var key = dateKey(obj.start); - var date = arrangedEvents[key] || { events: [], prettyDate: '' }; - date.prettyDate = date.prettyDate || prettyDate(obj.start); - date.events.push(obj); - arrangedEvents[key] = date; +function addEvents(data) { + data.result.items.forEach(function (item) { + addEvent(new Event( + item.summary.replace('/', '/'), + item.description, + item.location, + item.start, + )); }); +} - arrangedEvents = convertToArray(arrangedEvents); +function render() { + const dates = convertToArray(arrangedEvents); var templatedata = { - 'dates': arrangedEvents + 'dates': dates }; window.cal.innerHTML = window.Mustache.render(window.tmpl, templatedata); @@ -100,59 +187,8 @@ function displayEvents(data) { return; - function prettyTime(start) { - return hour(start) + ":" + minutes(start); - - function hour(time) { - var h = time.getHours(); - return h > 12 ? h - 12 : h; - } - - function minutes(time) { - var m = time.getUTCMinutes(); - return m < 10 ? "0" + m : m; - } - - } - - function meridian(time) { - return time.getHours() >= 12 ? 'PM' : 'AM'; - } - - function prettyDate(start) { - var day = days[start.getDay()], - month = months[start.getMonth()], - date = start.getDate(); - return day + ", " + month + " " + date; - } - - function startDate(start) { - var date = start.dateTime ? start.dateTime : start.date; - return new Date(date); - } - - function dateKey(date) { - return date.getFullYear() + '-' + date.getMonth() + '-' + date.getDate(); - } - - function autolink(text) { - if(typeof(text) == 'undefined') { return text; } - - // http://jsfiddle.net/kachibito/hEgvc/1/light/ - return text.replace(/((http|https|ftp):\/\/[\w?=&.\/-;#~%-]+(?![\w\s?&.\/;#~%"=-]*>))/g,"$1"); - } - - function formatTime(date) { - var output = []; - output.push((date.getHours() % 12) || 12); - output.push(':'); - output.push(('0' + date.getMinutes()).slice(-2)); - output.push(date.getHours() > 11 ? ' p.m.' : ' a.m.'); - return output.join(''); - } - function convertToArray(obj) { - var arr = Object.keys(obj).map(function(key) { + var arr = Object.keys(obj).map(function (key) { return obj[key]; }); diff --git a/assets/js/ical.js b/assets/js/ical.js new file mode 100644 index 0000000..b6ba647 --- /dev/null +++ b/assets/js/ical.js @@ -0,0 +1,445 @@ +(function (name, definition) { + + /**************** + * A tolerant, minimal icalendar parser + * (http://tools.ietf.org/html/rfc5545) + * + * + * **************/ + + if (typeof module !== 'undefined') { + module.exports = definition(); + } else if (typeof define === 'function' && typeof define.amd === 'object') { + define(definition); + } else { + this[name] = definition(); + } + +}('ical', function () { + + // Unescape Text re RFC 4.3.11 + var text = function (t) { + t = t || ""; + return (t + .replace(/\\\,/g, ',') + .replace(/\\\;/g, ';') + .replace(/\\[nN]/g, '\n') + .replace(/\\\\/g, '\\') + ) + } + + var parseParams = function (p) { + var out = {} + for (var i = 0; i < p.length; i++) { + if (p[i].indexOf('=') > -1) { + var segs = p[i].split('='); + + out[segs[0]] = parseValue(segs.slice(1).join('=')); + + } + } + return out || sp + } + + var parseValue = function (val) { + if ('TRUE' === val) + return true; + + if ('FALSE' === val) + return false; + + var number = Number(val); + if (!isNaN(number)) + return number; + + return val; + } + + var storeValParam = function (name) { + return function (val, curr) { + var current = curr[name]; + if (Array.isArray(current)) { + current.push(val); + return curr; + } + + if (current != null) { + curr[name] = [current, val]; + return curr; + } + + curr[name] = val; + return curr + } + } + + var storeParam = function (name) { + return function (val, params, curr) { + var data; + if (params && params.length && !(params.length == 1 && params[0] === 'CHARSET=utf-8')) { + data = { params: parseParams(params), val: text(val) } + } + else + data = text(val) + + return storeValParam(name)(data, curr); + } + } + + var addTZ = function (dt, params) { + var p = parseParams(params); + + if (params && p) { + dt.tz = p.TZID + } + + return dt + } + + var dateParam = function (name) { + return function (val, params, curr) { + + var newDate = text(val); + + + if (params && params[0] === "VALUE=DATE") { + // Just Date + + var comps = /^(\d{4})(\d{2})(\d{2})$/.exec(val); + if (comps !== null) { + // No TZ info - assume same timezone as this computer + newDate = new Date( + comps[1], + parseInt(comps[2], 10) - 1, + comps[3] + ); + + newDate = addTZ(newDate, params); + newDate.dateOnly = true; + + // Store as string - worst case scenario + return storeValParam(name)(newDate, curr) + } + } + + + //typical RFC date-time format + var comps = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(val); + if (comps !== null) { + if (comps[7] == 'Z') { // GMT + newDate = new Date(Date.UTC( + parseInt(comps[1], 10), + parseInt(comps[2], 10) - 1, + parseInt(comps[3], 10), + parseInt(comps[4], 10), + parseInt(comps[5], 10), + parseInt(comps[6], 10) + )); + // TODO add tz + } else { + newDate = new Date( + parseInt(comps[1], 10), + parseInt(comps[2], 10) - 1, + parseInt(comps[3], 10), + parseInt(comps[4], 10), + parseInt(comps[5], 10), + parseInt(comps[6], 10) + ); + } + + newDate = addTZ(newDate, params); + } + + + // Store as string - worst case scenario + return storeValParam(name)(newDate, curr) + } + } + + + var geoParam = function (name) { + return function (val, params, curr) { + storeParam(val, params, curr) + var parts = val.split(';'); + curr[name] = { lat: Number(parts[0]), lon: Number(parts[1]) }; + return curr + } + } + + var categoriesParam = function (name) { + var separatorPattern = /\s*,\s*/g; + return function (val, params, curr) { + storeParam(val, params, curr) + if (curr[name] === undefined) + curr[name] = val ? val.split(separatorPattern) : [] + else + if (val) + curr[name] = curr[name].concat(val.split(separatorPattern)) + return curr + } + } + + // EXDATE is an entry that represents exceptions to a recurrence rule (ex: "repeat every day except on 7/4"). + // The EXDATE entry itself can also contain a comma-separated list, so we make sure to parse each date out separately. + // There can also be more than one EXDATE entries in a calendar record. + // Since there can be multiple dates, we create an array of them. The index into the array is the ISO string of the date itself, for ease of use. + // i.e. You can check if ((curr.exdate != undefined) && (curr.exdate[date iso string] != undefined)) to see if a date is an exception. + // NOTE: This specifically uses date only, and not time. This is to avoid a few problems: + // 1. The ISO string with time wouldn't work for "floating dates" (dates without timezones). + // ex: "20171225T060000" - this is supposed to mean 6 AM in whatever timezone you're currently in + // 2. Daylight savings time potentially affects the time you would need to look up + // 3. Some EXDATE entries in the wild seem to have times different from the recurrence rule, but are still excluded by calendar programs. Not sure how or why. + // These would fail any sort of sane time lookup, because the time literally doesn't match the event. So we'll ignore time and just use date. + // ex: DTSTART:20170814T140000Z + // RRULE:FREQ=WEEKLY;WKST=SU;INTERVAL=2;BYDAY=MO,TU + // EXDATE:20171219T060000 + // Even though "T060000" doesn't match or overlap "T1400000Z", it's still supposed to be excluded? Odd. :( + // TODO: See if this causes any problems with events that recur multiple times a day. + var exdateParam = function (name) { + return function (val, params, curr) { + var separatorPattern = /\s*,\s*/g; + curr[name] = curr[name] || []; + var dates = val ? val.split(separatorPattern) : []; + dates.forEach(function (entry) { + var exdate = new Array(); + dateParam(name)(entry, params, exdate); + + if (exdate[name]) { + if (typeof exdate[name].toISOString === 'function') { + curr[name][exdate[name].toISOString().substring(0, 10)] = exdate[name]; + } else { + console.error("No toISOString function in exdate[name]", exdate[name]); + } + } + } + ) + return curr; + } + } + + // RECURRENCE-ID is the ID of a specific recurrence within a recurrence rule. + // TODO: It's also possible for it to have a range, like "THISANDPRIOR", "THISANDFUTURE". This isn't currently handled. + var recurrenceParam = function (name) { + return dateParam(name); + } + + var addFBType = function (fb, params) { + var p = parseParams(params); + + if (params && p) { + fb.type = p.FBTYPE || "BUSY" + } + + return fb; + } + + var freebusyParam = function (name) { + return function (val, params, curr) { + var fb = addFBType({}, params); + curr[name] = curr[name] || [] + curr[name].push(fb); + + storeParam(val, params, fb); + + var parts = val.split('/'); + + ['start', 'end'].forEach(function (name, index) { + dateParam(name)(parts[index], params, fb); + }); + + return curr; + } + } + + return { + + + objectHandlers: { + 'BEGIN': function (component, params, curr, stack) { + stack.push(curr) + + return { type: component, params: params } + } + + , 'END': function (component, params, curr, stack) { + // prevents the need to search the root of the tree for the VCALENDAR object + if (component === "VCALENDAR") { + //scan all high level object in curr and drop all strings + var key, + obj; + + for (key in curr) { + if (curr.hasOwnProperty(key)) { + obj = curr[key]; + if (typeof obj === 'string') { + delete curr[key]; + } + } + } + + return curr + } + + var par = stack.pop() + + if (curr.uid) { + // If this is the first time we run into this UID, just save it. + if (par[curr.uid] === undefined) { + par[curr.uid] = curr; + } + else { + // If we have multiple ical entries with the same UID, it's either going to be a + // modification to a recurrence (RECURRENCE-ID), and/or a significant modification + // to the entry (SEQUENCE). + + // TODO: Look into proper sequence logic. + + if (curr.recurrenceid === undefined) { + // If we have the same UID as an existing record, and it *isn't* a specific recurrence ID, + // not quite sure what the correct behaviour should be. For now, just take the new information + // and merge it with the old record by overwriting only the fields that appear in the new record. + var key; + for (key in curr) { + par[curr.uid][key] = curr[key]; + } + + } + } + + // If we have recurrence-id entries, list them as an array of recurrences keyed off of recurrence-id. + // To use - as you're running through the dates of an rrule, you can try looking it up in the recurrences + // array. If it exists, then use the data from the calendar object in the recurrence instead of the parent + // for that day. + + // NOTE: Sometimes the RECURRENCE-ID record will show up *before* the record with the RRULE entry. In that + // case, what happens is that the RECURRENCE-ID record ends up becoming both the parent record and an entry + // in the recurrences array, and then when we process the RRULE entry later it overwrites the appropriate + // fields in the parent record. + + if (curr.recurrenceid != null) { + + // TODO: Is there ever a case where we have to worry about overwriting an existing entry here? + + // Create a copy of the current object to save in our recurrences array. (We *could* just do par = curr, + // except for the case that we get the RECURRENCE-ID record before the RRULE record. In that case, we + // would end up with a shared reference that would cause us to overwrite *both* records at the point + // that we try and fix up the parent record.) + var recurrenceObj = new Object(); + var key; + for (key in curr) { + recurrenceObj[key] = curr[key]; + } + + if (recurrenceObj.recurrences != undefined) { + delete recurrenceObj.recurrences; + } + + + // If we don't have an array to store recurrences in yet, create it. + if (par[curr.uid].recurrences === undefined) { + par[curr.uid].recurrences = new Array(); + } + + // Save off our cloned recurrence object into the array, keyed by date but not time. + // We key by date only to avoid timezone and "floating time" problems (where the time isn't associated with a timezone). + // TODO: See if this causes a problem with events that have multiple recurrences per day. + if (typeof curr.recurrenceid.toISOString === 'function') { + par[curr.uid].recurrences[curr.recurrenceid.toISOString().substring(0, 10)] = recurrenceObj; + } else { + console.error("No toISOString function in curr.recurrenceid", curr.recurrenceid); + } + } + + // One more specific fix - in the case that an RRULE entry shows up after a RECURRENCE-ID entry, + // let's make sure to clear the recurrenceid off the parent field. + if ((par[curr.uid].rrule != undefined) && (par[curr.uid].recurrenceid != undefined)) { + delete par[curr.uid].recurrenceid; + } + + } + else + par[Math.random() * 100000] = curr // Randomly assign ID : TODO - use true GUID + + return par + } + + , 'SUMMARY': storeParam('summary') + , 'DESCRIPTION': storeParam('description') + , 'URL': storeParam('url') + , 'UID': storeParam('uid') + , 'LOCATION': storeParam('location') + , 'DTSTART': dateParam('start') + , 'DTEND': dateParam('end') + , 'EXDATE': exdateParam('exdate') + , ' CLASS': storeParam('class') + , 'TRANSP': storeParam('transparency') + , 'GEO': geoParam('geo') + , 'PERCENT-COMPLETE': storeParam('completion') + , 'COMPLETED': dateParam('completed') + , 'CATEGORIES': categoriesParam('categories') + , 'FREEBUSY': freebusyParam('freebusy') + , 'DTSTAMP': dateParam('dtstamp') + , 'CREATED': dateParam('created') + , 'LAST-MODIFIED': dateParam('lastmodified') + , 'RECURRENCE-ID': recurrenceParam('recurrenceid') + + }, + + + handleObject: function (name, val, params, ctx, stack, line) { + var self = this + + if (self.objectHandlers[name]) + return self.objectHandlers[name](val, params, ctx, stack, line) + + //handling custom properties + if (name.match(/X\-[\w\-]+/) && stack.length > 0) { + //trimming the leading and perform storeParam + name = name.substring(2); + return (storeParam(name))(val, params, ctx, stack, line); + } + + return storeParam(name.toLowerCase())(val, params, ctx); + }, + + + parseICS: function (str) { + var self = this + var lines = str.split(/\r?\n/) + var ctx = {} + var stack = [] + + for (var i = 0, ii = lines.length, l = lines[0]; i < ii; i++ , l = lines[i]) { + //Unfold : RFC#3.1 + while (lines[i + 1] && /[ \t]/.test(lines[i + 1][0])) { + l += lines[i + 1].slice(1) + i += 1 + } + + var kv = l.split(":") + + if (kv.length < 2) { + // Invalid line - must have k&v + continue; + } + + // Although the spec says that vals with colons should be quote wrapped + // in practise nobody does, so we assume further colons are part of the + // val + var value = kv.slice(1).join(":") + , kp = kv[0].split(";") + , name = kp[0] + , params = kp.slice(1) + + ctx = self.handleObject(name, value, params, ctx, stack, l) || {} + } + + // type and params are added to the list of items, get rid of them. + delete ctx.type + delete ctx.params + + return ctx + } + + } +})) diff --git a/events.html b/events.html index f54d1a1..069d13e 100644 --- a/events.html +++ b/events.html @@ -5,7 +5,9 @@ @@ -47,6 +49,10 @@

{{{ title }}}

{{/dates.length}} {% endraw %} + +