diff options
author | Determinant <ted.sybil@gmail.com> | 2019-02-13 01:11:31 -0500 |
---|---|---|
committer | Determinant <ted.sybil@gmail.com> | 2019-02-13 01:11:31 -0500 |
commit | c594888953151ddfb4ca04b7752bfd51edc1d6da (patch) | |
tree | 59b6d0b0f514f76d152eee9a4359c08110f73531 /src/gapi.ts | |
parent | f28b818cc62c7fff67517a4147e64f08ebd73027 (diff) |
WIP: migrate to TypeScriptX
Diffstat (limited to 'src/gapi.ts')
-rw-r--r-- | src/gapi.ts | 369 |
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 }); + } + } +} |