aboutsummaryrefslogblamecommitdiff
path: root/src/gapi.js
blob: 36af9060d25c42caa3db4957d0f9c9cd5cf37da4 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
                   
                            

                                                           





                                                 
 
                          
                                                                                                                                     

 


                                             

                                     















                                                                                    

                                          



























                                                                                           


                                     
                                                                                          




                                           



                                                                           

 


                                                                                                         

                                           
 
                                                                                                     




                                                                                                             

                    




                                             
                                                 
                                                     
                                                
              
                           














                                                               
                                                                                               

                           
                            








                                                          

                                          

                                               

     




                                                                       


                






                                      

     











                                                               
 


















                                                              



                                      
                           



                                      

                                          
                                      
                                              
                           
                           

                                             


                                                 



                    



                                                        



                                  
                         



                                                            


                                                                    
                                                          
                                                       





                       




                                                               






                                         
                                                                      




                                                                                                











                                                                                                           
                        
                                                   



                                    


                           






































                                                                                              
                                            







                                                                 

                                                         







                                                                                

     
/* 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 }));
        }
    }
}