浏览代码

feat: add sync service with Google Drive

close #141
Gerald 8 年之前
父节点
当前提交
3ce37782b5

+ 1 - 1
.eslintrc.js

@@ -40,7 +40,7 @@ module.exports = {
     'consistent-return': 'off',
     'no-use-before-define': ['error', 'nofunc'],
     'object-shorthand': ['error', 'always'],
-    'no-mixed-operators': ['error', { allowSamePrecedence: true }],
+    'no-mixed-operators': 'off',
     'no-bitwise': ['error', { int32Hint: true }],
     'no-underscore-dangle': 'off',
     'arrow-parens': ['error', 'as-needed'],

+ 62 - 49
src/background/sync/base.js

@@ -10,6 +10,9 @@ const autoSync = debounce(sync, 60 * 60 * 1000);
 let working = Promise.resolve();
 let syncConfig;
 
+export function getItemFilename({ name: filename, uri }) {
+  return uri ? getFilename(uri) : filename;
+}
 export function getFilename(uri) {
   return `vm-${encodeURIComponent(uri)}`;
 }
@@ -20,11 +23,6 @@ export function getURI(name) {
   return decodeURIComponent(name.slice(3));
 }
 
-function getLocalData() {
-  return getScripts()
-  .then(scripts => scripts.filter(script => !script.config.removed));
-}
-
 function initConfig() {
   function get(key, def) {
     const keys = normalizeKeys(key);
@@ -151,7 +149,6 @@ export const BaseService = serviceFactory({
       'syncing',
       'error',
     ], null, onStateChange);
-    // this.initToken();
     this.lastFetch = Promise.resolve();
     this.startSync = this.syncFactory();
     const events = getEventEmitter();
@@ -220,9 +217,14 @@ export const BaseService = serviceFactory({
     .then(() => this.startSync());
   },
   user: noop,
+  handleMetaError(err) {
+    throw err;
+  },
   getMeta() {
     return this.get(this.metaFile)
-    .then(data => JSON.parse(data));
+    .then(data => JSON.parse(data))
+    .catch(err => this.handleMetaError(err))
+    .then(data => ({ name: this.metaFile, data }));
   },
   initToken() {
     this.prepareHeaders();
@@ -273,6 +275,18 @@ export const BaseService = serviceFactory({
       return data;
     });
   },
+  getLocalData() {
+    return getScripts()
+    .then(scripts => scripts.filter(script => !script.config.removed));
+  },
+  getSyncData() {
+    return this.getMeta()
+    .then(remoteMeta => Promise.all([
+      remoteMeta,
+      this.list(),
+      this.getLocalData(),
+    ]));
+  },
   sync() {
     this.progress = {
       finished: 0,
@@ -280,15 +294,11 @@ export const BaseService = serviceFactory({
     };
     this.syncState.set('syncing');
     // Avoid simultaneous requests
-    return this.getMeta()
-    .then(remoteMeta => Promise.all([
-      remoteMeta,
-      this.list(),
-      getLocalData(),
-    ]))
+    return this.getSyncData()
     .then(([remoteMeta, remoteData, localData]) => {
-      const remoteMetaInfo = remoteMeta.info || {};
-      const remoteTimestamp = remoteMeta.timestamp || 0;
+      const { data: remoteMetaData } = remoteMeta;
+      const remoteMetaInfo = remoteMetaData.info || {};
+      const remoteTimestamp = remoteMetaData.timestamp || 0;
       let remoteChanged = !remoteTimestamp
         || Object.keys(remoteMetaInfo).length !== remoteData.length;
       const now = Date.now();
@@ -302,7 +312,7 @@ export const BaseService = serviceFactory({
       const putRemote = [];
       const delRemote = [];
       const delLocal = [];
-      remoteMeta.info = remoteData.reduce((info, item) => {
+      remoteMetaData.info = remoteData.reduce((info, item) => {
         remoteItemMap[item.uri] = item;
         let itemInfo = remoteMetaInfo[item.uri];
         if (!itemInfo) {
@@ -318,36 +328,36 @@ export const BaseService = serviceFactory({
       }, {});
       localData.forEach(item => {
         const { props: { uri, position, lastModified } } = item;
-        const remoteInfo = remoteMeta.info[uri];
+        const remoteInfo = remoteMetaData.info[uri];
         if (remoteInfo) {
+          const remoteItem = remoteItemMap[uri];
           if (firstSync || !lastModified || remoteInfo.modified > lastModified) {
-            const remoteItem = remoteItemMap[uri];
-            getRemote.push(remoteItem);
+            getRemote.push({ local: item, remote: remoteItem });
           } else if (remoteInfo.modified < lastModified) {
-            putRemote.push(item);
+            putRemote.push({ local: item, remote: remoteItem });
           } else if (remoteInfo.position !== position) {
             remoteInfo.position = position;
             remoteChanged = true;
           }
           delete remoteItemMap[uri];
         } else if (firstSync || !outdated || lastModified > remoteTimestamp) {
-          putRemote.push(item);
+          putRemote.push({ local: item });
         } else {
-          delLocal.push(item);
+          delLocal.push({ local: item });
         }
       });
       Object.keys(remoteItemMap).forEach(uri => {
         const item = remoteItemMap[uri];
         if (outdated) {
-          getRemote.push(item);
+          getRemote.push({ remote: item });
         } else {
-          delRemote.push(item);
+          delRemote.push({ remote: item });
         }
       });
       const promiseQueue = [
-        ...getRemote.map(item => {
-          this.log('Download script:', item.uri);
-          return this.get(getFilename(item.uri))
+        ...getRemote.map(({ remote }) => {
+          this.log('Download script:', remote.uri);
+          return this.get(remote)
           .then(raw => {
             const data = {};
             try {
@@ -370,7 +380,7 @@ export const BaseService = serviceFactory({
             }
             // Invalid data
             if (!data.code) return;
-            const remoteInfo = remoteMeta.info[item.uri];
+            const remoteInfo = remoteMetaData.info[remote.uri];
             const { modified } = remoteInfo;
             data.modified = modified;
             const position = +remoteInfo.position;
@@ -382,9 +392,9 @@ export const BaseService = serviceFactory({
             .then(res => { browser.runtime.sendMessage(res); });
           });
         }),
-        ...putRemote.map(script => {
-          this.log('Upload script:', script.props.uri);
-          return getScriptCode(script.props.id)
+        ...putRemote.map(({ local, remote }) => {
+          this.log('Upload script:', local.props.uri);
+          return getScriptCode(local.props.id)
           .then(code => {
             // const data = {
             //   version: 2,
@@ -397,28 +407,31 @@ export const BaseService = serviceFactory({
               version: 1,
               code,
               more: {
-                custom: script.custom,
-                enabled: script.config.enabled,
-                update: script.config.shouldUpdate,
+                custom: local.custom,
+                enabled: local.config.enabled,
+                update: local.config.shouldUpdate,
               },
             };
-            remoteMeta.info[script.props.uri] = {
-              modified: script.props.lastModified,
-              position: script.props.position,
+            remoteMetaData.info[local.props.uri] = {
+              modified: local.props.lastModified,
+              position: local.props.position,
             };
             remoteChanged = true;
-            return this.put(getFilename(script.props.uri), JSON.stringify(data));
+            return this.put(
+              Object.assign({}, remote, { uri: local.props.uri }),
+              JSON.stringify(data),
+            );
           });
         }),
-        ...delRemote.map(item => {
-          this.log('Remove remote script:', item.uri);
-          delete remoteMeta.info[item.uri];
+        ...delRemote.map(({ remote }) => {
+          this.log('Remove remote script:', remote.uri);
+          delete remoteMetaData.info[remote.uri];
           remoteChanged = true;
-          return this.remove(getFilename(item.uri));
+          return this.remove(remote);
         }),
-        ...delLocal.map(script => {
-          this.log('Remove local script:', script.props.uri);
-          return removeScript(script.props.id);
+        ...delLocal.map(({ local }) => {
+          this.log('Remove local script:', local.props.uri);
+          return removeScript(local.props.id);
         }),
       ];
       promiseQueue.push(Promise.all(promiseQueue).then(() => normalizePosition()).then(changed => {
@@ -426,7 +439,7 @@ export const BaseService = serviceFactory({
         remoteChanged = true;
         return getScripts().then(scripts => {
           scripts.forEach(script => {
-            const remoteInfo = remoteMeta.info[script.props.uri];
+            const remoteInfo = remoteMetaData.info[script.props.uri];
             if (remoteInfo) remoteInfo.position = script.props.position;
           });
         });
@@ -434,10 +447,10 @@ export const BaseService = serviceFactory({
       promiseQueue.push(Promise.all(promiseQueue).then(() => {
         const promises = [];
         if (remoteChanged) {
-          remoteMeta.timestamp = Date.now();
-          promises.push(this.put(this.metaFile, JSON.stringify(remoteMeta)));
+          remoteMetaData.timestamp = Date.now();
+          promises.push(this.put(remoteMeta, JSON.stringify(remoteMetaData)));
         }
-        localMeta.timestamp = remoteMeta.timestamp;
+        localMeta.timestamp = remoteMetaData.timestamp;
         localMeta.lastSync = Date.now();
         this.config.set('meta', localMeta);
         return Promise.all(promises);

+ 13 - 13
src/background/sync/dropbox.js

@@ -1,5 +1,5 @@
 import { loadQuery, dumpQuery } from '../utils';
-import { getURI, BaseService, isScriptFile, register } from './base';
+import { getURI, getFilename, BaseService, isScriptFile, register } from './base';
 
 const config = {
   client_id: 'f0q12zup2uys5w8',
@@ -26,12 +26,9 @@ const Dropbox = BaseService.extend({
       });
     });
   },
-  getMeta() {
-    return BaseService.prototype.getMeta.call(this)
-    .catch(res => {
-      if (res.status === 409) return {};
-      throw res;
-    });
+  handleMetaError(res) {
+    if (res.status === 409) return {};
+    throw res;
   },
   list() {
     return this.loadData({
@@ -46,24 +43,26 @@ const Dropbox = BaseService.extend({
       data.entries.filter(item => item['.tag'] === 'file' && isScriptFile(item.name)).map(normalize)
     ));
   },
-  get(path) {
+  get({ uri }) {
+    const name = getFilename(uri);
     return this.loadData({
       method: 'POST',
       url: 'https://content.dropboxapi.com/2/files/download',
       headers: {
         'Dropbox-API-Arg': JSON.stringify({
-          path: `/${path}`,
+          path: `/${name}`,
         }),
       },
     });
   },
-  put(path, data) {
+  put({ uri }, data) {
+    const name = getFilename(uri);
     return this.loadData({
       method: 'POST',
       url: 'https://content.dropboxapi.com/2/files/upload',
       headers: {
         'Dropbox-API-Arg': JSON.stringify({
-          path: `/${path}`,
+          path: `/${name}`,
           mode: 'overwrite',
         }),
         'Content-Type': 'application/octet-stream',
@@ -73,12 +72,13 @@ const Dropbox = BaseService.extend({
     })
     .then(normalize);
   },
-  remove(path) {
+  remove({ uri }) {
+    const name = getFilename(uri);
     return this.loadData({
       method: 'POST',
       url: 'https://api.dropboxapi.com/2/files/delete',
       body: {
-        path: `/${path}`,
+        path: `/${name}`,
       },
       responseType: 'json',
     })

+ 170 - 0
src/background/sync/googledrive.js

@@ -0,0 +1,170 @@
+// Reference:
+// - https://developers.google.com/drive/v3/reference/files
+// - https://github.com/google/google-api-nodejs-client
+import { getUniqId } from 'src/common';
+import { objectGet } from 'src/common/object';
+import { loadQuery, dumpQuery } from '../utils';
+import { getURI, getFilename, BaseService, register, isScriptFile } from './base';
+
+const config = {
+  client_id: '590447512361-05hjbhnf8ua3iha55e5pgqg15om0cpef.apps.googleusercontent.com',
+  redirect_uri: 'https://violentmonkey.github.io/auth_googledrive.html',
+};
+
+const GoogleDrive = BaseService.extend({
+  name: 'googledrive',
+  displayName: 'Google Drive',
+  urlPrefix: 'https://www.googleapis.com/drive/v3',
+  user() {
+    const params = {
+      access_token: this.config.get('token'),
+    };
+    return this.loadData({
+      method: 'GET',
+      url: `https://www.googleapis.com/oauth2/v3/tokeninfo?${dumpQuery(params)}`,
+      responseType: 'json',
+    })
+    .catch(res => {
+      if (res.status === 400 && objectGet(res, 'data.error_description') === 'Invalid Value') {
+        return Promise.reject({ type: 'unauthorized' });
+      }
+      return Promise.reject({
+        type: 'error',
+        data: res,
+      });
+    });
+  },
+  getSyncData() {
+    const params = {
+      spaces: 'appDataFolder',
+      fields: 'files(id,name,size)',
+    };
+    return this.loadData({
+      url: `/files?${dumpQuery(params)}`,
+      responseType: 'json',
+    })
+    .then(({ files }) => {
+      let metaFile;
+      const remoteData = files.filter(item => {
+        if (isScriptFile(item.name)) return true;
+        if (!metaFile && item.name === this.metaFile) {
+          metaFile = item;
+        } else {
+          this.remove(item);
+        }
+        return false;
+      })
+      .map(normalize)
+      .filter(item => {
+        if (!item.size) {
+          this.remove(item);
+          return false;
+        }
+        return true;
+      });
+      const metaItem = metaFile ? normalize(metaFile) : {};
+      const gotMeta = this.get(metaItem)
+      .then(data => JSON.parse(data))
+      .catch(err => this.handleMetaError(err))
+      .then(data => Object.assign({}, metaItem, {
+        data,
+        uri: null,
+        name: this.metaFile,
+      }));
+      return Promise.all([gotMeta, remoteData, this.getLocalData()]);
+    });
+  },
+  authorize() {
+    const params = {
+      response_type: 'token',
+      client_id: config.client_id,
+      redirect_uri: config.redirect_uri,
+      scope: 'https://www.googleapis.com/auth/drive.appdata',
+    };
+    const url = `https://accounts.google.com/o/oauth2/v2/auth?${dumpQuery(params)}`;
+    browser.tabs.create({ url });
+  },
+  authorized(raw) {
+    const data = loadQuery(raw);
+    if (data.access_token) {
+      this.config.set({
+        token: data.access_token,
+      });
+    }
+  },
+  checkAuth(url) {
+    const redirectUri = `${config.redirect_uri}#`;
+    if (url.startsWith(redirectUri)) {
+      this.authorized(url.slice(redirectUri.length));
+      this.checkSync();
+      return true;
+    }
+  },
+  revoke() {
+    this.config.set({
+      token: null,
+    });
+    return this.prepare();
+  },
+  handleMetaError() {
+    return {};
+  },
+  list() {
+    throw new Error('Not supported');
+  },
+  get({ id }) {
+    if (!id) return Promise.reject();
+    return this.loadData({
+      url: `/files/${id}?alt=media`,
+    });
+  },
+  put({ id, name: filename, uri }, data) {
+    const name = uri ? getFilename(uri) : filename;
+    const boundary = getUniqId('violentmonkey-is-great-');
+    const headers = {
+      'Content-Type': `multipart/related; boundary=${boundary}`,
+    };
+    const metadata = id ? {
+      name,
+    } : {
+      name,
+      parents: ['appDataFolder'],
+    };
+    const body = [
+      `--${boundary}`,
+      'Content-Type: application/json; charset=UTF-8',
+      '',
+      JSON.stringify(metadata),
+      `--${boundary}`,
+      'Content-Type: text/plain',
+      '',
+      data,
+      `--${boundary}--`,
+      '',
+    ].join('\r\n');
+    const url = id
+      ? `https://www.googleapis.com/upload/drive/v3/files/${id}?uploadType=multipart`
+      : 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart';
+    return this.loadData({
+      url,
+      body,
+      headers,
+      method: id ? 'PATCH' : 'POST',
+    });
+  },
+  remove({ id }) {
+    return this.loadData({
+      method: 'DELETE',
+      url: `/files/${id}`,
+    });
+  },
+});
+register(GoogleDrive);
+
+function normalize(item) {
+  return {
+    id: item.id,
+    size: +item.size,
+    uri: getURI(item.name),
+  };
+}

+ 1 - 0
src/background/sync/index.js

@@ -3,6 +3,7 @@ import {
 } from './base';
 import './dropbox';
 import './onedrive';
+import './googledrive';
 
 browser.tabs.onUpdated.addListener((tabId, changes) => {
   if (changes.url && checkAuthUrl(changes.url)) browser.tabs.remove(tabId);

+ 19 - 20
src/background/sync/onedrive.js

@@ -2,7 +2,7 @@
 import { noop } from 'src/common';
 import { objectGet } from 'src/common/object';
 import { dumpQuery } from '../utils';
-import { BaseService, isScriptFile, register, getURI } from './base';
+import { getURI, getFilename, BaseService, isScriptFile, register } from './base';
 
 const SECRET_KEY = JSON.parse(window.atob('eyJjbGllbnRfc2VjcmV0Ijoiajl4M09WRXRIdmhpSEtEV09HcXV5TWZaS2s5NjA0MEgifQ=='));
 const config = Object.assign({
@@ -35,7 +35,7 @@ const OneDrive = BaseService.extend({
       throw res;
     })
     .catch(res => {
-      if (res.status === 400 && objectGet(res, ['data', 'error']) === 'invalid_grant') {
+      if (res.status === 400 && objectGet(res, 'data.error') === 'invalid_grant') {
         return Promise.reject({
           type: 'unauthorized',
         });
@@ -46,19 +46,15 @@ const OneDrive = BaseService.extend({
       });
     });
   },
-  getMeta() {
-    const getMeta = () => BaseService.prototype.getMeta.call(this);
-    return getMeta()
-    .catch(res => {
-      if (res.status === 404) {
-        const header = res.xhr.getResponseHeader('WWW-Authenticate') || '';
-        if (/^Bearer realm="OneDriveAPI"/.test(header)) {
-          return this.refreshToken().then(getMeta);
-        }
-        return {};
+  handleMetaError(res) {
+    if (res.status === 404) {
+      const header = res.xhr.getResponseHeader('WWW-Authenticate') || '';
+      if (/^Bearer realm="OneDriveAPI"/.test(header)) {
+        return this.refreshToken().then(this.getMeta);
       }
-      throw res;
-    });
+      return {};
+    }
+    throw res;
   },
   list() {
     return this.loadData({
@@ -67,9 +63,10 @@ const OneDrive = BaseService.extend({
     })
     .then(data => data.value.filter(item => item.file && isScriptFile(item.name)).map(normalize));
   },
-  get(path) {
+  get({ uri }) {
+    const name = getFilename(uri);
     return this.loadData({
-      url: `/drive/special/approot:/${encodeURIComponent(path)}`,
+      url: `/drive/special/approot:/${encodeURIComponent(name)}`,
       responseType: 'json',
     })
     .then(data => this.loadData({
@@ -77,10 +74,11 @@ const OneDrive = BaseService.extend({
       delay: false,
     }));
   },
-  put(path, data) {
+  put({ uri }, data) {
+    const name = getFilename(uri);
     return this.loadData({
       method: 'PUT',
-      url: `/drive/special/approot:/${encodeURIComponent(path)}:/content`,
+      url: `/drive/special/approot:/${encodeURIComponent(name)}:/content`,
       headers: {
         'Content-Type': 'application/octet-stream',
       },
@@ -89,11 +87,12 @@ const OneDrive = BaseService.extend({
     })
     .then(normalize);
   },
-  remove(path) {
+  remove({ uri }) {
     // return 204
+    const name = getFilename(uri);
     return this.loadData({
       method: 'DELETE',
-      url: `/drive/special/approot:/${encodeURIComponent(path)}`,
+      url: `/drive/special/approot:/${encodeURIComponent(name)}`,
     })
     .catch(noop);
   },

+ 1 - 1
src/common/index.js

@@ -109,7 +109,7 @@ export function request(url, options = {}) {
     if (binaryTypes.includes(responseType)) xhr.responseType = responseType;
     const headers = Object.assign({}, options.headers);
     let { body } = options;
-    if (body && typeof body === 'object') {
+    if (body && Object.prototype.toString.call(body) === '[object Object]') {
       headers['Content-Type'] = 'application/json';
       body = JSON.stringify(body);
     }