aboutsummaryrefslogtreecommitdiff
path: root/src/gapi.ts
diff options
context:
space:
mode:
authorDeterminant <ted.sybil@gmail.com>2019-02-13 01:11:31 -0500
committerDeterminant <ted.sybil@gmail.com>2019-02-13 01:11:31 -0500
commitc594888953151ddfb4ca04b7752bfd51edc1d6da (patch)
tree59b6d0b0f514f76d152eee9a4359c08110f73531 /src/gapi.ts
parentf28b818cc62c7fff67517a4147e64f08ebd73027 (diff)
WIP: migrate to TypeScriptX
Diffstat (limited to 'src/gapi.ts')
-rw-r--r--src/gapi.ts369
1 files changed, 369 insertions, 0 deletions
diff --git a/src/gapi.ts b/src/gapi.ts
new file mode 100644
index 0000000..bf45ffc
--- /dev/null
+++ b/src/gapi.ts
@@ -0,0 +1,369 @@
+/* global chrome */
+
+import LRU from "lru-cache";
+
+const gapiBase = 'https://www.googleapis.com/calendar/v3';
+
+enum GApiError {
+ invalidSyncToken = "invalidSyncToken",
+ notLoggedIn = "notLoggedIn",
+ notLoggedOut = "notLoggedOut",
+ otherError = "otherError",
+}
+
+function to_params(dict: Object) {
+ return Object.entries(dict).filter(([k, v] : string[]) => v)
+ .map(([k, v]: string[]) => (
+ `${encodeURIComponent(k)}=${encodeURIComponent(v)}`
+ )).join('&');
+}
+
+let loggedIn = false;
+
+function _getAuthToken(interactive = false): Promise<string> {
+ return new Promise(resolver =>
+ chrome.identity.getAuthToken(
+ { interactive }, token => resolver([token, !chrome.runtime.lastError])))
+ .then(([token, ok] : [string, boolean]) => {
+ if (ok) return token;
+ else throw GApiError.notLoggedIn;
+ });
+}
+
+function _removeCachedAuthToken(token: string) {
+ 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(): Promise<string> {
+ 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 async function logout() {
+ let token = await getAuthToken();
+ let response = await fetch(
+ `https://accounts.google.com/o/oauth2/revoke?${to_params({ token })}`, { method: 'GET' });
+ //if (response.status === 200)
+ await _removeCachedAuthToken(token);
+ //else throw GApiError.otherError;
+ loggedIn = false;
+}
+
+export async function getCalendars(token: string) {
+ let response = await fetch(
+ `${gapiBase}/users/me/calendarList?${to_params({access_token: token})}`, { method: 'GET' });
+ return (await response.json()).items;
+}
+
+export async function getColors(token: string) {
+ let response = await fetch(
+ `${gapiBase}/colors?${to_params({access_token: token})}`, { method: 'GET' });
+ return response.json();
+}
+
+async function getEvent(calId: string, eventId: string, token: string) {
+ let response = await fetch(
+ `${gapiBase}/calendars/${calId}/events/${eventId}?${to_params({access_token: token})}`,
+ { method: 'GET' });
+ return response.json();
+}
+
+function getEvents(calId: string, token: string,
+ syncToken=null as string,
+ timeMin=null as string,
+ timeMax=null as string,
+ resultsPerRequest=100 as number):
+ Promise<{ results: any[], nextSyncToken: string }> {
+ let results = [] as any[];
+ const singleFetch = async (pageToken: string, syncToken: string):
+ Promise<{nextSyncToken: string, results: any[]}> => {
+ let response = await fetch(`${gapiBase}/calendars/${calId}/events?${to_params({
+ access_token: token,
+ pageToken,
+ syncToken,
+ timeMin,
+ timeMax,
+ maxResults: resultsPerRequest
+ })}`, { method: 'GET' });
+ if (response.status === 200)
+ {
+ let data = await response.json();
+ results.push(...data.items);
+ if (data.nextPageToken) {
+ return singleFetch(data.nextPageToken, '');
+ } else {
+ return ({
+ nextSyncToken: data.nextSyncToken,
+ results
+ });
+ }
+ }
+ else if (response.status === 410)
+ throw GApiError.invalidSyncToken;
+ else throw GApiError.otherError;
+ };
+
+ return singleFetch('', syncToken);
+}
+
+type GCalendarOptions = {
+ maxCachedItems: number,
+ nDaysPerSlot: number,
+ largeQuery: number
+};
+
+type GCalendarEvent = {
+ start: Date,
+ end: Date,
+ id: string
+};
+
+type GCalendarSlot = { [id: string]: GCalendarEvent };
+
+export class GCalendar {
+ calId: string;
+ name: string;
+ syncToken: string;
+ cache: LRU<number, GCalendarSlot>;
+ eventMeta: { [id: string]: { keys: Set<number>, summary: string } };
+ options: GCalendarOptions;
+ divider: number;
+
+ constructor(calId: string, name: string,
+ options={maxCachedItems: 100, nDaysPerSlot: 10, largeQuery: 10}) {
+ this.calId = calId;
+ this.name = name;
+ this.syncToken = '';
+ this.cache = new LRU<number, GCalendarSlot>({
+ 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: Date) {
+ return Math.floor(date.getTime() / this.divider);
+ }
+
+ dateRangeToCacheKeys(range: { start: Date, end: Date }) {
+ return {
+ start: this.dateToCacheKey(range.start),
+ end: this.dateToCacheKey(new Date(range.end.getTime() - 1))
+ };
+ }
+
+ getSlot(k: number) {
+ if (!this.cache.has(k))
+ {
+ let res = {};
+ this.cache.set(k, res);
+ return res;
+ }
+ else return this.cache.get(k);
+ }
+
+ onRemoveSlot(k: number, v: GCalendarSlot) {
+ for (let id in v) {
+ console.assert(this.eventMeta.hasOwnProperty(id));
+ let keys = this.eventMeta[id].keys;
+ keys.delete(k);
+ if (keys.size === 0)
+ delete this.eventMeta[id];
+ }
+ }
+
+ slotStartDate(k: number) { return new Date(k * this.divider); }
+ slotEndDate(k: number) { return new Date((k + 1) * this.divider); }
+
+ addEvent(e: {start: Date, end: Date, id: string, summary: string}, 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: {id: string}) {
+ let keys = this.eventMeta[e.id].keys;
+ keys.forEach(k => delete this.getSlot(k)[e.id]);
+ delete this.eventMeta[e.id];
+ }
+
+ getSlotEvents(k: number, r: {start: Date, end: Date}) {
+ let s = this.getSlot(k);
+ //console.log(s);
+ let results = [];
+ for (let id in s) {
+ if (!(s[id].start >= r.end || s[id].end <= r.start))
+ {
+ results.push({
+ id,
+ start: s[id].start < r.start ? r.start: s[id].start,
+ end: s[id].end > r.end ? r.end: s[id].end,
+ summary: this.eventMeta[id].summary
+ });
+ }
+ }
+ return results;
+ }
+
+ getCachedEvents(_r: {start: Date, end: Date}) {
+ let r = this.dateRangeToCacheKeys(_r);
+ let ks = r.start;
+ let ke = r.end;
+ let results = this.getSlotEvents(ks, _r);
+ for (let k = ks + 1; k < ke; k++)
+ {
+ let s = this.getSlot(k);
+ for (let id in s)
+ results.push({...s[id], summary: this.eventMeta[id].summary});
+ }
+ if (ke > ks)
+ results.push(...this.getSlotEvents(ke, _r));
+ return results;
+ }
+
+ async sync() {
+ try {
+ let token = await this.token;
+ let r = await getEvents(this.calId, token, this.syncToken);
+ let results = await Promise.all(
+ r.results.map(e => e.start ? Promise.resolve(e) : getEvent(this.calId, e.id, token)));
+ 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(err) {
+ if (err === GApiError.invalidSyncToken) {
+ this.syncToken = '';
+ this.sync();
+ } else throw err;
+ }
+ }
+
+ async getEvents(start: Date, end: Date) {
+ let r = this.dateRangeToCacheKeys({ start, end });
+ let query = {
+ start: null as number,
+ end: null as number
+ };
+ for (let k = r.start; k <= r.end; k++)
+ if (!this.cache.has(k))
+ {
+ if (query.start === null)
+ query.start = k;
+ query.end = k;
+ }
+ //console.log(`start: ${start} end: ${end}`);
+ if (query.start !== null)
+ {
+ console.assert(query.start <= query.end);
+ if (query.end - query.start + 1 > this.options.largeQuery) {
+ console.log(`encounter large query, use direct fetch`);
+ let token = await this.token;
+ let r = await getEvents(this.calId, token, null,
+ start.toISOString(), end.toISOString());
+ return r.results.map(e => {
+ console.assert(e.start);
+ e.start = new Date(e.start.dateTime);
+ e.end = new Date(e.end.dateTime);
+ return e;
+ }).filter(e => !(e.start >= end || e.end <= start)).map(e => ({
+ 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`);
+ let token = await this.token;
+ let r = await getEvents(this.calId, token, null,
+ this.slotStartDate(query.start).toISOString(),
+ this.slotEndDate(query.end).toISOString());
+ 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;
+ await this.sync();
+ return this.getCachedEvents({ start, end });
+ }
+ else
+ {
+ console.log(`cache hit`);
+ await this.sync();
+ return this.getCachedEvents({ start, end });
+ }
+ }
+}