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 %} + +