/* global chrome */ import LRU from "lru-cache"; const gapiBase = 'https://www.googleapis.com/calendar/v3'; export enum GApiError { invalidSyncToken = "invalidSyncToken", invalidAuthToken = "invalidAuthToken", notLoggedIn = "notLoggedIn", notLoggedOut = "notLoggedOut", fetchError = "fetchError", otherError = "otherError", } function toParams(dict: Object) { return Object.entries(dict).filter(([k, v] : string[]) => v) .map(([k, v]: string[]) => ( `${encodeURIComponent(k)}=${encodeURIComponent(v)}` )).join('&'); } async function _getAuthToken(interactive = false): Promise { let [token, ok]: [string, boolean] = await new Promise(resolver => chrome.identity.getAuthToken( { interactive }, token => resolver([token, !chrome.runtime.lastError]))); if (ok) return token; else throw GApiError.notLoggedIn; } function _removeCachedAuthToken(token: string) { return new Promise(resolver => chrome.identity.removeCachedAuthToken({ token }, () => resolver())); } export class Auth { _loggedIn: boolean; constructor() { this._loggedIn = null; } async loggedIn(): Promise { if (this._loggedIn === null) { try { await _getAuthToken(false); this._loggedIn = true; } catch(_) { this._loggedIn = false; } } return this._loggedIn; } async getAuthToken(): Promise { let b = await this.loggedIn(); //if (b) return _getAuthToken(false); // FIXME: Chrome OS dev has a bug if (b) return _getAuthToken(true); else throw GApiError.notLoggedIn; } async login(): Promise { let b = await this.loggedIn(); if (!b) { await _getAuthToken(true); this._loggedIn = true; } else throw GApiError.notLoggedOut; } async logout(): Promise { let token = await this.getAuthToken(); this._loggedIn = false; let response = await fetch( `https://accounts.google.com/o/oauth2/revoke?${toParams({ token })}`, { method: 'GET' }); //if (response.status === 200) await _removeCachedAuthToken(token); //else throw GApiError.otherError; } } export type GCalendarColor = { background: string }; export type GCalendarMeta = { name: string, color: GCalendarColor, enabled: boolean }; export async function getCalendars(token: string): Promise { let response = await fetch( `${gapiBase}/users/me/calendarList?${toParams({access_token: token})}`, { method: 'GET' }); try { return (await response.json()).items; } catch (err) { console.log(err); throw GApiError.fetchError; } } export async function getColors(token: string): Promise { let response = await fetch( `${gapiBase}/colors?${toParams({access_token: token})}`, { method: 'GET' }); try { return response.json(); } catch (err) { console.log(err); throw GApiError.fetchError; } } async function getEvent(calId: string, eventId: string, token: string): Promise { let response = await fetch( `${gapiBase}/calendars/${calId}/events/${eventId}?${toParams({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; try { response = await fetch(`${gapiBase}/calendars/${calId}/events?${toParams({ access_token: token, pageToken, syncToken, timeMin, timeMax, maxResults: resultsPerRequest })}`, { method: 'GET' }); } catch (err) { console.log(err); throw GApiError.fetchError; } switch (response.status) { case 200: { let data = await response.json(); results.push(...data.items); if (data.nextPageToken) { return singleFetch(data.nextPageToken, ''); } else { return ({ nextSyncToken: data.nextSyncToken, results }); } break; } case 410: throw GApiError.invalidSyncToken; break; case 401: throw GApiError.invalidAuthToken; break; default: throw GApiError.otherError; } }; return singleFetch('', syncToken); } export type GCalendarOptions = { maxCachedItems: number, nDaysPerSlot: number, largeQuery: number }; type EventFlat = { start: number, end: number, id: string }; class Event { start: Date; end: Date; id: string; constructor(start: Date, end: Date, id: string) { this.start = start; this.end = end; this.id = id; } deflate() { return { start: this.start.getTime(), end: this.end.getTime(), id: this.id }; } static inflate = (obj: EventFlat) => ( new Event(new Date(obj.start), new Date(obj.end), obj.id) ); } export type GCalendarEventFlat = { start: number, end: number, id: string, summary: string }; export class GCalendarEvent { start: Date; end: Date; id: string; summary: string; constructor(start: Date, end: Date, id: string, summary: string) { this.start = start; this.end = end; this.id = id; this.summary = summary; } deflate() { return { start: this.start.getTime(), end: this.end.getTime(), id: this.id, summary: this.summary }; } static inflate = (obj: GCalendarEventFlat) => ( new GCalendarEvent(new Date(obj.start), new Date(obj.end), obj.id, obj.summary) ); } type GCalendarSlot = { [id: string]: Event }; type GCalendarSlotFlat = { [id: string]: EventFlat }; export type GCalendarFlat = { calId: string, name: string, syncToken: string, cache: {k: number, v: GCalendarSlotFlat, e: number}[], eventMeta: { [id: string]: { keys: number[], summary: string } }, options: GCalendarOptions, divider: number }; export class GCalendar { calId: string; name: string; syncToken: string; cache: LRU; eventMeta: { [id: string]: { keys: Set, summary: string } }; options: GCalendarOptions; divider: number; auth: Auth; constructor(calId: string, name: string, auth: Auth, options={maxCachedItems: 100, nDaysPerSlot: 10, largeQuery: 10}) { this.calId = calId; this.name = name; this.auth = auth; 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; } deflate() { let cache = this.cache.dump().map(t => { let slot: GCalendarSlotFlat = {}; for (let id in t.v) slot[id] = t.v[id].deflate(); return { k: t.k, v: slot, e: t.e }; }); let eventMeta: { [id: string]: { keys: number[], summary: string } } = {}; for (let id in this.eventMeta) { let m = this.eventMeta[id]; eventMeta[id] = { keys: Array.from(m.keys), summary: m.summary }; } return { calId: this.calId, name: this.name, syncToken: this.syncToken, cache, eventMeta, options: this.options, divider: this.divider } } static inflate(obj: GCalendarFlat, auth: Auth) { let cache = obj.cache.map(t => { let slot: GCalendarSlot = {}; for (let id in t.v) slot[id] = Event.inflate(t.v[id]); return { k: t.k, v: slot, e: t.e }; }); let eventMeta: { [id: string]: { keys: Set, summary: string } } = {}; for (let id in obj.eventMeta) { let m = obj.eventMeta[id]; eventMeta[id] = { keys: new Set(m.keys), summary: m.summary }; } let gcal = new GCalendar(obj.calId, obj.name, auth, obj.options); gcal.syncToken = obj.syncToken; gcal.cache.load(cache); gcal.eventMeta = eventMeta; gcal.divider = obj.divider; return gcal; } get token() { return this.auth.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); 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] = new Event(e.start, e.end, e.id); else { this.getSlot(ks)[e.id] = new Event(e.start, this.slotEndDate(ks), e.id); this.getSlot(ke)[e.id] = new Event(this.slotStartDate(ke), e.end, e.id); for (let k = ks + 1; k < ke; k++) this.getSlot(k)[e.id] = new Event(this.slotStartDate(k), this.slotEndDate(k), e.id); } } removeEvent(e: {id: string}) { if (!this.eventMeta.hasOwnProperty(e.id)) return; 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(new GCalendarEvent( s[id].start < r.start ? r.start: s[id].start, s[id].end > r.end ? r.end: s[id].end, id, 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(new GCalendarEvent( s[id].start, s[id].end, s[id].id, this.eventMeta[id].summary)); } if (ke > ks) results.push(...this.getSlotEvents(ke, _r)); return results; } async sync(): Promise { let changed = false; 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); changed = true; } else if (e.status === 'cancelled') { this.removeEvent(e); changed = true; } }); if (this.syncToken !== r.nextSyncToken) changed = true; this.syncToken = r.nextSyncToken; return changed; } catch(err) { if (err === GApiError.invalidSyncToken) { this.syncToken = ''; return this.sync(); } else throw err; } } async getEvents(start: Date, end: Date, sync = false): Promise<{ events: GCalendarEvent[], changed: boolean }> { 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()); let events = 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 => ( new GCalendarEvent( e.start < start ? start: e.start, e.end > end ? end: e.end, e.id, e.summary) )); return { events, changed: false }; } 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; if (sync) await this.sync(); let events = await this.getCachedEvents({ start, end }); return { events, changed: true }; } else { let changed = false; if (sync) changed = await this.sync(); let events = await this.getCachedEvents({ start, end }); console.log(`cache hit sync:${sync} changed:${changed}`); return { events, changed }; } } }