瀏覽代碼

fix: start current sync service only

Gerald 8 年之前
父節點
當前提交
e8318ca465
共有 7 個文件被更改,包括 603 次插入534 次删除
  1. 7 7
      src/background/app.js
  2. 1 2
      src/background/options.js
  3. 518 0
      src/background/sync/base.js
  4. 28 17
      src/background/sync/dropbox.js
  5. 11 489
      src/background/sync/index.js
  6. 31 17
      src/background/sync/onedrive.js
  7. 7 2
      src/common.js

+ 7 - 7
src/background/app.js

@@ -59,8 +59,9 @@ var commands = {
       injectMode: options.get('injectMode'),
       version: VM_VER,
     };
-    if (src.url == src.tab.url)
+    if (src.tab && src.url === src.tab.url) {
       chrome.tabs.sendMessage(src.tab.id, {cmd: 'GetBadge'});
+    }
     return data.isApplied
       ? vmdb.getScriptsByURL(url).then(function (res) {
         return Object.assign(data, res);
@@ -154,13 +155,12 @@ var commands = {
     setBadge(num, src);
     return false;
   },
-  Authenticate: function (data, _src) {
-    var service = sync.service(data);
-    service && service.authenticate && service.authenticate();
+  Authenticate: function (_data, _src) {
+    sync.authenticate();
     return false;
   },
-  SyncStart: function (data, _src) {
-    sync.sync(data && sync.service(data));
+  SyncStart: function (_data, _src) {
+    sync.sync();
     return false;
   },
   GetFromCache: function (data, _src) {
@@ -234,7 +234,7 @@ vmdb.initialized.then(function () {
     }
   });
   setTimeout(autoUpdate, 2e4);
-  sync.init();
+  sync.initialize();
 });
 
 // Common functions

+ 1 - 2
src/background/options.js

@@ -10,11 +10,10 @@ var defaults = {
   closeAfterInstall: false,
   trackLocalFile: false,
   autoReload: false,
-  dropbox: {},
-  onedrive: {},
   features: null,
   blacklist: null,
   syncScriptStatus: true,
+  sync: null,
 };
 var changes = {};
 var hooks = _.initHooks();

+ 518 - 0
src/background/sync/base.js

@@ -0,0 +1,518 @@
+var _ = require('src/common');
+var events = require('../utils/events');
+var app = require('../app');
+var options = require('../options');
+
+var inited;
+var serviceNames = [];
+var services = {};
+var autoSync = _.debounce(function () {
+  sync();
+}, 60 * 60 * 1000);
+var working = Promise.resolve();
+var syncConfig = initConfig();
+
+function getFilename(uri) {
+  return 'vm-' + encodeURIComponent(uri);
+}
+function isScriptFile(name) {
+  return /^vm-/.test(name);
+}
+function getURI(name) {
+  return decodeURIComponent(name.slice(3));
+}
+
+function initConfig() {
+  function get(key, def) {
+    return _.object.get(config, key, def);
+  }
+  function set(key, value) {
+    if (key) {
+      _.object.set(config, key, value);
+    }
+    options.set('sync', config);
+  }
+  var config = options.get('sync');
+  if (!config || !config.services) {
+    config = {
+      services: {},
+    };
+    // XXX Migrate from old data
+    ['dropbox', 'onedrive']
+    .forEach(function (key) {
+      config.services[key] = options.get(key);
+    });
+    set();
+  }
+  console.log(config);
+  return {get: get, set: set};
+}
+
+function ServiceConfig(name) {
+  this.name = name;
+}
+ServiceConfig.prototype.normalizeKeys = function (key) {
+  var keys = _.normalizeKeys(key);
+  keys.unshift('services', this.name);
+  return keys;
+};
+ServiceConfig.prototype.get = function (key, def) {
+  var keys = this.normalizeKeys(key);
+  return syncConfig.get(keys, def);
+};
+ServiceConfig.prototype.set = function (key, val) {
+  var keys = this.normalizeKeys(key);
+  return syncConfig.set(keys, val);
+};
+ServiceConfig.prototype.clear = function () {
+  syncConfig.set(this.normalizeKeys(), {});
+};
+
+function serviceState(validStates, initialState, onChange) {
+  var state = initialState || validStates[0];
+  return {
+    get: function () {return state;},
+    set: function (_state) {
+      if (~validStates.indexOf(_state)) {
+        state = _state;
+        onChange && onChange();
+      } else {
+        console.warn('Invalid state:', _state);
+      }
+      return state;
+    },
+    is: function (states) {
+      if (!Array.isArray(states)) states = [states];
+      return ~states.indexOf(state);
+    },
+  };
+}
+function getStates() {
+  return serviceNames.map(function (name) {
+    var service = services[name];
+    return {
+      name: service.name,
+      displayName: service.displayName,
+      authState: service.authState.get(),
+      syncState: service.syncState.get(),
+      lastSync: service.config.get('meta', {}).lastSync,
+      progress: service.progress,
+    };
+  });
+}
+
+function serviceFactory(base, options) {
+  var Service = function () {
+    this.initialize.apply(this, arguments);
+  };
+  Service.prototype = Object.assign(Object.create(base), options);
+  Service.extend = extendService;
+  return Service;
+}
+function extendService(options) {
+  return serviceFactory(this.prototype, options);
+}
+var BaseService = serviceFactory({
+  name: 'base',
+  displayName: 'BaseService',
+  delayTime: 1000,
+  urlPrefix: '',
+  metaFile: 'Violentmonkey',
+  delay: function (time) {
+    if (time == null) time = this.delayTime;
+    return new Promise(function (resolve, _reject) {
+      setTimeout(resolve, time);
+    });
+  },
+  initialize: function (name) {
+    var _this = this;
+    _this.onStateChange = _.debounce(_this.onStateChange.bind(_this));
+    if (name) _this.name = name;
+    _this.progress = {
+      finished: 0,
+      total: 0,
+    };
+    _this.config = new ServiceConfig(_this.name);
+    _this.authState = serviceState([
+      'idle',
+      'initializing',
+      'authorizing',  // in case some services require asynchronous requests to get access_tokens
+      'authorized',
+      'unauthorized',
+      'error',
+    ], null, _this.onStateChange),
+      _this.syncState = serviceState([
+        'idle',
+        'ready',
+        'syncing',
+        'error',
+      ], null, _this.onStateChange),
+      _this.initHeaders();
+    _this.events = events.getEventEmitter();
+    _this.lastFetch = Promise.resolve();
+    _this.startSync = _this.syncFactory();
+  },
+  on: function () {
+    return this.events.on.apply(null, arguments);
+  },
+  off: function () {
+    return this.events.off.apply(null, arguments);
+  },
+  fire: function () {
+    return this.events.fire.apply(null, arguments);
+  },
+  onStateChange: function () {
+    _.messenger.post({
+      cmd: 'UpdateSync',
+      data: getStates(),
+    });
+  },
+  syncFactory: function () {
+    var _this = this;
+    var promise, debouncedResolve;
+    function shouldSync() {
+      return _this.authState.is('authorized') && getCurrent() === _this.name;
+    }
+    function init() {
+      if (!shouldSync()) return Promise.resolve();
+      console.log('Ready to sync:', _this.displayName);
+      _this.syncState.set('ready');
+      promise = working = working.then(function () {
+        return new Promise(function (resolve, _reject) {
+          debouncedResolve = _.debounce(resolve, 10 * 1000);
+          debouncedResolve();
+        });
+      })
+      .then(function () {
+        if (shouldSync()) {
+          return _this.sync();
+        }
+        _this.syncState.set('idle');
+      })
+      .catch(function (err) {
+        console.error(err);
+      })
+      .then(function () {
+        promise = debouncedResolve = null;
+      });
+    }
+    return function () {
+      if (!promise) init();
+      debouncedResolve && debouncedResolve();
+      return promise;
+    };
+  },
+  prepare: function () {
+    var _this = this;
+    _this.authState.set('initializing');
+    var token = _this.token = _this.config.get('token');
+    _this.initHeaders();
+    return (token ? Promise.resolve(_this.user()) : Promise.reject({
+      type: 'unauthorized',
+    }))
+    .then(function () {
+      _this.authState.set('authorized');
+    }, function (err) {
+      if (err.type === 'unauthorized') {
+        // _this.config.clear();
+        _this.authState.set('unauthorized');
+      } else {
+        console.error(err);
+        _this.authState.set('error');
+      }
+      _this.syncState.set('idle');
+      throw err;
+    });
+  },
+  checkSync: function () {
+    var _this = this;
+    return _this.prepare()
+    .then(function () {
+      return _this.startSync();
+    });
+  },
+  user: _.noop,
+  getMeta: function () {
+    var _this = this;
+    return _this.get(_this.metaFile)
+    .then(function (data) {
+      return JSON.parse(data);
+    });
+  },
+  initHeaders: function () {
+    var headers = this.headers = {};
+    var token = this.token;
+    if (token) headers.Authorization = 'Bearer ' + token;
+  },
+  request: function (options) {
+    var _this = this;
+    var progress = _this.progress;
+    var lastFetch;
+    if (options.noDelay) {
+      lastFetch = Promise.resolve();
+    } else {
+      lastFetch = _this.lastFetch;
+      _this.lastFetch = lastFetch.then(function () {
+        return _this.delay();
+      });
+    }
+    progress.total ++;
+    _this.onStateChange();
+    return lastFetch.then(function () {
+      return new Promise(function (resolve, reject) {
+        var xhr = new XMLHttpRequest;
+        var prefix = options.prefix;
+        if (prefix == null) prefix = _this.urlPrefix;
+        xhr.open(options.method || 'GET', prefix + options.url, true);
+        var headers = Object.assign({}, _this.headers, options.headers);
+        if (options.body && typeof options.body === 'object') {
+          headers['Content-Type'] = 'application/json';
+          options.body = JSON.stringify(options.body);
+        }
+        Object.keys(headers).forEach(function (key) {
+          var v = headers[key];
+          v && xhr.setRequestHeader(key, v);
+        });
+        xhr.onloadend = function () {
+          progress.finished ++;
+          var data = xhr.responseText;
+          if (options.responseType === 'json') {
+            try {
+              data = JSON.parse(data);
+            } catch (e) {
+              // Invalid JSON data
+            }
+          }
+          _this.onStateChange();
+          if (xhr.status === 503) {
+            // TODO Too Many Requests
+          }
+          // net error: xhr.status === 0
+          if (xhr.status >= 200 && xhr.status < 300) {
+            resolve(data);
+          } else {
+            requestError(data);
+          }
+        };
+        xhr.send(options.body);
+
+        function requestError(data) {
+          reject({
+            url: options.url,
+            status: xhr.status,
+            xhr: xhr,
+            data: data,
+          });
+        }
+      });
+    });
+  },
+  sync: function () {
+    var _this = this;
+    _this.progress = {
+      finished: 0,
+      total: 0,
+    };
+    _this.syncState.set('syncing');
+    return _this.getMeta()
+    .then(function (meta) {
+      return Promise.all([
+        meta,
+        _this.list(),
+        app.vmdb.getScriptsByIndex('position'),
+      ]);
+    }).then(function (res) {
+      var remote = {
+        meta: res[0],
+        data: res[1],
+      };
+      var local = {
+        meta: _this.config.get('meta', {}),
+        data: res[2],
+      };
+      var firstSync = !local.meta.timestamp;
+      var outdated = !local.meta.timestamp || remote.meta.timestamp > local.meta.timestamp;
+      console.log('First sync:', firstSync);
+      console.log('Outdated:', outdated, '(', 'local:', local.meta.timestamp, 'remote:', remote.meta.timestamp, ')');
+      var map = {};
+      var getRemote = [];
+      var putRemote = [];
+      var delRemote = [];
+      var delLocal = [];
+      remote.data.forEach(function (item) {
+        map[item.uri] = item;
+      });
+      local.data.forEach(function (item) {
+        var remoteItem = map[item.uri];
+        if (remoteItem) {
+          if (firstSync || !item.custom.modified || remoteItem.modified > item.custom.modified) {
+            getRemote.push(remoteItem);
+          } else if (remoteItem.modified < item.custom.modified) {
+            putRemote.push(item);
+          }
+          delete map[item.uri];
+        } else if (firstSync || !outdated) {
+          putRemote.push(item);
+        } else {
+          delLocal.push(item);
+        }
+      });
+      Object.keys(map).forEach(function (uri) {
+        var item = map[uri];
+        if (outdated) {
+          getRemote.push(item);
+        } else {
+          delRemote.push(item);
+        }
+      });
+      var promises = [].concat(
+        getRemote.map(function (item) {
+          console.log('Download script:', item.uri);
+          return _this.get(getFilename(item.uri)).then(function (raw) {
+            var data = {};
+            try {
+              var obj = JSON.parse(raw);
+              if (obj.version === 1) {
+                data.code = obj.code;
+                data.more = obj.more;
+              }
+            } catch (e) {
+              data.code = raw;
+            }
+            data.modified = item.modified;
+            if (!options.get('syncScriptStatus') && data.more) {
+              delete data.more.enabled;
+            }
+            return app.vmdb.parseScript(data)
+            .then(function (res) {
+              _.messenger.post(res);
+            });
+          });
+        }),
+        putRemote.map(function (item) {
+          console.log('Upload script:', item.uri);
+          var data = JSON.stringify({
+            version: 1,
+            code: item.code,
+            more: {
+              custom: item.custom,
+              enabled: item.enabled,
+              update: item.update,
+            },
+          });
+          return _this.put(getFilename(item.uri), data)
+          .then(function (data) {
+            if (item.custom.modified !== data.modified) {
+              item.custom.modified = data.modified;
+              return app.vmdb.saveScript(item);
+            }
+          });
+        }),
+        delRemote.map(function (item) {
+          console.log('Remove remote script:', item.uri);
+          return _this.remove(getFilename(item.uri));
+        }),
+        delLocal.map(function (item) {
+          console.log('Remove local script:', item.uri);
+          return app.vmdb.removeScript(item.id);
+        })
+      );
+      promises.push(Promise.all(promises).then(function () {
+        var promises = [];
+        var remoteChanged;
+        if (!remote.meta.timestamp || putRemote.length || delRemote.length) {
+          remoteChanged = true;
+          remote.meta.timestamp = Date.now();
+          promises.push(_this.put(_this.metaFile, JSON.stringify(remote.meta)));
+        }
+        if (!local.meta.timestamp || getRemote.length || delLocal.length || remoteChanged || outdated) {
+          local.meta.timestamp = remote.meta.timestamp;
+        }
+        local.meta.lastSync = Date.now();
+        _this.config.set('meta', local.meta);
+        return Promise.all(promises);
+      }));
+      return Promise.all(promises.map(function (promise) {
+        // ignore errors to ensure all promises are fulfilled
+        return promise.then(_.noop, function (err) {
+          return err || true;
+        });
+      }))
+      .then(function (errors) {
+        errors = errors.filter(function (err) {return err;});
+        if (errors.length) throw errors;
+      });
+    })
+    .then(function () {
+      _this.syncState.set('idle');
+    }, function (err) {
+      _this.syncState.set('error');
+      console.log('Failed syncing:', _this.name);
+      console.log(err);
+    });
+  },
+});
+
+function register(Service) {
+  var name = Service.prototype.name || Service.name;
+  var service = new Service(name);
+  serviceNames.push(name);
+  services[name] = service;
+  // setTimeout(function () {
+  //   inited && service.checkSync();
+  // });
+  return service;
+}
+function getCurrent() {
+  return syncConfig.get('current');
+}
+function getService(name) {
+  name = name || getCurrent();
+  return services[name];
+}
+function initialize() {
+  inited = true;
+  // serviceNames.forEach(function (name) {
+  //   var service = services[name];
+  //   service.checkSync();
+  // });
+  var service = getService();
+  service && service.checkSync();
+  // sync();
+}
+
+function syncOne(service) {
+  if (service.syncState.is(['ready', 'syncing'])) return;
+  if (service.authState.is(['idle', 'error'])) return service.checkSync();
+  if (service.authState.is('authorized')) return service.startSync();
+}
+function sync() {
+  var service = getService();
+  return service && syncOne(service).then(autoSync);
+}
+
+function checkAuthenticateUrl(url) {
+  return serviceNames.some(function (name) {
+    var service = services[name];
+    return service.checkAuthenticate && service.checkAuthenticate(url);
+  });
+}
+
+function authenticate() {
+  var service = getService();
+  service && service.authenticate && service.authenticate();
+}
+
+exports.utils = {
+  getFilename: getFilename,
+  isScriptFile: isScriptFile,
+  getURI: getURI,
+};
+exports.initialize = initialize;
+exports.sync = sync;
+exports.getStates = getStates;
+exports.checkAuthenticateUrl = checkAuthenticateUrl;
+exports.BaseService = BaseService;
+exports.register = register;
+exports.service = getService;
+exports.authenticate = authenticate;

+ 28 - 17
src/background/sync/dropbox.js

@@ -1,4 +1,4 @@
-var sync = require('.');
+var base = require('./base');
 var tabsUtils = require('../utils/tabs');
 var searchUtils = require('../utils/search');
 var config = {
@@ -19,7 +19,7 @@ function authenticate() {
 }
 function checkAuthenticate(url) {
   var redirect_uri = config.redirect_uri + '#';
-  if (url.slice(0, redirect_uri.length) === redirect_uri) {
+  if (url.startsWith(redirect_uri)) {
     authorized(url.slice(redirect_uri.length));
     dropbox.checkSync();
     return true;
@@ -37,23 +37,34 @@ function authorized(raw) {
 function normalize(item) {
   return {
     size: item.size,
-    uri: sync.utils.getURI(item.name),
+    uri: base.utils.getURI(item.name),
     modified: new Date(item.server_modified).getTime(),
-    //is_deleted: item.is_deleted,
+    // is_deleted: item.is_deleted,
   };
 }
 
-var Dropbox = sync.BaseService.extend({
+var Dropbox = base.BaseService.extend({
   name: 'dropbox',
   displayName: 'Dropbox',
   user: function () {
     return this.request({
       method: 'POST',
       url: 'https://api.dropboxapi.com/2/users/get_current_account',
+    })
+    .catch(function (err) {
+      if (err.status === 401) {
+        throw {
+          type: 'unauthorized',
+        };
+      }
+      throw {
+        type: 'error',
+        data: err,
+      };
     });
   },
   getMeta: function () {
-    return sync.BaseService.prototype.getMeta.call(this)
+    return base.BaseService.prototype.getMeta.call(this)
     .catch(function (res) {
       if (res.status === 409) return {};
       throw res;
@@ -67,11 +78,11 @@ var Dropbox = sync.BaseService.extend({
       body: {
         path: '',
       },
-    }).then(function (text) {
-      return JSON.parse(text);
-    }).then(function (data) {
+      responseType: 'json',
+    })
+    .then(function (data) {
       return data.entries.filter(function (item) {
-        return item['.tag'] === 'file' && sync.utils.isScriptFile(item.name);
+        return item['.tag'] === 'file' && base.utils.isScriptFile(item.name);
       }).map(normalize);
     });
   },
@@ -98,9 +109,9 @@ var Dropbox = sync.BaseService.extend({
         'Content-Type': 'application/octet-stream',
       },
       body: data,
-    }).then(function (text) {
-      return JSON.parse(text);
-    }).then(normalize);
+      responseType: 'json',
+    })
+    .then(normalize);
   },
   remove: function (path) {
     return this.request({
@@ -109,11 +120,11 @@ var Dropbox = sync.BaseService.extend({
       body: {
         path: '/' + path,
       },
-    }).then(function (text) {
-      return JSON.parse(text);
-    }).then(normalize);
+      responseType: 'json',
+    })
+    .then(normalize);
   },
   authenticate: authenticate,
   checkAuthenticate: checkAuthenticate,
 });
-var dropbox = sync.service('dropbox', Dropbox);
+var dropbox = base.register(Dropbox);

+ 11 - 489
src/background/sync/index.js

@@ -1,496 +1,18 @@
-/* eslint-disable no-console */
-var events = require('../utils/events');
-var app = require('../app');
 var tabs = require('../utils/tabs');
-var _ = require('../../common');
-var options = require('../options');
-
-setTimeout(function () {
-  // import sync modules
-  require('./dropbox');
-  require('./onedrive');
-});
-
-var services = [];
-var servicesReady = [];
-var inited;
-var current = Promise.resolve();
-var autoSync = _.debounce(function () {
-  sync();
-}, 60 * 60 * 1000);
-
-function ServiceConfig(name) {
-  this.name = name;
-}
-ServiceConfig.prototype.normalizeKeys = function (key) {
-  var keys = _.normalizeKeys(key);
-  keys.unshift(this.name);
-  return keys;
-};
-ServiceConfig.prototype.get = function (key, def) {
-  var keys = this.normalizeKeys(key);
-  return options.get(keys, def);
-};
-ServiceConfig.prototype.set = function (key, val) {
-  var _this = this;
-  if (arguments.length === 1) {
-    return options.set(_this.name, Object.assign(options.get(_this.name, {}), key));
-  } else {
-    var keys = this.normalizeKeys(key);
-    return options.set(keys, val);
-  }
-};
-ServiceConfig.prototype.clear = function () {
-  options.set(this.name, {});
-};
-
-function serviceState(validStates, initialState, onChange) {
-  var state = initialState || validStates[0];
-  return {
-    get: function () {return state;},
-    set: function (_state) {
-      if (~validStates.indexOf(_state)) {
-        state = _state;
-        onChange && onChange();
-      } else {
-        console.warn('Invalid state:', _state);
-      }
-      return state;
-    },
-    is: function (states) {
-      if (!Array.isArray(states)) states = [states];
-      return ~states.indexOf(state);
-    },
-  };
-}
-function service(name, Service) {
-  var service;
-  if (typeof name === 'function') {
-    Service = name;
-    name = Service.prototype.name || Service.name;
-  }
-  if (Service) {
-    // initialize
-    service = new Service(name);
-    setTimeout(function () {
-      services.push(service);
-      inited && service.checkSync();
-    });
-  } else {
-    // get existent instance
-    for (var i = services.length; i --; ) {
-      if (services[i].name === name) break;
-    }
-    // i may be -1 if not found
-    service = services[i];
-  }
-  return service;
-}
-function getStates() {
-  return services.map(function (service) {
-    return {
-      name: service.name,
-      displayName: service.displayName,
-      authState: service.authState.get(),
-      syncState: service.syncState.get(),
-      lastSync: service.config.get('meta', {}).lastSync,
-      progress: service.progress,
-    };
-  });
-}
-function syncOne(service) {
-  if (service.syncState.is(['ready', 'syncing'])) return;
-  if (service.authState.is(['idle', 'error'])) return service.checkSync();
-  if (service.authState.is('authorized')) return service.startSync();
-}
-function syncAll() {
-  return Promise.all(servicesReady.filter(function (service) {
-    return service.config.get('enabled') && !service.syncState.is(['ready', 'syncing']);
-  }).map(function (service) {
-    return service.startSync();
-  }));
-}
-function sync(service) {
-  return (service ? Promise.resolve(syncOne(service)) : syncAll())
-  .then(autoSync);
-}
-function init() {
-  inited = true;
-  services.forEach(function (service) {
-    service.checkSync();
-  });
-  sync();
-}
-function getFilename(uri) {
-  return 'vm-' + encodeURIComponent(uri);
-}
-function getURI(name) {
-  return decodeURIComponent(name.slice(3));
-}
-function isScriptFile(name) {
-  return /^vm-/.test(name);
-}
-
-function serviceFactory(base, options) {
-  var Service = function () {
-    this.initialize.apply(this, arguments);
-  };
-  Service.prototype = Object.assign(Object.create(base), options);
-  Service.extend = extendService;
-  return Service;
-}
-function extendService(options) {
-  return serviceFactory(this.prototype, options);
-}
-var BaseService = serviceFactory({
-  name: 'base',
-  displayName: 'BaseService',
-  delayTime: 1000,
-  urlPrefix: '',
-  metaFile: 'Violentmonkey',
-  delay: function (time) {
-    if (time == null) time = this.delayTime;
-    return new Promise(function (resolve, _reject) {
-      setTimeout(resolve, time);
-    });
-  },
-  initialize: function (name) {
-    var _this = this;
-    _this.onStateChange = _.debounce(_this.onStateChange.bind(_this));
-    if (name) _this.name = name;
-    _this.progress = {
-      finished: 0,
-      total: 0,
-    };
-    _this.config = new ServiceConfig(_this.name);
-    _this.authState = serviceState([
-      'idle',
-      'initializing',
-      'authorizing',  // in case some services require asynchronous requests to get access_tokens
-      'authorized',
-      'unauthorized',
-      'error',
-    ], null, _this.onStateChange),
-      _this.syncState = serviceState([
-        'idle',
-        'ready',
-        'syncing',
-        'error',
-      ], null, _this.onStateChange),
-      _this.initHeaders();
-    _this.events = events.getEventEmitter();
-    _this.lastFetch = Promise.resolve();
-    _this.startSync = _this.syncFactory();
-  },
-  on: function () {
-    return this.events.on.apply(null, arguments);
-  },
-  off: function () {
-    return this.events.off.apply(null, arguments);
-  },
-  fire: function () {
-    return this.events.fire.apply(null, arguments);
-  },
-  onStateChange: function () {
-    _.messenger.post({
-      cmd: 'UpdateSync',
-      data: getStates(),
-    });
-  },
-  syncFactory: function () {
-    var _this = this;
-    var promise, debouncedResolve;
-    function shouldSync() {
-      return _this.authState.is('authorized') && _this.config.get('enabled');
-    }
-    function init() {
-      if (!shouldSync()) return Promise.resolve();
-      console.log('Ready to sync:', _this.displayName);
-      _this.syncState.set('ready');
-      promise = current = current.then(function () {
-        return new Promise(function (resolve, _reject) {
-          debouncedResolve = _.debounce(resolve, 10 * 1000);
-          debouncedResolve();
-        });
-      }).then(function () {
-        if (shouldSync()) {
-          return _this.sync();
-        }
-        _this.syncState.set('idle');
-      }).then(function () {
-        promise = debouncedResolve = null;
-      });
-    }
-    return function () {
-      if (!promise) init();
-      debouncedResolve && debouncedResolve();
-      return promise;
-    };
-  },
-  prepare: function () {
-    var _this = this;
-    _this.authState.set('initializing');
-    var token = _this.token = _this.config.get('token');
-    _this.initHeaders();
-    return (token ? Promise.resolve(_this.user()) : Promise.reject())
-    .then(function () {
-      _this.authState.set('authorized');
-    }, function (err) {
-      if (err) {
-        if (err.status === 401) {
-          _this.config.clear();
-          _this.authState.set('unauthorized');
-        } else {
-          console.error(err);
-          _this.authState.set('error');
-        }
-        _this.syncState.set('idle');
-        // _this.config.set('enabled', false);
-      } else {
-        _this.authState.set('unauthorized');
-      }
-      throw err;
-    });
-  },
-  checkSync: function () {
-    var _this = this;
-    return _this.prepare()
-    .then(function () {
-      servicesReady.push(_this);
-      return _this.startSync();
-    }, function () {
-      var i = servicesReady.indexOf(_this);
-      if (~i) servicesReady.splice(i, 1);
-    });
-  },
-  user: _.noop,
-  getMeta: function () {
-    var _this = this;
-    return _this.get(_this.metaFile)
-    .then(function (data) {
-      return JSON.parse(data);
-    });
-  },
-  initHeaders: function () {
-    var headers = this.headers = {};
-    var token = this.token;
-    if (token) headers.Authorization = 'Bearer ' + token;
-  },
-  request: function (options) {
-    var _this = this;
-    var progress = _this.progress;
-    var lastFetch;
-    if (options.noDelay) {
-      lastFetch = Promise.resolve();
-    } else {
-      lastFetch = _this.lastFetch;
-      _this.lastFetch = lastFetch.then(function () {
-        return _this.delay();
-      });
-    }
-    progress.total ++;
-    _this.onStateChange();
-    return lastFetch.then(function () {
-      return new Promise(function (resolve, reject) {
-        var xhr = new XMLHttpRequest;
-        var prefix = options.prefix;
-        if (prefix == null) prefix = _this.urlPrefix;
-        xhr.open(options.method || 'GET', prefix + options.url, true);
-        var headers = Object.assign({}, _this.headers, options.headers);
-        if (options.body && typeof options.body === 'object') {
-          headers['Content-Type'] = 'application/json';
-          options.body = JSON.stringify(options.body);
-        }
-        Object.keys(headers).forEach(function (key) {
-          var v = headers[key];
-          v && xhr.setRequestHeader(key, v);
-        });
-        xhr.onloadend = function () {
-          progress.finished ++;
-          _this.onStateChange();
-          if (xhr.status === 503) {
-            // TODO Too Many Requests
-          }
-          // net error: xhr.status === 0
-          if (xhr.status >= 200 && xhr.status < 300) {
-            resolve(xhr.responseText);
-          } else {
-            requestError();
-          }
-        };
-        xhr.send(options.body);
-
-        function requestError() {
-          reject({
-            url: options.url,
-            status: xhr.status,
-            xhr: xhr,
-          });
-        }
-      });
-    });
-  },
-  sync: function () {
-    var _this = this;
-    _this.progress = {
-      finished: 0,
-      total: 0,
-    };
-    _this.syncState.set('syncing');
-    return _this.getMeta()
-    .then(function (meta) {
-      return Promise.all([
-        meta,
-        _this.list(),
-        app.vmdb.getScriptsByIndex('position'),
-      ]);
-    }).then(function (res) {
-      var remote = {
-        meta: res[0],
-        data: res[1],
-      };
-      var local = {
-        meta: _this.config.get('meta', {}),
-        data: res[2],
-      };
-      var firstSync = !local.meta.timestamp;
-      var outdated = !local.meta.timestamp || remote.meta.timestamp > local.meta.timestamp;
-      console.log('First sync:', firstSync);
-      console.log('Outdated:', outdated, '(', 'local:', local.meta.timestamp, 'remote:', remote.meta.timestamp, ')');
-      var map = {};
-      var getRemote = [];
-      var putRemote = [];
-      var delRemote = [];
-      var delLocal = [];
-      remote.data.forEach(function (item) {
-        map[item.uri] = item;
-      });
-      local.data.forEach(function (item) {
-        var remoteItem = map[item.uri];
-        if (remoteItem) {
-          if (firstSync || !item.custom.modified || remoteItem.modified > item.custom.modified) {
-            getRemote.push(remoteItem);
-          } else if (remoteItem.modified < item.custom.modified) {
-            putRemote.push(item);
-          }
-          delete map[item.uri];
-        } else if (firstSync || !outdated) {
-          putRemote.push(item);
-        } else {
-          delLocal.push(item);
-        }
-      });
-      Object.keys(map).forEach(function (uri) {
-        var item = map[uri];
-        if (outdated) {
-          getRemote.push(item);
-        } else {
-          delRemote.push(item);
-        }
-      });
-      var promises = [].concat(
-        getRemote.map(function (item) {
-          console.log('Download script:', item.uri);
-          return _this.get(getFilename(item.uri)).then(function (raw) {
-            var data = {};
-            try {
-              var obj = JSON.parse(raw);
-              if (obj.version === 1) {
-                data.code = obj.code;
-                data.more = obj.more;
-              }
-            } catch (e) {
-              data.code = raw;
-            }
-            data.modified = item.modified;
-            if (!options.get('syncScriptStatus') && data.more) {
-              delete data.more.enabled;
-            }
-            return app.vmdb.parseScript(data)
-            .then(function (res) {
-              _.messenger.post(res);
-            });
-          });
-        }),
-        putRemote.map(function (item) {
-          console.log('Upload script:', item.uri);
-          var data = JSON.stringify({
-            version: 1,
-            code: item.code,
-            more: {
-              custom: item.custom,
-              enabled: item.enabled,
-              update: item.update,
-            },
-          });
-          return _this.put(getFilename(item.uri), data)
-          .then(function (data) {
-            if (item.custom.modified !== data.modified) {
-              item.custom.modified = data.modified;
-              return app.vmdb.saveScript(item);
-            }
-          });
-        }),
-        delRemote.map(function (item) {
-          console.log('Remove remote script:', item.uri);
-          return _this.remove(getFilename(item.uri));
-        }),
-        delLocal.map(function (item) {
-          console.log('Remove local script:', item.uri);
-          return app.vmdb.removeScript(item.id);
-        })
-      );
-      promises.push(Promise.all(promises).then(function () {
-        var promises = [];
-        var remoteChanged;
-        if (!remote.meta.timestamp || putRemote.length || delRemote.length) {
-          remoteChanged = true;
-          remote.meta.timestamp = Date.now();
-          promises.push(_this.put(_this.metaFile, JSON.stringify(remote.meta)));
-        }
-        if (!local.meta.timestamp || getRemote.length || delLocal.length || remoteChanged || outdated) {
-          local.meta.timestamp = remote.meta.timestamp;
-        }
-        local.meta.lastSync = Date.now();
-        _this.config.set('meta', local.meta);
-        return Promise.all(promises);
-      }));
-      return Promise.all(promises.map(function (promise) {
-        // ignore errors to ensure all promises are fulfilled
-        return promise.then(_.noop, function (err) {
-          return err || true;
-        });
-      }))
-      .then(function (errors) {
-        errors = errors.filter(function (err) {return err;});
-        if (errors.length) throw errors;
-      });
-    })
-    .then(function () {
-      _this.syncState.set('idle');
-    }, function (err) {
-      _this.syncState.set('error');
-      console.log('Failed syncing:', _this.name);
-      console.log(err);
-    });
-  },
-});
+var base = require('./base');
 
 tabs.update(function (tab) {
-  tab.url && services.some(function (service) {
-    return service.checkAuthenticate && service.checkAuthenticate(tab.url);
-  }) && tabs.remove(tab.id);
+  tab.url && base.checkAuthenticateUrl(tab.url) && tabs.remove(tab.id);
 });
 
+// import sync modules
+require('./dropbox');
+require('./onedrive');
+
 module.exports = {
-  init: init,
-  sync: sync,
-  service: service,
-  states: getStates,
-  utils: {
-    getFilename: getFilename,
-    isScriptFile: isScriptFile,
-    getURI: getURI,
-  },
-  BaseService: BaseService,
+  initialize: base.initialize,
+  sync: base.sync,
+  states: base.getStates,
+  service: base.getService,
+  authenticate: base.authenticate,
 };

+ 31 - 17
src/background/sync/onedrive.js

@@ -1,7 +1,9 @@
-var sync = require('.');
+// Reference: https://dev.onedrive.com/README.htm
+
+var _ = require('src/common');
+var base = require('./base');
 var tabsUtils = require('../utils/tabs');
 var searchUtils = require('../utils/search');
-var _ = require('../../common');
 
 var config = Object.assign({
   client_id: '000000004418358A',
@@ -13,10 +15,10 @@ var config = Object.assign({
 
 function authenticate() {
   var params = {
-    response_type: 'code',
     client_id: config.client_id,
-    redirect_uri: config.redirect_uri,
     scope: 'onedrive.appfolder wl.offline_access',
+    response_type: 'code',
+    redirect_uri: config.redirect_uri,
   };
   var url = 'https://login.live.com/oauth20_authorize.srf';
   var qs = searchUtils.dump(params);
@@ -25,7 +27,7 @@ function authenticate() {
 }
 function checkAuthenticate(url) {
   var redirect_uri = config.redirect_uri + '?code=';
-  if (url.slice(0, redirect_uri.length) === redirect_uri) {
+  if (url.startsWith(redirect_uri)) {
     onedrive.authState.set('authorizing');
     authorized({
       code: url.slice(redirect_uri.length),
@@ -49,8 +51,8 @@ function authorized(params) {
       redirect_uri: config.redirect_uri,
       grant_type: 'authorization_code',
     }, params)),
-  }).then(function (text) {
-    var data = JSON.parse(text);
+    responseType: 'json',
+  }).then(function (data) {
     if (data.access_token) {
       onedrive.config.set({
         uid: data.user_id,
@@ -65,12 +67,12 @@ function authorized(params) {
 function normalize(item) {
   return {
     size: item.size,
-    uri: sync.utils.getURI(item.name),
+    uri: base.utils.getURI(item.name),
     modified: new Date(item.lastModifiedDateTime).getTime(),
   };
 }
 
-var OneDrive = sync.BaseService.extend({
+var OneDrive = base.BaseService.extend({
   name: 'onedrive',
   displayName: 'OneDrive',
   urlPrefix: 'https://api.onedrive.com/v1.0',
@@ -85,6 +87,12 @@ var OneDrive = sync.BaseService.extend({
     });
   },
   user: function () {
+    function requestUser() {
+      return _this.request({
+        url: '/drive',
+        responseType: 'json',
+      });
+    }
     var _this = this;
     return requestUser()
     .catch(function (res) {
@@ -92,16 +100,22 @@ var OneDrive = sync.BaseService.extend({
         return _this.refreshToken().then(requestUser);
       }
       throw res;
+    })
+    .catch(function (res) {
+      if (res.status === 400 && _.object.get(res, ['data', 'error']) === 'invalid_grant') {
+        throw {
+          type: 'unauthorized',
+        };
+      }
+      throw {
+        type: 'error',
+        data: res,
+      };
     });
-    function requestUser() {
-      return _this.request({
-        url: '/drive',
-      });
-    }
   },
   getMeta: function () {
     function getMeta() {
-      return sync.BaseService.prototype.getMeta.call(_this);
+      return base.BaseService.prototype.getMeta.call(_this);
     }
     var _this = this;
     return getMeta()
@@ -125,7 +139,7 @@ var OneDrive = sync.BaseService.extend({
       return JSON.parse(text);
     }).then(function (data) {
       return data.value.filter(function (item) {
-        return item.file && sync.utils.isScriptFile(item.name);
+        return item.file && base.utils.isScriptFile(item.name);
       }).map(normalize);
     });
   },
@@ -174,4 +188,4 @@ var OneDrive = sync.BaseService.extend({
   authenticate: authenticate,
   checkAuthenticate: checkAuthenticate,
 });
-var onedrive = sync.service('onedrive', OneDrive);
+var onedrive = base.register(OneDrive);

+ 7 - 2
src/common.js

@@ -55,7 +55,7 @@ _.object = function () {
     var keys = normalizeKeys(key);
     for (var i = 0, len = keys.length; i < len; i ++) {
       key = keys[i];
-      if (obj && (key in obj)) obj = obj[key];
+      if (obj && typeof obj === 'object' && (key in obj)) obj = obj[key];
       else return def;
     }
     return obj;
@@ -68,7 +68,12 @@ _.object = function () {
       key = keys[i];
       sub = sub[key] = sub[key] || {};
     }
-    sub[keys[keys.length - 1]] = val;
+    var lastKey = keys[keys.length - 1];
+    if (val == null) {
+      delete sub[lastKey];
+    } else {
+      sub[lastKey] = val;
+    }
     return obj;
   }
   return {