Added google helper

This commit is contained in:
Benoit Schweblin 2017-08-15 11:43:26 +01:00
parent 855e6cb056
commit 1e74fb00dc
13 changed files with 672 additions and 127 deletions

View File

@ -11,7 +11,7 @@
</button> </button>
</div> </div>
<div class="navigation-bar__inner navigation-bar__inner--right flex flex--row"> <div class="navigation-bar__inner navigation-bar__inner--right flex flex--row">
<div class="navigation-bar__spinner"> <div class="navigation-bar__spinner" v-show="showSpinner">
<div class="spinner"></div> <div class="spinner"></div>
</div> </div>
<div class="navigation-bar__title navigation-bar__title--fake text-input"></div> <div class="navigation-bar__title navigation-bar__title--fake text-input"></div>
@ -75,6 +75,9 @@ export default {
...mapGetters('layout', [ ...mapGetters('layout', [
'styles', 'styles',
]), ]),
showSpinner() {
return !this.$store.state.queue.isEmpty;
},
titleWidth() { titleWidth() {
if (!this.mounted) { if (!this.mounted) {
return 0; return 0;

View File

@ -13,16 +13,16 @@
</div> </div>
<div class="side-bar__inner"> <div class="side-bar__inner">
<div v-if="panel === 'menu'" class="side-bar__panel side-bar__panel--menu"> <div v-if="panel === 'menu'" class="side-bar__panel side-bar__panel--menu">
<side-bar-item @click.native="signin"> <side-bar-item v-if="!loginToken" @click.native="signin">
<icon-login slot="icon"></icon-login> <icon-login slot="icon"></icon-login>
<div>Sign in with Google</div> <div>Sign in with Google</div>
<span>Have all your files and settings backed up and synced.</span> <span>Have all your files and settings backed up and synced.</span>
</side-bar-item> </side-bar-item>
<side-bar-item @click.native="signin"> <!-- <side-bar-item @click.native="signin">
<icon-login slot="icon"></icon-login> <icon-login slot="icon"></icon-login>
<div>Sign in on CouchDB</div> <div>Sign in on CouchDB</div>
<span>Save and collaborate on a CouchDB hosted by you.</span> <span>Save and collaborate on a CouchDB hosted by you.</span>
</side-bar-item> </side-bar-item> -->
<side-bar-item @click.native="panel = 'toc'"> <side-bar-item @click.native="panel = 'toc'">
<icon-toc slot="icon"></icon-toc> <icon-toc slot="icon"></icon-toc>
Table of contents Table of contents
@ -44,12 +44,13 @@
</template> </template>
<script> <script>
import { mapActions } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import Toc from './Toc'; import Toc from './Toc';
import SideBarItem from './SideBarItem'; import SideBarItem from './SideBarItem';
import markdownSample from '../data/markdownSample.md'; import markdownSample from '../data/markdownSample.md';
import markdownConversionSvc from '../services/markdownConversionSvc'; import markdownConversionSvc from '../services/markdownConversionSvc';
import userSvc from '../services/userSvc'; import googleHelper from '../services/helpers/googleHelper';
import syncSvc from '../services/syncSvc';
const panelNames = { const panelNames = {
menu: 'Menu', menu: 'Menu',
@ -72,6 +73,9 @@ export default {
markdownSample: markdownConversionSvc.highlight(markdownSample), markdownSample: markdownConversionSvc.highlight(markdownSample),
}), }),
computed: { computed: {
...mapGetters('data', [
'loginToken',
]),
panelName() { panelName() {
return panelNames[this.panel]; return panelNames[this.panel];
}, },
@ -81,7 +85,10 @@ export default {
'toggleSideBar', 'toggleSideBar',
]), ]),
signin() { signin() {
userSvc.signinWithGoogle(); googleHelper.startOauth2([
'openid',
'https://www.googleapis.com/auth/drive.appdata',
]).then(() => syncSvc.requestSync());
}, },
}, },
}; };
@ -111,6 +118,10 @@ export default {
left: 1000px; left: 1000px;
} }
.side-bar__panel--menu {
padding: 5px;
}
.side-bar__panel--help { .side-bar__panel--help {
padding: 0 10px 40px 20px; padding: 0 10px 40px 20px;

View File

@ -14,7 +14,6 @@
text-align: left; text-align: left;
padding: 10px 12px; padding: 10px 12px;
height: auto; height: auto;
margin: 5px;
span { span {
display: inline-block; display: inline-block;

View File

@ -1,6 +1,5 @@
import Vue from 'vue'; import Vue from 'vue';
import './extensions/'; import './extensions/';
import './services/syncSvc';
import './services/optional'; import './services/optional';
import './icons/'; import './icons/';
import App from './components/App'; import App from './components/App';

View File

@ -0,0 +1,224 @@
import utils from '../utils';
import store from '../../store';
const clientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com';
const appsDomain = null;
const tokenExpirationMargin = 10 * 60 * 1000; // 10 min
// const scopeMap = {
// profile: [
// 'https://www.googleapis.com/auth/userinfo.profile',
// ],
// gdrive: [
// 'https://www.googleapis.com/auth/drive.install',
// store.getters['data/settings'].gdriveFullAccess === true ?
// 'https://www.googleapis.com/auth/drive' :
// 'https://www.googleapis.com/auth/drive.file',
// ],
// blogger: [
// 'https://www.googleapis.com/auth/blogger',
// ],
// picasa: [
// 'https://www.googleapis.com/auth/photos',
// ],
// };
const request = (googleToken, options) => utils.request({
...options,
headers: {
...options.headers,
Authorization: `Bearer ${googleToken.accessToken}`,
},
});
const saveFile = (googleToken, data, appData) => {
const options = {
method: 'POST',
url: 'https://www.googleapis.com/upload/drive/v2/files',
headers: {},
};
if (appData) {
options.method = 'PUT';
options.url = `https://www.googleapis.com/drive/v2/files/${appData.id}`;
options.headers['if-match'] = appData.etag;
}
const metadata = {
title: data.name,
parents: [{
id: 'appDataFolder',
}],
properties: Object.keys(data)
.filter(key => key !== 'name' && key !== 'tx')
.map(key => ({
key,
value: JSON.stringify(data[key]),
visibility: 'PUBLIC',
})),
};
const media = null;
const boundary = `-------${utils.uid()}`;
const delimiter = `\r\n--${boundary}\r\n`;
const closeDelimiter = `\r\n--${boundary}--`;
if (media) {
let multipartRequestBody = '';
multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: application/json\r\n\r\n';
multipartRequestBody += JSON.stringify(metadata);
multipartRequestBody += delimiter;
multipartRequestBody += 'Content-Type: application/json\r\n\r\n';
multipartRequestBody += JSON.stringify(media);
multipartRequestBody += closeDelimiter;
return request(googleToken, {
...options,
params: {
uploadType: 'multipart',
},
headers: {
...options.headers,
'Content-Type': `multipart/mixed; boundary="${boundary}"`,
},
body: multipartRequestBody,
});
}
return request(googleToken, {
...options,
body: metadata,
}).then(res => ({
id: res.body.id,
etag: res.body.etag,
}));
};
export default {
startOauth2(scopes, sub = null, silent = false) {
return utils.startOauth2(
'https://accounts.google.com/o/oauth2/v2/auth', {
client_id: clientId,
response_type: 'token',
scope: scopes.join(' '),
hd: appsDomain,
login_hint: sub,
prompt: silent ? 'none' : null,
}, silent)
// Call the tokeninfo endpoint
.then(data => utils.request({
method: 'POST',
url: 'https://www.googleapis.com/oauth2/v3/tokeninfo',
params: {
access_token: data.accessToken,
},
}).then((res) => {
// Check the returned client ID consistency
if (res.body.aud !== clientId) {
throw new Error('Client ID inconsistent.');
}
// Check the returned sub consistency
if (sub && res.body.sub !== sub) {
throw new Error('Google account ID not expected.');
}
// Build token object including scopes and sub
return {
scopes,
accessToken: data.accessToken,
expiresOn: Date.now() + (data.expiresIn * 1000),
sub: res.body.sub,
isLogin: !store.getters['data/loginToken'],
};
}))
// Call the tokeninfo endpoint
.then(googleToken => request(googleToken, {
method: 'GET',
url: 'https://www.googleapis.com/plus/v1/people/me',
}).then((res) => {
// Add name to googleToken
googleToken.name = res.body.displayName;
const existingToken = store.getters['data/googleTokens'][googleToken.sub];
if (existingToken) {
if (!sub) {
throw new Error('Google account already linked.');
}
// Add isLogin and lastChangeId to googleToken
googleToken.isLogin = existingToken.isLogin;
googleToken.lastChangeId = existingToken.lastChangeId;
}
// Add googleToken to googleTokens
store.dispatch('data/setGoogleToken', googleToken);
return googleToken;
}));
},
refreshToken(scopes, googleToken) {
const sub = googleToken.sub;
const lastToken = store.getters['data/googleTokens'][sub];
const mergedScopes = [...new Set([
...scopes,
...lastToken.scopes,
])];
return Promise.resolve()
.then(() => {
if (mergedScopes.length === lastToken.scopes.length) {
return lastToken;
}
// New scopes are requested, popup an authorize window
return this.startOauth2(mergedScopes, sub);
})
.then((refreshedToken) => {
if (refreshedToken.expiresOn > Date.now() + tokenExpirationMargin) {
// Token is fresh enough
return refreshedToken;
}
// Token is almost outdated, try to take one in background
return this.startOauth2(mergedScopes, sub, true)
// If it fails try to popup a window
.catch(() => this.startOauth2(mergedScopes, sub));
});
},
getChanges(googleToken) {
let changes = [];
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
.then((refreshedToken) => {
const lastChangeId = refreshedToken.lastChangeId || 0;
const getPage = pageToken => request(refreshedToken, {
method: 'GET',
url: 'https://www.googleapis.com/drive/v2/changes',
params: {
pageToken,
startChangeId: pageToken || !lastChangeId ? null : lastChangeId + 1,
spaces: 'appDataFolder',
fields: 'nextPageToken,items(deleted,file/id,file/etag,file/title,file/properties(key,value))',
},
}).then((res) => {
changes = changes.concat(res.body.items);
if (res.body.nextPageToken) {
return getPage(res.body.nextPageToken);
}
return changes;
});
return getPage();
});
},
updateLastChangeId(googleToken, changes) {
const refreshedToken = store.getters['data/googleTokens'][googleToken.sub];
let lastChangeId = refreshedToken.lastChangeId || 0;
changes.forEach((change) => {
if (change.id > lastChangeId) {
lastChangeId = change.id;
}
});
if (lastChangeId !== refreshedToken.lastChangeId) {
store.dispatch('data/setGoogleToken', {
...refreshedToken,
lastChangeId,
});
}
},
insertData(googleToken, data) {
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
.then(refreshedToken => saveFile(refreshedToken, data));
},
updateData(googleToken, data, appData) {
return this.refreshToken(['https://www.googleapis.com/auth/drive.appdata'], googleToken)
.then(refreshedToken => saveFile(refreshedToken, data, appData));
},
};

View File

@ -2,6 +2,96 @@ import localDbSvc from './localDbSvc';
import store from '../store'; import store from '../store';
import welcomeFile from '../data/welcomeFile.md'; import welcomeFile from '../data/welcomeFile.md';
import utils from './utils'; import utils from './utils';
import userActivitySvc from './userActivitySvc';
import googleHelper from './helpers/googleHelper';
const lastSyncActivityKey = 'lastSyncActivity';
let lastSyncActivity;
const getStoredLastSyncActivity = () => parseInt(localStorage[lastSyncActivityKey], 10) || 0;
const inactivityThreshold = 3 * 1000; // 3 sec
const autoSyncAfter = 60 * 1000; // 1 min
const isSyncAvailable = () => window.navigator.onLine !== false &&
!!store.getters['data/loginToken'];
function isSyncWindow() {
const storedLastSyncActivity = getStoredLastSyncActivity();
return lastSyncActivity === storedLastSyncActivity ||
Date.now() > inactivityThreshold + storedLastSyncActivity;
}
function isAutoSyncNeeded() {
const storedLastSyncActivity = getStoredLastSyncActivity();
return Date.now() > autoSyncAfter + storedLastSyncActivity;
}
function setLastSyncActivity() {
const currentDate = Date.now();
lastSyncActivity = currentDate;
localStorage[lastSyncActivityKey] = currentDate;
}
function sync() {
const googleToken = store.getters['data/loginToken'];
return googleHelper.getChanges(googleToken)
.then((changes) => {
console.log(changes);
const localChanges = [];
[
store.state.files,
].forEach((moduleState) => {
Object.keys(moduleState.itemMap).forEach((id) => {
localChanges.push(moduleState.itemMap[id]);
});
});
const uploadLocalChange = () => {
const localChange = localChanges.pop();
if (!localChange) {
return null;
}
return googleHelper.insertAppData(googleToken, localChange)
.then(() => uploadLocalChange());
};
return uploadLocalChange();
});
}
function requestSync() {
store.dispatch('queue/enqueueSyncRequest', () => new Promise((resolve, reject) => {
let intervalId;
const attempt = () => {
// Only start syncing when these conditions are met
if (userActivitySvc.isActive() && isSyncWindow()) {
clearInterval(intervalId);
if (!isSyncAvailable()) {
// Cancel sync
reject();
} else {
// Call setLastSyncActivity periodically
intervalId = utils.setInterval(() => setLastSyncActivity(), 1000);
setLastSyncActivity();
const cleaner = cb => (res) => {
clearInterval(intervalId);
cb(res);
};
sync().then(cleaner(resolve), cleaner(reject));
}
}
};
intervalId = utils.setInterval(() => attempt(), 1000);
attempt();
}));
}
// Sync periodically
utils.setInterval(() => {
if (isSyncAvailable() &&
userActivitySvc.isActive() &&
isSyncWindow() &&
isAutoSyncNeeded()
) {
requestSync();
}
}, 1000);
const ifNoId = cb => (obj) => { const ifNoId = cb => (obj) => {
if (obj.id) { if (obj.id) {
@ -12,33 +102,38 @@ const ifNoId = cb => (obj) => {
// Load the DB on boot // Load the DB on boot
localDbSvc.sync() localDbSvc.sync()
// Watch file changing // And watch file changing
.then(() => store.watch( .then(() => store.watch(
() => store.getters['files/current'].id, () => store.getters['files/current'].id,
() => Promise.resolve(store.getters['files/current']) () => Promise.resolve(store.getters['files/current'])
// If current file has no ID, get the most recent file // If current file has no ID, get the most recent file
.then(ifNoId(() => store.getters['files/mostRecent'])) .then(ifNoId(() => store.getters['files/mostRecent']))
// If still no ID, create a new file // If still no ID, create a new file
.then(ifNoId(() => { .then(ifNoId(() => {
const contentId = utils.uid(); const contentId = utils.uid();
store.commit('contents/setItem', { store.commit('contents/setItem', {
id: contentId, id: contentId,
text: welcomeFile, text: welcomeFile,
}); });
const fileId = utils.uid(); const fileId = utils.uid();
store.commit('files/setItem', { store.commit('files/setItem', {
id: fileId, id: fileId,
name: 'Welcome file', name: 'Welcome file',
contentId, contentId,
}); });
return store.state.files.itemMap[fileId]; return store.state.files.itemMap[fileId];
})) }))
.then((currentFile) => { .then((currentFile) => {
store.commit('files/setCurrentId', currentFile.id); store.commit('files/setCurrentId', currentFile.id);
store.dispatch('files/patchCurrent', {}); // Update `updated` field to make it the mostRecent store.dispatch('files/patchCurrent', {}); // Update `updated` field to make it the mostRecent
}), }), {
{
immediate: true, immediate: true,
})); }));
utils.setInterval(() => localDbSvc.sync(), 1200); // Sync local DB periodically
utils.setInterval(() => localDbSvc.sync(), 1000);
export default {
isSyncAvailable,
requestSync,
};

View File

@ -0,0 +1,28 @@
const inactiveAfter = 2 * 60 * 1000; // 2 minutes
let lastActivity;
let lastFocus;
const lastFocusKey = 'lastWindowFocus';
function setLastActivity() {
lastActivity = Date.now();
}
function setLastFocus() {
lastFocus = Date.now();
localStorage[lastFocusKey] = lastFocus;
setLastActivity();
}
setLastFocus();
window.addEventListener('focus', setLastFocus);
window.document.addEventListener('mousedown', setLastActivity);
window.document.addEventListener('keydown', setLastActivity);
export default {
isFocused() {
return parseInt(localStorage[lastFocusKey], 10) === lastFocus;
},
isActive() {
return lastActivity > Date.now() - inactiveAfter && this.isFocused();
},
};

View File

@ -1,70 +0,0 @@
import utils from './utils';
import store from '../store';
const googleClientId = '241271498917-t4t7d07qis7oc0ahaskbif3ft6tk63cd.apps.googleusercontent.com';
const appUri = 'http://localhost:8080/';
const googleAppsDomain = null;
const origin = `${location.protocol}//${location.host}`;
export default {
oauth2Context: null,
signinWithGoogle() {
this.cleanOauth2Context();
const state = utils.uid();
let authorizeUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
authorizeUrl = utils.addQueryParam(authorizeUrl, 'client_id', googleClientId);
authorizeUrl = utils.addQueryParam(authorizeUrl, 'response_type', 'token');
authorizeUrl = utils.addQueryParam(authorizeUrl, 'redirect_uri', `${appUri}oauth2/callback.html`);
authorizeUrl = utils.addQueryParam(authorizeUrl, 'state', state);
if (googleAppsDomain) {
authorizeUrl = utils.addQueryParam(authorizeUrl, 'scope', 'openid email');
authorizeUrl = utils.addQueryParam(authorizeUrl, 'hd', googleAppsDomain);
} else {
authorizeUrl = utils.addQueryParam(authorizeUrl, 'scope', 'profile email');
}
const wnd = window.open(authorizeUrl);
if (!wnd) {
return Promise.resolve();
}
return new Promise((resolve) => {
const msgHandler = (event) => {
if (event.source === wnd
&& event.origin === origin
&& event.data
&& event.data.state === state
) {
this.cleanOauth2Context();
if (event.data.accessToken) {
store.dispatch('data/patchTokens', {
googleToken: {
accessToken: event.data.accessToken,
},
});
resolve();
}
}
};
window.addEventListener('message', msgHandler);
const checkClosedInterval = setInterval(() => {
if (this.oauth2Context && this.oauth2Context.wnd.closed) {
this.cleanOauth2Context();
}
}, 200);
this.oauth2Context = {
wnd,
msgHandler,
checkClosedInterval,
};
});
},
cleanOauth2Context() {
if (this.oauth2Context) {
clearInterval(this.oauth2Context.checkClosedInterval);
if (!this.oauth2Context.wnd.closed) {
this.oauth2Context.wnd.close();
}
window.removeEventListener('message', this.oauth2Context.msgHandler);
this.oauth2Context = null;
}
},
};

View File

@ -1,20 +1,29 @@
// For uid()
const crypto = window.crypto || window.msCrypto; const crypto = window.crypto || window.msCrypto;
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const radix = alphabet.length; const radix = alphabet.length;
const array = new Uint32Array(20); const array = new Uint32Array(20);
// For addQueryParam()
const urlParser = window.document.createElement('a'); const urlParser = window.document.createElement('a');
// For loadScript()
const scriptLoadingPromises = Object.create(null);
// For startOauth2()
const origin = `${location.protocol}//${location.host}`;
export default { export default {
uid() { uid() {
crypto.getRandomValues(array); crypto.getRandomValues(array);
return array.cl_map(value => alphabet[value % radix]).join(''); return array.cl_map(value => alphabet[value % radix]).join('');
}, },
setInterval(func, interval) { setInterval(func, interval) {
const randomizedInterval = Math.floor((1 + ((Math.random() - 0.5) * 0.1)) * interval); const randomizedInterval = Math.floor((1 + (Math.random() * 0.1)) * interval);
setInterval(() => func(), randomizedInterval); return setInterval(() => func(), randomizedInterval);
}, },
addQueryParam(url, key, value) { addQueryParam(url, key, value) {
if (!url || !key || !value) { if (!url || !key || value == null) {
return url; return url;
} }
urlParser.href = url; urlParser.href = url;
@ -26,4 +35,191 @@ export default {
urlParser.search += `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; urlParser.search += `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
return urlParser.href; return urlParser.href;
}, },
loadScript(url) {
if (!scriptLoadingPromises[url]) {
scriptLoadingPromises[url] = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.onload = resolve;
script.onerror = () => {
scriptLoadingPromises[url] = null;
reject();
};
script.src = url;
document.head.appendChild(script);
});
}
return scriptLoadingPromises[url];
},
startOauth2(url, params = {}, silent = false) {
const oauth2Context = {};
// Build the authorize URL
const state = this.uid();
params.state = state;
params.redirect_uri = `${origin}/oauth2/callback.html`;
let authorizeUrl = url;
Object.keys(params).forEach((key) => {
authorizeUrl = this.addQueryParam(authorizeUrl, key, params[key]);
});
if (silent) {
// Use an iframe as wnd for silent mode
oauth2Context.iframeElt = document.createElement('iframe');
oauth2Context.iframeElt.style.position = 'absolute';
oauth2Context.iframeElt.style.left = '-9999px';
oauth2Context.iframeElt.onload = () => {
oauth2Context.closeTimeout = setTimeout(() => oauth2Context.clean(), 5 * 1000);
};
oauth2Context.iframeElt.src = authorizeUrl;
document.body.appendChild(oauth2Context.iframeElt);
oauth2Context.wnd = oauth2Context.iframeElt.contentWindow;
} else {
// Open a new tab otherwise
oauth2Context.wnd = window.open(authorizeUrl);
// If window opening has been blocked by the browser
if (!oauth2Context.wnd) {
return Promise.reject();
}
oauth2Context.closeTimeout = setTimeout(() => oauth2Context.clean(), 120 * 1000);
}
return new Promise((resolve, reject) => {
oauth2Context.clean = (errorMsg) => {
clearInterval(oauth2Context.checkClosedInterval);
if (!silent && !oauth2Context.wnd.closed) {
oauth2Context.wnd.close();
}
if (oauth2Context.iframeElt) {
document.body.removeChild(oauth2Context.iframeElt);
}
clearTimeout(oauth2Context.closeTimeout);
window.removeEventListener('message', oauth2Context.msgHandler);
oauth2Context.clean = () => {}; // Prevent from cleaning several times
if (errorMsg) {
reject(new Error(errorMsg));
}
};
oauth2Context.msgHandler = (event) => {
if (event.source === oauth2Context.wnd &&
event.origin === origin &&
event.data &&
event.data.state === state
) {
oauth2Context.clean();
if (event.data.accessToken) {
resolve(event.data);
} else {
reject(event.data);
}
}
};
window.addEventListener('message', oauth2Context.msgHandler);
oauth2Context.checkClosedInterval = !silent && setInterval(() => {
if (oauth2Context.wnd.closed) {
oauth2Context.clean();
}
}, 200);
});
},
request(configParam) {
const timeout = 30 * 1000; // 30 sec
let retryAfter = 500; // 500 ms
const maxRetryAfter = 30 * 1000; // 30 sec
const config = Object.assign({}, configParam);
config.headers = Object.assign({
'Content-Type': 'application/json',
}, config.headers);
function parseHeaders(xhr) {
const pairs = xhr.getAllResponseHeaders().trim().split('\n');
return pairs.reduce((headers, header) => {
const split = header.trim().split(':');
const key = split.shift().trim().toLowerCase();
const value = split.join(':').trim();
headers[key] = value;
return headers;
}, {});
}
function isRetriable(err) {
switch (err.status) {
case 403:
{
const googleReason = ((((err.body || {}).error || {}).errors || [])[0] || {}).reason;
return googleReason === 'rateLimitExceeded' || googleReason === 'userRateLimitExceeded';
}
case 429:
return true;
default:
if (err.status >= 500 && err.status < 600) {
return true;
}
}
return false;
}
const attempt =
() => new Promise((resolve, reject) => {
const xhr = new window.XMLHttpRequest();
let timeoutId;
xhr.onload = () => {
clearTimeout(timeoutId);
const result = {
status: xhr.status,
headers: parseHeaders(xhr),
body: xhr.responseText,
};
try {
result.body = JSON.parse(result.body);
} catch (e) {
// ignore
}
if (result.status >= 200 && result.status < 300) {
resolve(result);
return;
}
reject(result);
};
xhr.onerror = () => {
clearTimeout(timeoutId);
reject(new Error('Network request failed.'));
};
timeoutId = setTimeout(() => {
xhr.abort();
reject(new Error('Network request timeout.'));
}, timeout);
// Add query params to URL
let url = config.url || '';
if (config.params) {
Object.keys(config.params).forEach((key) => {
url = this.addQueryParam(url, key, config.params[key]);
});
}
xhr.open(config.method, url);
Object.keys(config.headers).forEach((key) => {
xhr.setRequestHeader(key, config.headers[key]);
});
xhr.send(config.body ? JSON.stringify(config.body) : null);
})
.catch((err) => {
// Try again later in case of retriable error
if (isRetriable(err) && retryAfter < maxRetryAfter) {
return new Promise(
(resolve) => {
setTimeout(resolve, retryAfter);
// Exponential backoff
retryAfter *= 2;
})
.then(attempt);
}
throw err;
});
return attempt();
},
}; };

View File

@ -9,6 +9,7 @@ import layout from './modules/layout';
import editor from './modules/editor'; import editor from './modules/editor';
import explorer from './modules/explorer'; import explorer from './modules/explorer';
import modal from './modules/modal'; import modal from './modules/modal';
import queue from './modules/queue';
Vue.use(Vuex); Vue.use(Vuex);
@ -32,6 +33,7 @@ const store = new Vuex.Store({
editor, editor,
explorer, explorer,
modal, modal,
queue,
}, },
strict: debug, strict: debug,
plugins: debug ? [createLogger()] : [], plugins: debug ? [createLogger()] : [],

View File

@ -13,30 +13,42 @@ const module = moduleTemplate(empty);
const getter = id => state => state.itemMap[id] || empty(id); const getter = id => state => state.itemMap[id] || empty(id);
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => {
dispatch('patchLocalSettings', {
[propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
});
};
module.getters = { module.getters = {
...module.getters, ...module.getters,
localSettings: getter('localSettings'), localSettings: getter('localSettings'),
settings: getter('settings'),
syncData: getter('syncData'),
tokens: getter('tokens'), tokens: getter('tokens'),
googleTokens: (state, getters) => getters.tokens.google || {},
loginToken: (state, getters) => {
const googleTokens = getters.googleTokens;
// Return the first googleToken that has the isLogin flag
const loginSubs = Object.keys(googleTokens)
.filter(sub => googleTokens[sub].isLogin);
return googleTokens[loginSubs[0]];
},
}; };
const patcher = id => ({ getters, commit }, value) => commit('patchOrSetItem', {
...value,
id,
});
const localSettingsToggler = propertyName => ({ getters, dispatch }, value) => dispatch('patchLocalSettings', {
[propertyName]: value === undefined ? !getters.localSettings[propertyName] : value,
});
module.actions = { module.actions = {
...module.actions, ...module.actions,
patchLocalSettings({ getters, commit }, value) { patchLocalSettings: patcher('localSettings'),
commit('patchOrSetItem', { patchSyncData: patcher('syncData'),
...value, patchTokens: patcher('tokens'),
id: 'localSettings', setGoogleToken({ getters, dispatch }, googleToken) {
}); dispatch('patchTokens', {
}, google: {
patchTokens({ getters, commit }, value) { ...getters.googleTokens,
commit('patchOrSetItem', { [googleToken.sub]: googleToken,
...value, },
id: 'tokens',
}); });
}, },
toggleNavigationBar: localSettingsToggler('showNavigationBar'), toggleNavigationBar: localSettingsToggler('showNavigationBar'),

View File

@ -0,0 +1,42 @@
const setter = propertyName => (state, value) => {
state[propertyName] = value;
};
let queue = Promise.resolve();
export default {
namespaced: true,
state: {
isEmpty: true,
isSyncRequested: false,
},
mutations: {
setIsEmpty: setter('isEmpty'),
setIsSyncRequested: setter('isSyncRequested'),
},
actions: {
enqueue({ state, commit }, cb) {
if (state.isEmpty) {
commit('setIsEmpty', false);
}
const newQueue = queue
.then(cb)
.catch((err) => {
console.error(err);
})
.then(() => {
if (newQueue === queue) {
commit('setIsEmpty', true);
}
});
queue = newQueue;
},
enqueueSyncRequest({ state, commit, dispatch }, cb) {
if (!state.isSyncRequested) {
commit('setIsSyncRequested', true);
const unset = () => commit('setIsSyncRequested', false);
dispatch('enqueue', () => cb().then(unset, unset));
}
},
},
};

View File

@ -4,6 +4,7 @@
<script> <script>
var state; var state;
var accessToken; var accessToken;
var expiresIn;
function parse(search) { function parse(search) {
(search || '').slice(1).split('&').forEach(function (param) { (search || '').slice(1).split('&').forEach(function (param) {
var split = param.split('='); var split = param.split('=');
@ -13,15 +14,18 @@
state = value; state = value;
} else if (key === 'access_token') { } else if (key === 'access_token') {
accessToken = value; accessToken = value;
} else if (key === 'expires_in') {
expiresIn = value;
} }
}); });
} }
parse(location.search); parse(location.search);
parse(location.hash); parse(location.hash);
var origin = location.protocol + '//' + location.host; var origin = location.protocol + '//' + location.host;
opener.postMessage({ (window.opener || window.parent).postMessage({
state: state, state: state,
accessToken: accessToken accessToken: accessToken,
expiresIn: expiresIn
}, origin); }, origin);
</script> </script>
</body> </body>