diff options
author | Determinant <[email protected]> | 2019-02-13 01:11:31 -0500 |
---|---|---|
committer | Determinant <[email protected]> | 2019-02-13 01:11:31 -0500 |
commit | c594888953151ddfb4ca04b7752bfd51edc1d6da (patch) | |
tree | 59b6d0b0f514f76d152eee9a4359c08110f73531 /src | |
parent | f28b818cc62c7fff67517a4147e64f08ebd73027 (diff) |
WIP: migrate to TypeScriptX
Diffstat (limited to 'src')
-rw-r--r-- | src/Analyze.tsx (renamed from src/Analyze.js) | 36 | ||||
-rw-r--r-- | src/Chart.tsx (renamed from src/Chart.js) | 0 | ||||
-rw-r--r-- | src/Dashboard.tsx (renamed from src/Dashboard.js) | 0 | ||||
-rw-r--r-- | src/Dialog.tsx (renamed from src/Dialog.js) | 0 | ||||
-rw-r--r-- | src/Logo.tsx (renamed from src/Logo.js) | 0 | ||||
-rw-r--r-- | src/PatternTable.tsx (renamed from src/PatternTable.js) | 0 | ||||
-rw-r--r-- | src/RegexField.tsx (renamed from src/RegexField.js) | 0 | ||||
-rw-r--r-- | src/Settings.tsx (renamed from src/Settings.js) | 16 | ||||
-rw-r--r-- | src/Snackbar.tsx (renamed from src/Snackbar.js) | 0 | ||||
-rw-r--r-- | src/background.ts (renamed from src/background.js) | 2 | ||||
-rw-r--r-- | src/decl.ts | 1 | ||||
-rw-r--r-- | src/duration.js | 21 | ||||
-rw-r--r-- | src/duration.ts | 26 | ||||
-rw-r--r-- | src/gapi.js | 335 | ||||
-rw-r--r-- | src/gapi.ts | 369 | ||||
-rw-r--r-- | src/index.js | 11 | ||||
-rw-r--r-- | src/index.tsx | 5 | ||||
-rw-r--r-- | src/msg.js | 97 | ||||
-rw-r--r-- | src/msg.ts | 87 | ||||
-rw-r--r-- | src/pattern.ts (renamed from src/pattern.js) | 46 | ||||
-rw-r--r-- | src/popup.tsx (renamed from src/popup.js) | 10 | ||||
-rw-r--r-- | src/serviceWorker.js | 135 | ||||
-rw-r--r-- | src/theme.tsx (renamed from src/theme.js) | 0 |
23 files changed, 560 insertions, 637 deletions
diff --git a/src/Analyze.js b/src/Analyze.tsx index 98e3ce2..5450998 100644 --- a/src/Analyze.js +++ b/src/Analyze.tsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import 'react-dates/initialize'; import 'react-dates/lib/css/_datepicker.css'; import { DateRangePicker } from 'react-dates'; -import { withStyles } from '@material-ui/core/styles'; +import { Theme, withStyles } from '@material-ui/core/styles'; import cyan from '@material-ui/core/colors/cyan'; import deepOrange from '@material-ui/core/colors/deepOrange'; import CssBaseline from '@material-ui/core/CssBaseline'; @@ -15,51 +15,55 @@ import Grid from '@material-ui/core/Grid'; import AddCircleIcon from '@material-ui/icons/AddCircle'; import IconButton from '@material-ui/core/IconButton'; import * as gapi from './gapi'; -import { msgType, MsgClient } from './msg'; +import { MsgType, MsgClient } from './msg'; import { Pattern, PatternEntry } from './pattern'; import { AnalyzePieChart, getChartData } from './Chart'; import PatternTable from './PatternTable'; import Snackbar from './Snackbar'; import AlertDialog from './Dialog'; +import moment from 'moment'; const default_chart_data = [ {name: 'Work', value: 10, color: cyan[300]}, {name: 'Wasted', value: 10, color: deepOrange[300]}]; -const styles = theme => ({ +const styles = (theme: Theme) => ({ buttonSpacer: { marginBottom: theme.spacing.unit * 4, }, }); class Analyze extends React.Component { + msgClient: MsgClient; + state = { - patterns: [], + patterns: [] as PatternEntry[], calendars: {}, - startDate: null, - endDate: null, + startDate: null as moment.Moment, + endDate: null as moment.Moment, patternGraphData: default_chart_data, calendarGraphData: default_chart_data, snackBarOpen: false, snackBarMsg: 'unknown', snackBarVariant: 'error', dialogOpen: false, - dialogMsg: {title: '', message: ''}, + dialogMsg: {title: '', message: ''} }; - constructor(props) { + constructor(props: any) { super(props); + this.msgClient = new MsgClient('main'); this.msgClient.sendMsg({ - type: msgType.getPatterns, + type: MsgType.getPatterns, data: { id: 'analyze' } }).then(msg => { this.setState({ patterns: msg.data.map(p => PatternEntry.inflate(p)) }); }); this.msgClient.sendMsg({ - type: msgType.getCalendars, + type: MsgType.getCalendars, data: { enabledOnly: true } }).then(msg => { this.setState({ calendars: msg.data }); @@ -71,20 +75,20 @@ class Analyze extends React.Component { this.dialogPromiseResolver = null; } - loadPatterns = patterns => { + loadPatterns = (patterns: PatternEntry[]) => { this.msgClient.sendMsg({ - type: msgType.updatePatterns, + type: MsgType.updatePatterns, data: { id: 'analyze', patterns: patterns.map(p => p.deflate()) } }).then(() => this.setState({ patterns })); }; - updatePattern = (field, idx, value) => { + updatePattern = (field: string, idx: number, value: PatternEntry[]) => { let patterns = this.state.patterns; patterns[idx][field] = value; this.loadPatterns(patterns); }; - removePattern = idx => { + removePattern = (idx: number) => { let patterns = this.state.patterns; patterns.splice(idx, 1); for (let i = 0; i < patterns.length; i++) @@ -99,8 +103,8 @@ class Analyze extends React.Component { this.loadPatterns(patterns); }; - getCalEvents = (id, start, end) => { - return this.msgClient.sendMsg({ type: msgType.getCalEvents, data: { id, + getCalEvents = (id: string, start: Date, end: Date) => { + return this.msgClient.sendMsg({ type: MsgType.getCalEvents, data: { id, start: start.getTime(), end: end.getTime() } }) .then(({ data }) => data.map(e => { diff --git a/src/Chart.js b/src/Chart.tsx index b1c36ed..b1c36ed 100644 --- a/src/Chart.js +++ b/src/Chart.tsx diff --git a/src/Dashboard.js b/src/Dashboard.tsx index 04ced46..04ced46 100644 --- a/src/Dashboard.js +++ b/src/Dashboard.tsx diff --git a/src/Dialog.js b/src/Dialog.tsx index 7e24176..7e24176 100644 --- a/src/Dialog.js +++ b/src/Dialog.tsx diff --git a/src/Logo.js b/src/Logo.tsx index a4036a9..a4036a9 100644 --- a/src/Logo.js +++ b/src/Logo.tsx diff --git a/src/PatternTable.js b/src/PatternTable.tsx index 93be293..93be293 100644 --- a/src/PatternTable.js +++ b/src/PatternTable.tsx diff --git a/src/RegexField.js b/src/RegexField.tsx index e3fa9f4..e3fa9f4 100644 --- a/src/RegexField.js +++ b/src/RegexField.tsx diff --git a/src/Settings.js b/src/Settings.tsx index 2835483..83f1da6 100644 --- a/src/Settings.js +++ b/src/Settings.tsx @@ -20,7 +20,7 @@ import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; import Checkbox from '@material-ui/core/Checkbox'; import * as gapi from './gapi'; -import { msgType, MsgClient } from './msg'; +import { MsgType, MsgClient } from './msg'; import { Pattern, PatternEntry } from './pattern'; import PatternTable from './PatternTable'; import Snackbar from './Snackbar'; @@ -137,21 +137,21 @@ class Settings extends React.Component { this.msgClient = new MsgClient('main'); this.msgClient.sendMsg({ - type: msgType.getPatterns, + type: MsgType.getPatterns, data: { id: 'main' } }).then(msg => { this.setState({ patterns: msg.data.map(p => PatternEntry.inflate(p)) }); }); this.msgClient.sendMsg({ - type: msgType.getCalendars, + type: MsgType.getCalendars, data: { enabledOnly: false } }).then(msg => { this.setState({ calendars: msg.data }); }); this.msgClient.sendMsg({ - type: msgType.getConfig, + type: MsgType.getConfig, data: ['trackedPeriods'] }).then(msg => { let config = { @@ -191,7 +191,7 @@ class Settings extends React.Component { var calendars = {...this.state.calendars}; calendars[id].enabled = !calendars[id].enabled; this.msgClient.sendMsg({ - type: msgType.updateCalendars, + type: MsgType.updateCalendars, data: calendars }).then(() => this.setState({ calendars })); } @@ -240,14 +240,14 @@ class Settings extends React.Component { calendars[id].enabled = this.state.calendars[id].enabled; } this.msgClient.sendMsg({ - type: msgType.updateCalendars, + type: MsgType.updateCalendars, data: calendars }).then(() => this.setState({ calendars })); }; loadPatterns = (patterns, id) => { this.msgClient.sendMsg({ - type: msgType.updatePatterns, + type: MsgType.updatePatterns, data: { id, patterns: patterns.map(p => p.deflate()) } }).then(() => this.setState({ patterns })); }; @@ -297,7 +297,7 @@ class Settings extends React.Component { updateTrackedPeriods = trackedPeriods => { this.msgClient.sendMsg({ - type: msgType.updateConfig, + type: MsgType.updateConfig, data: { trackedPeriods: trackedPeriods.map(p => ({ name: p.name, start: p.start.deflate(), diff --git a/src/Snackbar.js b/src/Snackbar.tsx index f17863c..f17863c 100644 --- a/src/Snackbar.js +++ b/src/Snackbar.tsx diff --git a/src/background.js b/src/background.ts index f22970e..2a23b57 100644 --- a/src/background.js +++ b/src/background.ts @@ -5,7 +5,7 @@ import moment from 'moment'; import { getChartData } from './Chart'; import { PatternEntry } from './pattern'; -let mainPatterns = []; +let mainPatterns: number[] = []; let analyzePatterns = []; let calendars = {}; let calData = {}; diff --git a/src/decl.ts b/src/decl.ts new file mode 100644 index 0000000..5b3daa3 --- /dev/null +++ b/src/decl.ts @@ -0,0 +1 @@ +declare module 'react-dates'; diff --git a/src/duration.js b/src/duration.js deleted file mode 100644 index 53de0ad..0000000 --- a/src/duration.js +++ /dev/null @@ -1,21 +0,0 @@ -import moment from 'moment'; - -export class Duration { - constructor(value, unit) { - this.value = value - this.unit = unit - } - - toMoment() { - let m = moment.duration(this.value, this.unit); - if (m.isValid()) return m; - return null; - } - - static days(n) { return new Duration(n, 'days'); } - static weeks(n) { return new Duration(n, 'weeks'); } - static months(n) { return new Duration(n, 'months'); } - - deflate() { return { value: this.value, unit: this.unit }; } - static inflate = obj => new Duration(obj.value, obj.unit); -} diff --git a/src/duration.ts b/src/duration.ts new file mode 100644 index 0000000..18849a0 --- /dev/null +++ b/src/duration.ts @@ -0,0 +1,26 @@ +import moment from 'moment'; + +export type TimeUnit = moment.unitOfTime.DurationConstructor; + +export class Duration { + value: number; + unit: TimeUnit; + constructor(value: number, unit: TimeUnit) { + this.value = value + this.unit = unit + } + + isValid() { return moment.duration(this.value, this.unit).isValid(); } + toMoment() { + let m = moment.duration(this.value, this.unit); + if (m.isValid()) return m; + return null; + } + + static days(n: number) { return new Duration(n, 'days'); } + static weeks(n: number) { return new Duration(n, 'weeks'); } + static months(n: number) { return new Duration(n, 'months'); } + + deflate() { return { value: this.value, unit: this.unit }; } + static inflate = (obj: { value: number, unit: TimeUnit }) => new Duration(obj.value, obj.unit); +} diff --git a/src/gapi.js b/src/gapi.js deleted file mode 100644 index 3938864..0000000 --- a/src/gapi.js +++ /dev/null @@ -1,335 +0,0 @@ -/* 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 })); - } - } -} 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 }); + } + } +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index adcb634..0000000 --- a/src/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import Dashboard from './Dashboard'; -import * as serviceWorker from './serviceWorker'; - -ReactDOM.render(<Dashboard />, document.getElementById('root')); - -// If you want your app to work offline and load faster, you can change -// unregister() to register() below. Note this comes with some pitfalls. -// Learn more about service workers: http://bit.ly/CRA-PWA -serviceWorker.unregister(); diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..f5c836b --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import Dashboard from './Dashboard'; + +ReactDOM.render(<Dashboard />, document.getElementById('root')); diff --git a/src/msg.js b/src/msg.js deleted file mode 100644 index 2e72ea7..0000000 --- a/src/msg.js +++ /dev/null @@ -1,97 +0,0 @@ -/* global chrome */ -const _updatePatterns = "updatePatterns"; -const _getPatterns = "getPatterns"; -const _updateCalendars = "updateCalendars"; -const _getCalendars = "getCalendars"; -const _getCalEvents = "getCalEvents"; -const _updateConfig = "updateConfig"; -const _getConfig = "getConfig"; -const _getGraphData = "getGraphData"; - -export const msgType = Object.freeze({ - updatePatterns: Symbol(_updatePatterns), - getPatterns: Symbol(_getPatterns), - updateCalendars: Symbol(_updateCalendars), - getCalendars: Symbol(_getCalendars), - getCalEvents: Symbol(_getCalEvents), - updateConfig: Symbol(_updateConfig), - getConfig: Symbol(_getConfig), - getGraphData: Symbol(_getGraphData), -}); - -function stringifyMsgType(mt) { - switch (mt) { - case msgType.updatePatterns: return _updatePatterns; - case msgType.getPatterns: return _getPatterns; - case msgType.updateCalendars: return _updateCalendars; - case msgType.getCalendars: return _getCalendars; - case msgType.getCalEvents: return _getCalEvents; - case msgType.updateConfig: return _updateConfig; - case msgType.getConfig: return _getConfig; - case msgType.getGraphData: return _getGraphData; - default: console.error("unreachable"); - } -} - -function parseMsgType(s) { - switch(s) { - case _updatePatterns: return msgType.updatePatterns; - case _getPatterns: return msgType.getPatterns; - case _updateCalendars: return msgType.updateCalendars; - case _getCalendars: return msgType.getCalendars; - case _getCalEvents: return msgType.getCalEvents; - case _updateConfig: return msgType.updateConfig; - case _getConfig: return msgType.getConfig; - case _getGraphData: return msgType.getGraphData; - default: console.error("unreachable"); - } -} - -export class Msg { - constructor(id, type, data) { - this.id = id; - this.type = type; - this.data = data; - } - genResp(data) { return new Msg(this.id, this.type, data); } - deflate() { - return { - id: this.id, - type: stringifyMsgType(this.type), - data: this.data - } - } - static inflate = obj => new Msg(obj.id, parseMsgType(obj.type), obj.data); -} - -export class MsgClient { - constructor(channelName) { - let port = chrome.runtime.connect({name: channelName}); - const getCallBack = rcb => this.requestCallback; - port.onMessage.addListener(function(msg) { - console.log(msg); - let rcb = getCallBack(msg.type); - let cb = rcb.inFlight[msg.id]; - console.assert(cb !== undefined); - rcb.ids.push(msg.id); - cb(msg); - }); - this.port = port; - this.requestCallback = {inFlight: {}, ids: [], maxId: 0}; - } - - sendMsg = ({ type, data }) => { - let rcb = this.requestCallback; - let cb; - let pm = new Promise(resolve => { cb = resolve; }); - let id; - if (rcb.ids.length > 0) { - id = rcb.ids.pop(); - } else { - id = rcb.maxId++; - } - rcb.inFlight[id] = cb; - this.port.postMessage((new Msg(id, type, data)).deflate()); - return pm; - } -} diff --git a/src/msg.ts b/src/msg.ts new file mode 100644 index 0000000..12eb2bc --- /dev/null +++ b/src/msg.ts @@ -0,0 +1,87 @@ +/* global chrome */ + +export enum MsgType { + updatePatterns = "updatePatterns", + getPatterns = "getPatterns", + updateCalendars = "updateCalendars", + getCalendars = "getCalendars", + getCalEvents = "getCalEvents", + updateConfig = "updateConfig", + getConfig = "getConfig", + getGraphData = "getGraphData" +} + +function stringifyMsgType(mt: MsgType): string { return MsgType[mt]; } + +function parseMsgType(s: string): MsgType { + switch (s) { + case "updatePatterns": return MsgType.updatePatterns; + case "getPatterns": return MsgType.getPatterns; + case "updateCalendars" : return MsgType.updateCalendars; + case "getCalendars": return MsgType.getCalendars; + case "updateConfig": return MsgType.updateConfig; + case "getConfig": return MsgType.getConfig; + case "getGraphData": return MsgType.getGraphData; + default: console.error("unreachable"); + } +} + +export class Msg<T> { + id: number; + mt: MsgType; + data: T; + constructor(id: number, mt: MsgType, data: T) { + this.id = id; + this.mt = mt; + this.data = data; + } + genResp(data: T) { return new Msg(this.id, this.mt, data); } + deflate() { + return { + id: this.id, + mt: stringifyMsgType(this.mt), + data: this.data + } + } + static inflate = <T>(obj: {id: number, mt: MsgType, data: T}) => ( + new Msg(obj.id, parseMsgType(obj.mt), obj.data) + ); +} + +export class MsgClient { + requestCallback: { + ids: number[], + inFlight: {[id: number]: (msg: Msg<any>) => any; }, + maxId: number + }; + port: chrome.runtime.Port; + + constructor(channelName: string) { + let port = chrome.runtime.connect({name: channelName}); + const rcb = this.requestCallback; + port.onMessage.addListener(function(msg) { + console.log(msg); + let cb = rcb.inFlight[msg.id]; + console.assert(cb !== undefined); + rcb.ids.push(msg.id); + cb(msg); + }); + this.port = port; + this.requestCallback = {inFlight: {}, ids: [], maxId: 0}; + } + + sendMsg({ mt, data }: { mt: MsgType, data: any }) { + const rcb = this.requestCallback; + let cb; + let pm = new Promise(resolve => { cb = resolve; }); + let id; + if (rcb.ids.length > 0) { + id = rcb.ids.pop(); + } else { + id = rcb.maxId++; + } + rcb.inFlight[id] = cb; + this.port.postMessage((new Msg(id, mt, data)).deflate()); + return pm; + } +} diff --git a/src/pattern.js b/src/pattern.ts index 858f2a3..cae35a9 100644 --- a/src/pattern.js +++ b/src/pattern.ts @@ -1,5 +1,17 @@ +interface PatternFlat { + id: number | string; + isRegex: boolean; + value: string; + label: string; +} + export class Pattern { - constructor(id, isRegex, value, label) { + id: number | string; + isRegex: boolean; + value: string; + label: string; + + constructor(id: number | string, isRegex: boolean, value: string, label: string) { this.id = id; this.isRegex = isRegex; this.value = value; @@ -18,11 +30,31 @@ export class Pattern { } static emptyPattern = () => new Pattern(0, true, '', null); static anyPattern = () => new Pattern('any', true, '.*', 'Any'); - static inflate = obj => new Pattern(obj.id, obj.isRegex, obj.value, obj.label); + static inflate = (obj: PatternFlat) => new Pattern(obj.id, obj.isRegex, obj.value, obj.label); +} + +interface PatternEntryColor { + background: string +} + +interface PatternEntryFlat { + name: string; + idx: number; + cal: PatternFlat; + event: PatternFlat; + color: PatternEntryColor; } export class PatternEntry { - constructor(name, idx, calPattern, eventPattern, color) { + name: string; + idx: number; + cal: Pattern; + event: Pattern; + color: PatternEntryColor; + + constructor(name: string, idx: number, + calPattern: Pattern, eventPattern: Pattern, + color: PatternEntryColor) { this.name = name; this.idx = idx; this.cal = calPattern; @@ -40,8 +72,12 @@ export class PatternEntry { }; } - static defaultPatternEntry = (idx) => new PatternEntry('', idx, Pattern.emptyPattern(), Pattern.anyPattern(), {background: null}); - static inflate = obj => new PatternEntry( + static defaultPatternEntry = (idx: number) => ( + new PatternEntry('', idx, + Pattern.emptyPattern(), + Pattern.anyPattern(), {background: null})); + + static inflate = (obj: PatternEntryFlat) => new PatternEntry( obj.name, obj.idx, Pattern.inflate(obj.cal), Pattern.inflate(obj.event), obj.color); diff --git a/src/popup.js b/src/popup.tsx index c93ce91..5474476 100644 --- a/src/popup.js +++ b/src/popup.tsx @@ -1,6 +1,5 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import * as serviceWorker from './serviceWorker'; import { withStyles, MuiThemeProvider } from '@material-ui/core/styles'; import Button from '@material-ui/core/Button'; import IconButton from '@material-ui/core/IconButton'; @@ -11,7 +10,7 @@ import { theme } from './theme'; import CssBaseline from '@material-ui/core/CssBaseline'; import { PatternEntry } from './pattern'; import { Duration } from './duration'; -import { msgType, MsgClient } from './msg'; +import { MsgType, MsgClient } from './msg'; import { StyledPatternPieChart } from './Chart'; import Divider from '@material-ui/core/Divider'; import moment from 'moment'; @@ -49,7 +48,7 @@ class Popup extends React.Component { loadGraphData(sync) { return this.msgClient.sendMsg({ - type: msgType.getGraphData, + type: MsgType.getGraphData, data: { sync } }).then(msg => { this.setState({ patternGraphData: msg.data.map(d => ({ @@ -111,8 +110,3 @@ class Popup extends React.Component { const StyledPopup = withStyles(styles)(Popup); ReactDOM.render(<StyledPopup />, document.getElementById('root')); - -// If you want your app to work offline and load faster, you can change -// unregister() to register() below. Note this comes with some pitfalls. -// Learn more about service workers: http://bit.ly/CRA-PWA -serviceWorker.unregister(); diff --git a/src/serviceWorker.js b/src/serviceWorker.js deleted file mode 100644 index 2283ff9..0000000 --- a/src/serviceWorker.js +++ /dev/null @@ -1,135 +0,0 @@ -// This optional code is used to register a service worker. -// register() is not called by default. - -// This lets the app load faster on subsequent visits in production, and gives -// it offline capabilities. However, it also means that developers (and users) -// will only see deployed updates on subsequent visits to a page, after all the -// existing tabs open on the page have been closed, since previously cached -// resources are updated in the background. - -// To learn more about the benefits of this model and instructions on how to -// opt-in, read http://bit.ly/CRA-PWA - -const isLocalhost = Boolean( - window.location.hostname === 'localhost' || - // [::1] is the IPv6 localhost address. - window.location.hostname === '[::1]' || - // 127.0.0.1/8 is considered localhost for IPv4. - window.location.hostname.match( - /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ - ) -); - -export function register(config) { - if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { - // The URL constructor is available in all browsers that support SW. - const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); - if (publicUrl.origin !== window.location.origin) { - // Our service worker won't work if PUBLIC_URL is on a different origin - // from what our page is served on. This might happen if a CDN is used to - // serve assets; see https://github.com/facebook/create-react-app/issues/2374 - return; - } - - window.addEventListener('load', () => { - const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; - - if (isLocalhost) { - // This is running on localhost. Let's check if a service worker still exists or not. - checkValidServiceWorker(swUrl, config); - - // Add some additional logging to localhost, pointing developers to the - // service worker/PWA documentation. - navigator.serviceWorker.ready.then(() => { - console.log( - 'This web app is being served cache-first by a service ' + - 'worker. To learn more, visit http://bit.ly/CRA-PWA' - ); - }); - } else { - // Is not localhost. Just register service worker - registerValidSW(swUrl, config); - } - }); - } -} - -function registerValidSW(swUrl, config) { - navigator.serviceWorker - .register(swUrl) - .then(registration => { - registration.onupdatefound = () => { - const installingWorker = registration.installing; - if (installingWorker == null) { - return; - } - installingWorker.onstatechange = () => { - if (installingWorker.state === 'installed') { - if (navigator.serviceWorker.controller) { - // At this point, the updated precached content has been fetched, - // but the previous service worker will still serve the older - // content until all client tabs are closed. - console.log( - 'New content is available and will be used when all ' + - 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' - ); - - // Execute callback - if (config && config.onUpdate) { - config.onUpdate(registration); - } - } else { - // At this point, everything has been precached. - // It's the perfect time to display a - // "Content is cached for offline use." message. - console.log('Content is cached for offline use.'); - - // Execute callback - if (config && config.onSuccess) { - config.onSuccess(registration); - } - } - } - }; - }; - }) - .catch(error => { - console.error('Error during service worker registration:', error); - }); -} - -function checkValidServiceWorker(swUrl, config) { - // Check if the service worker can be found. If it can't reload the page. - fetch(swUrl) - .then(response => { - // Ensure service worker exists, and that we really are getting a JS file. - const contentType = response.headers.get('content-type'); - if ( - response.status === 404 || - (contentType != null && contentType.indexOf('javascript') === -1) - ) { - // No service worker found. Probably a different app. Reload the page. - navigator.serviceWorker.ready.then(registration => { - registration.unregister().then(() => { - window.location.reload(); - }); - }); - } else { - // Service worker found. Proceed as normal. - registerValidSW(swUrl, config); - } - }) - .catch(() => { - console.log( - 'No internet connection found. App is running in offline mode.' - ); - }); -} - -export function unregister() { - if ('serviceWorker' in navigator) { - navigator.serviceWorker.ready.then(registration => { - registration.unregister(); - }); - } -} diff --git a/src/theme.js b/src/theme.tsx index 0269fd3..0269fd3 100644 --- a/src/theme.js +++ b/src/theme.tsx |