Added google helper
This commit is contained in:
parent
855e6cb056
commit
1e74fb00dc
@ -11,7 +11,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
<div class="navigation-bar__title navigation-bar__title--fake text-input"></div>
|
||||
@ -75,6 +75,9 @@ export default {
|
||||
...mapGetters('layout', [
|
||||
'styles',
|
||||
]),
|
||||
showSpinner() {
|
||||
return !this.$store.state.queue.isEmpty;
|
||||
},
|
||||
titleWidth() {
|
||||
if (!this.mounted) {
|
||||
return 0;
|
||||
|
@ -13,16 +13,16 @@
|
||||
</div>
|
||||
<div class="side-bar__inner">
|
||||
<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>
|
||||
<div>Sign in with Google</div>
|
||||
<span>Have all your files and settings backed up and synced.</span>
|
||||
</side-bar-item>
|
||||
<side-bar-item @click.native="signin">
|
||||
<!-- <side-bar-item @click.native="signin">
|
||||
<icon-login slot="icon"></icon-login>
|
||||
<div>Sign in on CouchDB</div>
|
||||
<span>Save and collaborate on a CouchDB hosted by you.</span>
|
||||
</side-bar-item>
|
||||
</side-bar-item> -->
|
||||
<side-bar-item @click.native="panel = 'toc'">
|
||||
<icon-toc slot="icon"></icon-toc>
|
||||
Table of contents
|
||||
@ -44,12 +44,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions } from 'vuex';
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import Toc from './Toc';
|
||||
import SideBarItem from './SideBarItem';
|
||||
import markdownSample from '../data/markdownSample.md';
|
||||
import markdownConversionSvc from '../services/markdownConversionSvc';
|
||||
import userSvc from '../services/userSvc';
|
||||
import googleHelper from '../services/helpers/googleHelper';
|
||||
import syncSvc from '../services/syncSvc';
|
||||
|
||||
const panelNames = {
|
||||
menu: 'Menu',
|
||||
@ -72,6 +73,9 @@ export default {
|
||||
markdownSample: markdownConversionSvc.highlight(markdownSample),
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters('data', [
|
||||
'loginToken',
|
||||
]),
|
||||
panelName() {
|
||||
return panelNames[this.panel];
|
||||
},
|
||||
@ -81,7 +85,10 @@ export default {
|
||||
'toggleSideBar',
|
||||
]),
|
||||
signin() {
|
||||
userSvc.signinWithGoogle();
|
||||
googleHelper.startOauth2([
|
||||
'openid',
|
||||
'https://www.googleapis.com/auth/drive.appdata',
|
||||
]).then(() => syncSvc.requestSync());
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -111,6 +118,10 @@ export default {
|
||||
left: 1000px;
|
||||
}
|
||||
|
||||
.side-bar__panel--menu {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.side-bar__panel--help {
|
||||
padding: 0 10px 40px 20px;
|
||||
|
||||
|
@ -14,7 +14,6 @@
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
height: auto;
|
||||
margin: 5px;
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
|
@ -1,6 +1,5 @@
|
||||
import Vue from 'vue';
|
||||
import './extensions/';
|
||||
import './services/syncSvc';
|
||||
import './services/optional';
|
||||
import './icons/';
|
||||
import App from './components/App';
|
||||
|
224
src/services/helpers/googleHelper.js
Normal file
224
src/services/helpers/googleHelper.js
Normal 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));
|
||||
},
|
||||
};
|
@ -2,6 +2,96 @@ import localDbSvc from './localDbSvc';
|
||||
import store from '../store';
|
||||
import welcomeFile from '../data/welcomeFile.md';
|
||||
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) => {
|
||||
if (obj.id) {
|
||||
@ -12,7 +102,7 @@ const ifNoId = cb => (obj) => {
|
||||
|
||||
// Load the DB on boot
|
||||
localDbSvc.sync()
|
||||
// Watch file changing
|
||||
// And watch file changing
|
||||
.then(() => store.watch(
|
||||
() => store.getters['files/current'].id,
|
||||
() => Promise.resolve(store.getters['files/current'])
|
||||
@ -36,9 +126,14 @@ localDbSvc.sync()
|
||||
.then((currentFile) => {
|
||||
store.commit('files/setCurrentId', currentFile.id);
|
||||
store.dispatch('files/patchCurrent', {}); // Update `updated` field to make it the mostRecent
|
||||
}),
|
||||
{
|
||||
}), {
|
||||
immediate: true,
|
||||
}));
|
||||
|
||||
utils.setInterval(() => localDbSvc.sync(), 1200);
|
||||
// Sync local DB periodically
|
||||
utils.setInterval(() => localDbSvc.sync(), 1000);
|
||||
|
||||
export default {
|
||||
isSyncAvailable,
|
||||
requestSync,
|
||||
};
|
||||
|
28
src/services/userActivitySvc.js
Normal file
28
src/services/userActivitySvc.js
Normal 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();
|
||||
},
|
||||
};
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
@ -1,20 +1,29 @@
|
||||
// For uid()
|
||||
const crypto = window.crypto || window.msCrypto;
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
const radix = alphabet.length;
|
||||
const array = new Uint32Array(20);
|
||||
|
||||
// For addQueryParam()
|
||||
const urlParser = window.document.createElement('a');
|
||||
|
||||
// For loadScript()
|
||||
const scriptLoadingPromises = Object.create(null);
|
||||
|
||||
// For startOauth2()
|
||||
const origin = `${location.protocol}//${location.host}`;
|
||||
|
||||
export default {
|
||||
uid() {
|
||||
crypto.getRandomValues(array);
|
||||
return array.cl_map(value => alphabet[value % radix]).join('');
|
||||
},
|
||||
setInterval(func, interval) {
|
||||
const randomizedInterval = Math.floor((1 + ((Math.random() - 0.5) * 0.1)) * interval);
|
||||
setInterval(() => func(), randomizedInterval);
|
||||
const randomizedInterval = Math.floor((1 + (Math.random() * 0.1)) * interval);
|
||||
return setInterval(() => func(), randomizedInterval);
|
||||
},
|
||||
addQueryParam(url, key, value) {
|
||||
if (!url || !key || !value) {
|
||||
if (!url || !key || value == null) {
|
||||
return url;
|
||||
}
|
||||
urlParser.href = url;
|
||||
@ -26,4 +35,191 @@ export default {
|
||||
urlParser.search += `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
@ -9,6 +9,7 @@ import layout from './modules/layout';
|
||||
import editor from './modules/editor';
|
||||
import explorer from './modules/explorer';
|
||||
import modal from './modules/modal';
|
||||
import queue from './modules/queue';
|
||||
|
||||
Vue.use(Vuex);
|
||||
|
||||
@ -32,6 +33,7 @@ const store = new Vuex.Store({
|
||||
editor,
|
||||
explorer,
|
||||
modal,
|
||||
queue,
|
||||
},
|
||||
strict: debug,
|
||||
plugins: debug ? [createLogger()] : [],
|
||||
|
@ -13,30 +13,42 @@ const module = moduleTemplate(empty);
|
||||
|
||||
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,
|
||||
localSettings: getter('localSettings'),
|
||||
settings: getter('settings'),
|
||||
syncData: getter('syncData'),
|
||||
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,
|
||||
patchLocalSettings({ getters, commit }, value) {
|
||||
commit('patchOrSetItem', {
|
||||
...value,
|
||||
id: 'localSettings',
|
||||
});
|
||||
patchLocalSettings: patcher('localSettings'),
|
||||
patchSyncData: patcher('syncData'),
|
||||
patchTokens: patcher('tokens'),
|
||||
setGoogleToken({ getters, dispatch }, googleToken) {
|
||||
dispatch('patchTokens', {
|
||||
google: {
|
||||
...getters.googleTokens,
|
||||
[googleToken.sub]: googleToken,
|
||||
},
|
||||
patchTokens({ getters, commit }, value) {
|
||||
commit('patchOrSetItem', {
|
||||
...value,
|
||||
id: 'tokens',
|
||||
});
|
||||
},
|
||||
toggleNavigationBar: localSettingsToggler('showNavigationBar'),
|
||||
|
42
src/store/modules/queue.js
Normal file
42
src/store/modules/queue.js
Normal 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));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
@ -4,6 +4,7 @@
|
||||
<script>
|
||||
var state;
|
||||
var accessToken;
|
||||
var expiresIn;
|
||||
function parse(search) {
|
||||
(search || '').slice(1).split('&').forEach(function (param) {
|
||||
var split = param.split('=');
|
||||
@ -13,15 +14,18 @@
|
||||
state = value;
|
||||
} else if (key === 'access_token') {
|
||||
accessToken = value;
|
||||
} else if (key === 'expires_in') {
|
||||
expiresIn = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
parse(location.search);
|
||||
parse(location.hash);
|
||||
var origin = location.protocol + '//' + location.host;
|
||||
opener.postMessage({
|
||||
(window.opener || window.parent).postMessage({
|
||||
state: state,
|
||||
accessToken: accessToken
|
||||
accessToken: accessToken,
|
||||
expiresIn: expiresIn
|
||||
}, origin);
|
||||
</script>
|
||||
</body>
|
||||
|
Loading…
Reference in New Issue
Block a user