<!DOCTYPE html>
<html>
<head>
<!-- css for the calendar -->
<link data-require="bootstrap-css@3.0.2" data-semver="3.0.2" rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.2/css/bootstrap.min.css" />
<link data-require="fullcalendar@1.6.1" data-semver="1.6.1" rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/fullcalendar/1.6.1/fullcalendar.min.css" />
<!-- jquery & angular frameworks (must come in this order!) -->
<script data-require="jquery@*" data-semver="2.0.3" src="http://code.jquery.com/jquery-2.0.3.min.js"></script>
<script data-require="angular.js@1.2.2" data-semver="1.2.2" src="http://code.angularjs.org/1.2.2/angular.js"></script>
<!-- fullCalendar plus the anguluar adapter -->
<script data-require="fullcalendar@1.6.1" data-semver="1.6.1" src="//cdnjs.cloudflare.com/ajax/libs/fullcalendar/1.6.1/fullcalendar.min.js"></script>
<script src="angular-ui-calendar.js"></script>
<!-- Google API client library and helper -->
<script src="https://apis.google.com/js/client.js"></script>
<script src="gapi-helper.js"></script>
</head>
<body ng-app="CalendarApp">
<div ng-controller="CalendarController">
<!--Add a button for the user to click to initiate auth sequence -->
<button id="authorize-button" ng-show="authNeeded" ng-click="requestAuth()">Authorize</button>
<button id="logout-button" ng-hide="authNeeded" onclick="window.open('https://accounts.google.com/logout')">Sign Out</button>
<div class="calendar" ng-model="eventSources" calendar="calendar" config="uiConfig.calendar" ui-calendar="uiConfig.calendar"></div>
</div>
<!-- the app -->
<script src="app.js"></script>
<script src="factories.js"></script>
<script src="controller.js"></script>
</body>
</html>
/**
* https://github.com/dr-skot/gapi-helper
*
*/
gapi_helper = {
start: new Date(),
listeners: {},
status: {}
};
// for logging elapsed times to console
gapi_helper.time = function() {
return new Date() - gapi_helper.start;
};
// config must contain clientId, apiKey, scopes, and services to load, eg
// { clientId: "<id>", apiKey: "<key>", scopes: "https://www.googleapis.com/auth/calendar",
// services: { calendar: 'v3' } }
gapi_helper.configure = function(config) {
console.log("gapi configured %s", gapi_helper.time());
// TODO: confirm valid config
gapi_helper.config = config;
if (gapi_helper.status.scriptLoaded) gapi_helper.init();
};
gapi_helper.onScriptLoad = function() {
console.log("gapi script loaded %s", gapi_helper.time());
gapi_helper.status.scriptLoaded = true;
if (gapi_helper.config) gapi_helper.init();
};
// this synonym is needed by the '?onload=' construction, which seems to choke on object notatation
// use «script src="https://apis.google.com/js/client.js?onload=gapi_helper_onScriptLoad»
gapi_helper_onScriptLoad = gapi_helper.onScriptLoad;
gapi_helper.init = function() {
console.log("gapi_helper.init %s", gapi_helper.time());
gapi.client.setApiKey(gapi_helper.config.apiKey);
window.setTimeout(gapi_helper.checkAuth, 1);
};
gapi_helper.checkAuth = function() {
console.log("gapi_helper.checkAuth %s", gapi_helper.time());
gapi.auth.authorize({
client_id: gapi_helper.config.clientId,
scope: gapi_helper.config.scopes,
immediate: true
}, gapi_helper.handleAuthResult);
};
gapi_helper.handleAuthResult = function(authResult) {
console.log("gapi_helper.handleAuthResult %s", gapi_helper.time());
if (authResult && !authResult.error) {
gapi_helper.fireEvent('authorized');
gapi_helper.loadServices();
} else {
gapi_helper.fireEvent('authFailed');
}
};
gapi_helper.requestAuth = function(event) {
console.log("gapi_helper.requestAuth %s", gapi_helper.time());
gapi.auth.authorize({
client_id: gapi_helper.config.clientId,
scope: gapi_helper.config.scopes,
immediate: false,
cookie_policy: "single_host_origin"
}, gapi_helper.handleAuthResult);
return false; // so you can use this as an onclick handler
};
gapi_helper.loadServices = function() {
console.log("gapi_helper.loadServices %s", gapi_helper.time());
function eventFirer(name, version) {
return function(result) {
console.log("%s %s loaded %s %O", name, version, gapi_helper.time(), result);
gapi_helper.fireEvent(name + 'Loaded');
};
}
for (var name in gapi_helper.config.services) {
var version = gapi_helper.config.services[name];
gapi.client.load(name, version, eventFirer(name, version));
}
};
// TODO add gapi_helper.logout
gapi_helper.when = function(eventName, callback) {
// if event has already happened, trigger the callback immediately
if (gapi_helper.status[eventName]) callback();
// in any case, add the callback to the listeners array
if (!gapi_helper.listeners[eventName]) gapi_helper.listeners[eventName] = [];
gapi_helper.listeners[eventName].push(callback);
console.log('gapi_helper: registered listener for %s', eventName);
};
gapi_helper.fireEvent = function(eventName) {
console.log("firing %s", eventName);
// register event
gapi_helper.status[eventName] = true;
// trigger listeners
var listeners = gapi_helper.listeners[eventName] || [];
for (var i = 0; i < listeners.length; i++) {
listeners[i]();
}
};
gapi_helper.watcher = setInterval(function() {
var loaded = typeof gapi !== "undefined" && gapi.client;
console.log("%s %s", loaded ? "gapi loaded" : "waiting", gapi_helper.time());
if (loaded) {
clearTimeout(gapi_helper.watcher);
gapi_helper.onScriptLoad();
}
}, 500);
app.controller("CalendarController", function($scope, $location, CalendarData, EventSourceFactory) {
$scope.eventSources = [];
$scope.authNeeded = false;
// load calendars from google and pass them as event sources to fullcalendar
$scope.loadSources = function() {
EventSourceFactory.getEventSources().then(function(result) {
$scope.$log.debug("event sources %O", result);
$scope.eventSources = result;
angular.forEach(result, function(source) {
$scope.calendar.fullCalendar('addEventSource', source);
});
});
};
// request Google authorization from the user
$scope.requestAuth = function() {
gapi_helper.requestAuth();
};
// configure gapi-helper
// (you'll have to change these values for your own app)
gapi_helper.configure({
clientId: '721457523650-pjoaci65s9ob0241fbb151i3u7sjvv3r.apps.googleusercontent.com',
apiKey: 'AIzaSyDY4uF058d78YHd7SPaF8bH0aoJqPGKXFU',
scopes: 'https://www.googleapis.com/auth/calendar',
services: {
calendar: 'v3'
}
});
// set authNeeded to appropriate value on auth events
gapi_helper.when('authorized', function() {
$scope.$apply(function() {
$scope.authNeeded = false;
});
});
gapi_helper.when('authFailed', function() {
$scope.$apply(function() {
$scope.authNeeded = true;
});
});
// load the event sources when the calendar api is loaded
gapi_helper.when('calendarLoaded', $scope.loadSources);
});
app = angular.module('CalendarApp', ['ui.calendar']);
app.run(function($rootScope, $log){
$rootScope.$log = $log;
});
app.factory('CalendarData', function($q, $log) {
var self = {};
// gets the calendar list from Google
self.getCalendars = function() {
var deferred = $q.defer();
var request = gapi.client.calendar.calendarList.list();
request.execute(function(resp) {
$log.debug("CalendarData.getCalendars response %O", resp);
deferred.resolve(resp.items);
});
return deferred.promise;
};
// fetches events from a particular calendar
// start and end dates (optional) must be RFC 3339 format
self.getEvents = function(calendarId, start, end) {
var deferred = $q.defer();
if (gapi_helper.status.calendarLoaded) {
var request = gapi.client.calendar.events.list({
calendarId: calendarId,
timeMin: start,
timeMax: end,
maxResults: 10000, // max results causes problems: http://goo.gl/FqwIFh
singleEvents: true
});
request.execute(function(resp) {
$log.debug("CalendarData.getEvents response %O", resp);
deferred.resolve(resp.items || []);
});
} else {
deferred.reject([]);
}
return deferred.promise;
};
return self;
});
app.factory("EventSourceFactory", function($q, CalendarData) {
var self = {};
self.eventCache = {};
// if cached data is older than this, don't display it; wait for server data
self.displayableTime = 1000 * 60 * 5; // 5 minutes
// if cached data is younger than this, don't bother hitting the server at all
self.noUpdateTime = 1000 * 30; // 30 seconds
// (if age falls inbetween, display cached, then query server in the bg to update cache)
// converts unix timestamp to Google API date format (RFC 3339)
self.ts2googleDate = function(ts) {
return $.fullCalendar.formatDate($.fullCalendar.parseDate(ts), 'u');
};
// reformats events from Google's API into an object fullcalendar can use
self.google2fcEvent = function(google) {
var fc = {
title: google.summary,
start: google.start.date || google.start.dateTime,
end: google.end.date || google.end.dateTime,
allDay: google.start.date ? true : false,
google: google // keep a reference to the original
};
if (fc.allDay) {
// subtract 1 from end date: Google all-day end dates are exclusive
// FullCalendar's are inclusive
var end = $.fullCalendar.parseDate(fc.end);
end.setDate(end.getDate() - 1);
fc.end = $.fullCalendar.formatDate(end, 'yyyy-MM-dd');
}
return fc;
};
// fetches events from Google
// callback is called with the results when they arrive
self.fetchEvents = function(calendarId, start, end, callback) {
start = self.ts2googleDate(start);
end = self.ts2googleDate(end);
CalendarData.getEvents(calendarId, start, end).then(function(result) {
callback(result.map(self.google2fcEvent));
});
};
// gets events, possibly from the cache if it's not stale
self.getEvents = function(calendarId, start, end, callback) {
var key = calendarId + '|' + start + '|' + end;
var cached = self.eventCache[key];
var now = new Date();
var displayCached = false,
updateCache = true;
if (cached) {
var age = new Date().getTime() - cached.date.getTime();
displayCached = age <= self.displayableTime;
updateCache = age > self.noUpdateTime;
}
// cached data is ok to display? then display it
if (displayCached) {
callback(cached.data);
}
// do we need to update the cache with fresh data from Google?
if (updateCache) {
self.fetchEvents(calendarId, start, end, function(data) {
self.eventCache[key] = {
date: new Date(),
data: data
};
// display the fresh data if we didn't display the cached data
if (!displayCached) callback(data);
});
}
};
// converts a calendar object from Google's API to a fullcalendar event source
self.google2fcEventSource = function(calendar) {
return {
events: function(start, end, callback) {
self.getEvents(calendar.id, start, end, callback);
},
textColor: calendar.foregroundColor,
color: calendar.backgroundColor,
google: calendar // keep a reference to the original
};
};
// gets event sources for all calendars in the user's Google account
self.getEventSources = function() {
var deferred = $q.defer();
CalendarData.getCalendars().then(function(result) {
sources = result.map(self.google2fcEventSource);
deferred.resolve(sources);
}, function(error) {
$scope.$log("EventSourceFactory.getEventSources error %O", error);
deferred.reject(error);
});
return deferred.promise;
};
return self;
});
/*
* AngularJs Fullcalendar Wrapper for the JQuery FullCalendar
* API @ http://arshaw.com/fullcalendar/
*
* Angular Calendar Directive that takes in the [eventSources] nested array object as the ng-model and watches it deeply changes.
* Can also take in multiple event urls as a source object(s) and feed the events per view.
* The calendar will watch any eventSource array and update itself when a change is made.
*
*/
angular.module('ui.calendar', [])
.constant('uiCalendarConfig', {})
.directive('uiCalendar', ['uiCalendarConfig', '$parse', function(uiCalendarConfig) {
uiCalendarConfig = uiCalendarConfig || {};
var sourceSerialId = 1, eventSerialId = 1;
//returns calendar
return {
require: 'ngModel',
scope: {ngModel:'=',config:'='},
restrict: 'A',
link: function(scope, elm, attrs) {
var sources = scope.ngModel;
scope.destroy = function(){
if(attrs.calendar){
scope.calendar = scope.$parent[attrs.calendar] = elm.html('');
}else{
scope.calendar = elm.html('');
}
};
scope.destroy();
scope.init = function(){
var options = { eventSources: sources };
angular.extend(options, uiCalendarConfig, attrs.uiCalendar ? scope.$parent.$eval(attrs.uiCalendar) : {});
scope.calendar.fullCalendar(options);
};
scope.init();
// Track changes in array by assigning id tokens to each element and watching the scope for changes in those tokens
// arguments:
// arraySource array of function that returns array of objects to watch
// tokenFn function(object) that returns the token for a given object
var changeWatcher = function(arraySource, tokenFn) {
var self;
var getTokens = function() {
var array = angular.isFunction(arraySource) ? arraySource() : arraySource;
return array.map(function(el) {
var token = tokenFn(el);
map[token] = el;
return token;
});
};
// returns elements in that are in a but not in b
// subtractAsSets([4, 5, 6], [4, 5, 7]) => [6]
var subtractAsSets = function(a, b) {
var result = [], inB = {}, i, n;
for (i = 0, n = b.length; i < n; i++) {
inB[b[i]] = true;
}
for (i = 0, n = a.length; i < n; i++) {
if (!inB[a[i]]) {
result.push(a[i]);
}
}
return result;
};
// Map objects to tokens and vice-versa
var map = {};
var applyChanges = function(newTokens, oldTokens) {
var i, n, el, token;
var replacedTokens = {};
var removedTokens = subtractAsSets(oldTokens, newTokens);
for (i = 0, n = removedTokens.length; i < n; i++) {
var removedToken = removedTokens[i];
el = map[removedToken];
delete map[removedToken];
var newToken = tokenFn(el);
// if the element wasn't removed but simply got a new token, its old token will be different from the current one
if (newToken === removedToken) {
self.onRemoved(el);
} else {
replacedTokens[newToken] = removedToken;
self.onChanged(el);
}
}
var addedTokens = subtractAsSets(newTokens, oldTokens);
for (i = 0, n = addedTokens.length; i < n; i++) {
token = addedTokens[i];
el = map[token];
if (!replacedTokens[token]) {
self.onAdded(el);
}
}
};
return self = {
subscribe: function(scope) {
scope.$watch(getTokens, applyChanges, true);
},
onAdded: angular.noop,
onChanged: angular.noop,
onRemoved: angular.noop
};
};
//= tracking sources added/removed
var eventSourcesWatcher = changeWatcher(sources, function(source) {
return source.__id || (source.__id = sourceSerialId++);
});
eventSourcesWatcher.subscribe(scope);
eventSourcesWatcher.onAdded = function(source) {
scope.calendar.fullCalendar('addEventSource', source);
};
eventSourcesWatcher.onRemoved = function(source) {
scope.calendar.fullCalendar('removeEventSource', source);
};
//= tracking individual events added/changed/removed
var allEvents = function() {
// return sources.flatten(); but we don't have flatten
var arraySources = [];
for (var i = 0, srcLen = sources.length; i < srcLen; i++) {
var source = sources[i];
if (angular.isArray(source)) {
arraySources.push(source);
}
}
return Array.prototype.concat.apply([], arraySources);
};
var eventsWatcher = changeWatcher(allEvents, function(e) {
if (!e.__uiCalId) {
e.__uiCalId = eventSerialId++;
}
// This extracts all the information we need from the event. http://jsperf.com/angular-calendar-events-fingerprint/3
return "" + e.__uiCalId + (e.id || '') + (e.title || '') + (e.url || '') + (+e.start || '') + (+e.end || '') +
(e.allDay || false) + (e.className || '');
});
eventsWatcher.subscribe(scope);
eventsWatcher.onAdded = function(event) {
scope.calendar.fullCalendar('renderEvent', event);
};
eventsWatcher.onRemoved = function(event) {
scope.calendar.fullCalendar('removeEvents', function(e) { return e === event; });
};
eventsWatcher.onChanged = function(event) {
scope.calendar.fullCalendar('updateEvent', event);
};
}
};
}]);