/* global chrome */
import LRU from "lru-cache";
const gapi_base = 'https://www.googleapis.com/calendar/v3';
const GApiError = Object.freeze({
invalidSyncToken: Symbol("invalidSyncToken"),
notLoggedIn: Symbol("notLoggedIn"),
notLoggedOut: Symbol("notLoggedOut"),
otherError: Symbol("otherError"),
});
function to_params(dict) {
return Object.entries(dict).filter(([k, v]) => v).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
}
let loggedIn = null;
function _getAuthToken(interactive = false) {
return new Promise(resolver =>
chrome.identity.getAuthToken(
{ interactive }, token => resolver([token, !chrome.runtime.lastError])))
.then(([token, ok]) => {
if (ok) return token;
else throw GApiError.notLoggedIn;
});
}
function _removeCachedAuthToken(token) {
return new Promise(resolver =>
chrome.identity.removeCachedAuthToken({ token }, () => resolver()));
}
export function getLoggedIn() {
if (loggedIn === null)
{
return _getAuthToken(false)
.then(() => loggedIn = true)
.catch(() => loggedIn = false)
.then(() => loggedIn);
}
else return Promise.resolve(loggedIn);
}
export function getAuthToken() {
return getLoggedIn().then(b => {
if (b) return _getAuthToken(false);
else throw GApiError.notLoggedIn;
});
}
export function login() {
return getLoggedIn().then(b => {
if (!b) return _getAuthToken(true).then(() => loggedIn = true);
else throw GApiError.notLoggedOut;
});
}
export function logout() {
return getAuthToken().then(token => {
return fetch(`https://accounts.google.com/o/oauth2/revoke?${to_params({ token })}`,
{ method: 'GET', async: true }).then(response => {
if (response.status === 200)
return _removeCachedAuthToken(token);
else throw GApiError.otherError;
});
}).then(() => loggedIn = false);
}
export function getCalendars(token) {
return fetch(`${gapi_base}/users/me/calendarList?${to_params({access_token: token})}`,
{ method: 'GET', async: true })
.then(response => response.json())
.then(data => data.items);
}
export function getColors(token) {
return fetch(`${gapi_base}/colors?${to_params({access_token: token})}`,
{ method: 'GET', async: true })
.then(response => response.json());
}
function getEvent(calId, eventId, token) {
return fetch(`${gapi_base}/calendars/${calId}/events/${eventId}?${to_params({access_token: token})}`,
{ method: 'GET', async: true })
.then(response => response.json());
}
function getEvents(calId, token, syncToken=null, timeMin=null, timeMax=null, resultsPerRequest=100) {
let results = [];
const singleFetch = (pageToken, syncToken) => fetch(`${gapi_base}/calendars/${calId}/events?${to_params({
access_token: token,
pageToken,
syncToken,
timeMin,
timeMax,
maxResults: resultsPerRequest
})}`, { method: 'GET', async: true })
.then(response => {
if (response.status === 200)
return response.json();
else if (response.status === 410)
throw GApiError.invalidSyncToken;
else throw GApiError.otherError;
})
.then(data => {
results.push(...data.items);
if (data.nextPageToken) {
return singleFetch(data.nextPageToken, '');
} else {
return ({
nextSyncToken: data.nextSyncToken,
results
});
}
})
return singleFetch('', syncToken);
}
export class GCalendar {
constructor(calId, name, options={maxCachedItems: 100, nDaysPerSlot: 10, largeQuery: 10}) {
this.calId = calId;
this.name = name;
this.syncToken = '';
this.cache = new LRU({
max: options.maxCachedItems,
dispose: (k, v) => this.onRemoveSlot(k, v)
});
this.eventMeta = {};
this.options = options;
this.divider = 8.64e7 * this.options.nDaysPerSlot;
}
get token() { return getAuthToken(); }
dateToCacheKey(date) {
return Math.floor(date / this.divider);
}
dateRangeToCacheKeys(range) {
return {
start: this.dateToCacheKey(range.start),
end: this.dateToCacheKey(new Date(range.end.getTime() - 1))
};
}
getSlot(k) {
if (!this.cache.has(k))
{
let res = {};
this.cache.set(k, res);
return res;
}
else return this.cache.get(k);
}
onRemoveSlot(k, v) {
for (let id in v) {
console.assert(this.eventMeta[id]);
let keys = this.eventMeta[id].keys;
keys.delete(k);
if (keys.size === 0)
delete this.eventMeta[id];
}
}
slotStartDate(k) { return new Date(k * this.divider); }
slotEndDate(k) { return new Date((k + 1) * this.divider); }
addEvent(e, evict = false) {
//console.log('adding event', e);
if (this.eventMeta.hasOwnProperty(e.id))
this.removeEvent(e);
let r = this.dateRangeToCacheKeys(e);
let ks = r.start;
let ke = r.end;
let t = this.cache.length;
let keys = new Set();
for (let i = ks; i <= ke; i++)
{
keys.add(i);
if (!this.cache.has(i)) t++;
}
this.eventMeta[e.id] = {
keys,
summary: e.summary,
};
if (!evict && t > this.options.maxCachedItems) return;
if (ks === ke)
this.getSlot(ks)[e.id] = {
start: e.start,
end: e.end,
id: e.id };
else
{
this.getSlot(ks)[e.id] = {
start: e.start,
end: this.slotEndDate(ks),
id: e.id };
this.getSlot(ke)[e.id] = {
start: this.slotStartDate(ke),
end: e.end,
id: e.id };
for (let k = ks + 1; k < ke; k++)
this.getSlot(k)[e.id] = {
start: this.slotStartDate(k),
end: this.slotEndDate(k),
id: e.id};
}
}
removeEvent(e) {
let keys = this.eventMeta[e.id].keys;
console.assert(keys);
keys.forEach(k => delete this.getSlot(k)[e.id]);
delete this.eventMeta[e.id];
}
getSlotEvents(k, start, end) {
let s = this.getSlot(k);
//console.log(s);
let results = [];
for (let id in s) {
if (!(s[id].start >= end || s[id].end <= start))
{
results.push({
id,
start: s[id].start < start ? start: s[id].start,
end: s[id].end > end ? end: s[id].end,
summary: this.eventMeta[id].summary
});
}
}
return results;
}
getCachedEvents(_r) {
let r = this.dateRangeToCacheKeys(_r);
let ks = r.start;
let ke = r.end;
let results = this.getSlotEvents(ks, _r.start, _r.end);
for (let k = ks + 1; k < ke; k++)
{
let s = this.getSlot(k);
for (let id in s)
results.push(s[id]);
}
if (ke > ks)
results.push(...this.getSlotEvents(ke, _r.start, _r.end));
return results;
}
sync() {
return this.token.then(token => getEvents(this.calId, token, this.syncToken).then(r => {
let pms = r.results.map(e => e.start ? Promise.resolve(e) : getEvent(this.calId, e.id, token));
return Promise.all(pms).then(results => {
results.forEach(e => {
e.start = new Date(e.start.dateTime);
e.end = new Date(e.end.dateTime);
if (e.status === 'confirmed')
this.addEvent(e);
else if (e.status === 'cancelled')
this.removeEvent(e);
});
this.syncToken = r.nextSyncToken;
});
})).catch(e => {
if (e === GApiError.invalidSyncToken) {
this.syncToken = '';
this.sync();
} else throw e;
});
}
getEvents(start, end) {
let r = this.dateRangeToCacheKeys({ start, end });
let query = {};
for (let k = r.start; k <= r.end; k++)
if (!this.cache.has(k))
{
if (!query.hasOwnProperty('start'))
query.start = k;
query.end = k;
}
console.log(`start: ${start} end: ${end}`);
if (query.hasOwnProperty('start'))
{
console.assert(query.start <= query.end);
if (query.end - query.start + 1 > this.options.largeQuery) {
console.log(`encounter large query, use direct fetch`);
return this.token.then(token => getEvents(this.calId, token, null,
start.toISOString(), end.toISOString()).then(r => {
let results = [];
r.results.forEach(e => {
console.assert(e.start);
e.start = new Date(e.start.dateTime);
e.end = new Date(e.end.dateTime);
results.push(e);
});
return results.filter(e => !(e.start >= end || e.end <= start)).map(e => {
return {
id: e.id,
start: e.start < start ? start: e.start,
end: e.end > end ? end: e.end,
summary: e.summary,
};
});
}));
}
console.log(`fetching short event list`);
return this.token.then(token => getEvents(this.calId, token, null,
this.slotStartDate(query.start).toISOString(),
this.slotEndDate(query.end).toISOString()).then(r => {
r.results.forEach(e => {
if (e.status === 'confirmed')
{
console.assert(e.start);
e.start = new Date(e.start.dateTime);
e.end = new Date(e.end.dateTime);
this.addEvent(e, true);
}
});
if (this.syncToken === '')
this.syncToken = r.nextSyncToken;
})).then(() => this.sync())
.then(() => this.getCachedEvents({ start, end }));
}
else
{
console.log(`cache hit`);
return this.sync().then(() => this.getCachedEvents({ start, end }));
}
}
}