From ca29cc992c31ebc96c099a9314ba11ad57a96c30 Mon Sep 17 00:00:00 2001 From: WangJie Date: Sun, 21 Oct 2018 23:06:16 +0800 Subject: [PATCH 01/11] change: main page use drawer & some perf improve - perf: dynamic import template & split chunks with popup page - change: use drawer & primary color - feat: allow import / export file --- package.json | 4 +- src/_locales/en/messages.json | 12 +++ src/_locales/zh_CN/messages.json | 12 +++ src/common/exchange.js | 42 ++++++++ src/common/utils.js | 14 ++- src/component/ImportExportPanel.vue | 129 ----------------------- src/component/main/Drawer.vue | 116 +++++++++++++++++++++ src/component/main/Snackbar.vue | 19 ++++ src/component/main/Toolbar.vue | 84 +++++++++++++++ src/index.js | 9 +- src/page/Main.vue | 156 +++------------------------- src/page/{ => main}/About.vue | 0 src/page/{ => main}/DetailList.vue | 4 +- src/page/main/ImportExport.vue | 125 ++++++++++++++++++++++ src/page/{ => main}/Options.vue | 1 + src/page/{ => main}/SyncInfo.vue | 0 src/router/index.js | 18 ++-- src/store/index.js | 24 ++++- yarn.lock | 36 +++++++ 19 files changed, 524 insertions(+), 281 deletions(-) create mode 100644 src/common/exchange.js delete mode 100644 src/component/ImportExportPanel.vue create mode 100644 src/component/main/Drawer.vue create mode 100644 src/component/main/Snackbar.vue create mode 100644 src/component/main/Toolbar.vue rename src/page/{ => main}/About.vue (100%) rename src/page/{ => main}/DetailList.vue (99%) create mode 100644 src/page/main/ImportExport.vue rename src/page/{ => main}/Options.vue (99%) rename src/page/{ => main}/SyncInfo.vue (100%) diff --git a/package.json b/package.json index 28f4c7a..da105ef 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "prebuild": "yarn lint" }, "dependencies": { + "downloadjs": "^1.4.7", "lodash": "^4.17.10", "material-design-icons-iconfont": "^3.0.3", "moment": "^2.22.1", "vue": "^2.5.16", + "vue-clipboard2": "^0.2.1", "vue-router": "^3.0.1", "vuedraggable": "^2.16.0", "vuetify": "^1.2.3", @@ -33,9 +35,9 @@ "babel-loader": "^7.1.4", "babel-plugin-lodash": "^3.3.4", "babel-plugin-syntax-dynamic-import": "^6.18.0", - "cross-env": "^5.2.0", "clean-webpack-plugin": "^0.1.19", "copy-webpack-plugin": "^4.5.1", + "cross-env": "^5.2.0", "css-loader": "^0.28.11", "eslint": "^5.3.0", "eslint-plugin-vue": "^5.0.0-beta.3", diff --git a/src/_locales/en/messages.json b/src/_locales/en/messages.json index d209344..6224c7a 100644 --- a/src/_locales/en/messages.json +++ b/src/_locales/en/messages.json @@ -233,6 +233,18 @@ "ui_export_json": { "message": "Export to JSON" }, + "ui_save_as_file": { + "message": "Save as file" + }, + "ui_copy": { + "message": "Copy" + }, + "ui_copied": { + "message": "Copied!" + }, + "ui_import_from_file": { + "message": "Import from file" + }, "ui_import_comp": { "message": "Import (compatible with OneTab)" }, diff --git a/src/_locales/zh_CN/messages.json b/src/_locales/zh_CN/messages.json index 338b5e3..850927a 100644 --- a/src/_locales/zh_CN/messages.json +++ b/src/_locales/zh_CN/messages.json @@ -233,6 +233,18 @@ "ui_export_json": { "message": "导出至 JSON" }, + "ui_save_as_file": { + "message": "保存为文件" + }, + "ui_copy": { + "message": "复制" + }, + "ui_copied": { + "message": "复制成功!" + }, + "ui_import_from_file": { + "message": "导入文件" + }, "ui_import_comp": { "message": "导入 (兼容 OneTab)" }, diff --git a/src/common/exchange.js b/src/common/exchange.js new file mode 100644 index 0000000..f8c1082 --- /dev/null +++ b/src/common/exchange.js @@ -0,0 +1,42 @@ +import _ from 'lodash' +import moment from 'moment' +import list from '@/common/list' +import storage from '@/common/storage' +import download from 'downloadjs' + +const importFromText = async (compatible, data) => { + const lists = compatible ? data.split('\n\n') + .filter(i => i) + .map(i => i.split('\n') + .filter(j => j) + .map(j => j.split('|').map(k => k.trim())) + .map(([url, title]) => ({ url, title }))) + .map(i => list.createNewTabList({tabs: i})) + : JSON.parse(data).map(i => list.createNewTabList(i)) + + const currentList = await storage.getLists() + await storage.setLists(_.concat(lists, currentList)) +} + +const exportToText = async compatible => { + const lists = await storage.getLists() + if (compatible) return lists.map(list => list.tabs.map(tab => tab.url + ' | ' + tab.title).join('\n')).join('\n\n') + return JSON.stringify(lists.map(i => _.pick(i, ['tabs', 'title', 'time'])), null, 4) +} + +const exportToFile = (text, {type, suffix}) => { + const name = 'BetterOnetab_backup_' + moment().format('L') + suffix + download(text, name, type) +} + +const types = { + JSON: { type: 'application/json', suffix: '.json' }, + TEXT: { type: 'plain/text', suffix: '.txt' }, +} + +export default { + importFromText, + exportToText, + exportToFile, + types, +} diff --git a/src/common/utils.js b/src/common/utils.js index 74c4cfc..16ea2c7 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -1,5 +1,7 @@ -import moment from 'moment' import __ from './i18n' +import moment from 'moment' +import browser from 'webextension-polyfill' + moment.locale(__('@@ui_locale')) export const formatTime = time => { if (Date.now() - time < 3600E3) return moment(time).fromNow() @@ -23,3 +25,13 @@ export const one = fn => { return re } } +export const checkPermission = async permission => { + if (await browser.permissions.contains({permissions: [permission]})) return true + return browser.permissions.request({permissions: [permission]}) +} +export const readFile = file => new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = event => resolve(event.target.result) + reader.onerror = reject + reader.readAsText(file) +}) diff --git a/src/component/ImportExportPanel.vue b/src/component/ImportExportPanel.vue deleted file mode 100644 index 6b09c21..0000000 --- a/src/component/ImportExportPanel.vue +++ /dev/null @@ -1,129 +0,0 @@ - - diff --git a/src/component/main/Drawer.vue b/src/component/main/Drawer.vue new file mode 100644 index 0000000..622b898 --- /dev/null +++ b/src/component/main/Drawer.vue @@ -0,0 +1,116 @@ + + + diff --git a/src/component/main/Snackbar.vue b/src/component/main/Snackbar.vue new file mode 100644 index 0000000..8b41132 --- /dev/null +++ b/src/component/main/Snackbar.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/component/main/Toolbar.vue b/src/component/main/Toolbar.vue new file mode 100644 index 0000000..9ecdf5e --- /dev/null +++ b/src/component/main/Toolbar.vue @@ -0,0 +1,84 @@ + + + diff --git a/src/index.js b/src/index.js index 1ff5bb4..6457e71 100644 --- a/src/index.js +++ b/src/index.js @@ -3,10 +3,17 @@ import App from './App.vue' import router from './router' import store from './store' import Vuetify from 'vuetify' +import VueClipboard from 'vue-clipboard2' +import colors from 'vuetify/es5/util/colors' Vue.config.productionTip = false Vue.config.devtools = true -Vue.use(Vuetify) +Vue.use(VueClipboard) +Vue.use(Vuetify, { + theme: { + primary: colors.lightBlue, + } +}) const app = new Vue({ el: '#app', diff --git a/src/page/Main.vue b/src/page/Main.vue index 34438c6..ad7e217 100644 --- a/src/page/Main.vue +++ b/src/page/Main.vue @@ -1,88 +1,8 @@ diff --git a/src/page/Options.vue b/src/page/main/Options.vue similarity index 99% rename from src/page/Options.vue rename to src/page/main/Options.vue index 33fb413..5e163ac 100644 --- a/src/page/Options.vue +++ b/src/page/main/Options.vue @@ -35,6 +35,7 @@ > import(/* webpackChunkName: "popup" */ '@/page/Popup') +const Main = () => import(/* webpackChunkName: "main" */ '@/page/Main') +const SyncInfo = () => import(/* webpackChunkName: "main" */ '@/page/main/SyncInfo') +const Options = () => import(/* webpackChunkName: "main" */ '@/page/main/Options') +const About = () => import(/* webpackChunkName: "main" */ '@/page/main/About') +const ImportExport = () => import(/* webpackChunkName: "main" */ '@/page/main/ImportExport') +const DetailList = () => import(/* webpackChunkName: "main" */ '@/page/main/DetailList') Vue.use(Router) @@ -35,6 +36,11 @@ const router = new Router({ component: About, name: 'about', }, + { + path: 'import-export', + component: ImportExport, + name: 'import-export', + }, { path: '*', component: DetailList, diff --git a/src/store/index.js b/src/store/index.js index 2f0216c..193adb1 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -13,6 +13,9 @@ export default new Vuex.Store({ opts: options.getDefaultOptions(), hasToken: false, conflict: null, + drawer: false, + nightmode: false, + snackbar: { status: false, msg: '' }, }, mutations: { setOption(state, payload) { @@ -26,6 +29,16 @@ export default new Vuex.Store({ setConflict(state, payload) { state.conflict = _.isEmpty(payload) ? null : payload }, + switchDrawer(state) { + state.drawer = !state.drawer + }, + setNightmode(state, payload) { + state.nightmode = payload + }, + showSnackbar(state, message) { + state.snackbar.msg = message + state.snackbar.status = true + }, }, actions: { async loadOptions({commit}) { @@ -37,6 +50,15 @@ export default new Vuex.Store({ async loadConflict({commit}) { const {conflict} = await browser.storage.local.get('conflict') commit('setConflict', conflict) - } + }, + async loadNightmode({commit, state}) { + const window = await browser.runtime.getBackgroundPage() + window.nightmode = _.defaultTo(window.nightmode, state.opts.defaultNightMode) + commit('setNightmode', window.nightmode) + }, + async switchNightmode({commit, state}) { + const window = await browser.runtime.getBackgroundPage() + commit('setNightmode', window.nightmode = !state.nightmode) + }, } }) diff --git a/yarn.lock b/yarn.lock index 437874b..6685c67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1544,6 +1544,14 @@ cli-width@^2.0.0: version "2.2.0" resolved "http://registry.npm.taobao.org/cli-width/download/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" +clipboard@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/clipboard/-/clipboard-2.0.1.tgz#a12481e1c13d8a50f5f036b0560fe5d16d74e46a" + dependencies: + good-listener "^1.2.2" + select "^1.1.2" + tiny-emitter "^2.0.0" + cliui@^3.2.0: version "3.2.0" resolved "http://registry.npm.taobao.org/cliui/download/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d" @@ -2063,6 +2071,10 @@ delayed-stream@~1.0.0: version "1.0.0" resolved "http://registry.npm.taobao.org/delayed-stream/download/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" +delegate@^3.1.2: + version "3.2.0" + resolved "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" + delegates@^1.0.0: version "1.0.0" resolved "http://registry.npm.taobao.org/delegates/download/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" @@ -2163,6 +2175,10 @@ dot-prop@^4.1.1: dependencies: is-obj "^1.0.0" +downloadjs@^1.4.7: + version "1.4.7" + resolved "https://registry.npmjs.org/downloadjs/-/downloadjs-1.4.7.tgz#f69f96f940e0d0553dac291139865a3cd0101e3c" + duplexer3@^0.1.4: version "0.1.4" resolved "http://registry.npm.taobao.org/duplexer3/download/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2" @@ -2954,6 +2970,12 @@ globule@^1.0.0: lodash "~4.17.4" minimatch "~3.0.2" +good-listener@^1.2.2: + version "1.2.2" + resolved "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" + dependencies: + delegate "^3.1.2" + got@^7.0.0: version "7.1.0" resolved "http://registry.npm.taobao.org/got/download/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a" @@ -5778,6 +5800,10 @@ scss-tokenizer@^0.2.3: js-base64 "^2.1.8" source-map "^0.4.2" +select@^1.1.2: + version "1.1.2" + resolved "https://registry.npmjs.org/select/-/select-1.1.2.tgz#0e7350acdec80b1108528786ec1d4418d11b396d" + "semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.5.0: version "5.5.0" resolved "http://registry.npm.taobao.org/semver/download/semver-5.5.0.tgz#dc4bbc7a6ca9d916dee5d43516f0092b58f7b8ab" @@ -6272,6 +6298,10 @@ timers-browserify@^2.0.4: dependencies: setimmediate "^1.0.4" +tiny-emitter@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.0.2.tgz#82d27468aca5ade8e5fd1e6d22b57dd43ebdfb7c" + tmp@^0.0.33: version "0.0.33" resolved "http://registry.npm.taobao.org/tmp/download/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -6597,6 +6627,12 @@ vm-browserify@0.0.4: dependencies: indexof "0.0.1" +vue-clipboard2@^0.2.1: + version "0.2.1" + resolved "https://registry.npmjs.org/vue-clipboard2/-/vue-clipboard2-0.2.1.tgz#9f06690af1c98aef344be1fc4beb00cdc5307ee1" + dependencies: + clipboard "^2.0.0" + vue-eslint-parser@^3.2.1: version "3.2.2" resolved "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-3.2.2.tgz#47c971ee4c39b0ee7d7f5e154cb621beb22f7a34" From 2457a83e9c171e6d8d08aa2c0446707662d56ca1 Mon Sep 17 00:00:00 2001 From: WangJie Date: Sun, 28 Oct 2018 16:07:15 +0800 Subject: [PATCH 02/11] refactor front-end styles & features - feat: multi items operations - feat: detail list pagination - feat: hide too much items in detail list - refactor: search feature can be used in every page - refactor: use drawer - depracate: some options have be depracated (removeItemBtnPos, fixedToolbar, enableSearch) --- src/common/constants.js | 2 + src/common/list.js | 16 +- src/common/options.js | 34 ++- src/common/storage.js | 3 +- src/common/tabs.js | 5 +- src/component/main/Drawer.vue | 17 +- src/component/main/SearchForm.vue | 51 ++++ src/component/main/Toolbar.vue | 23 +- src/manifest.json | 2 +- src/page/Main.vue | 7 +- src/page/main/DetailList.vue | 451 ++++++++++++++++++++++-------- src/page/main/ImportExport.vue | 22 ++ src/page/main/Search.vue | 164 +++++++++++ src/page/main/SyncInfo.vue | 59 +--- src/router/index.js | 12 +- src/store/index.js | 37 ++- 16 files changed, 688 insertions(+), 217 deletions(-) create mode 100644 src/common/constants.js create mode 100644 src/component/main/SearchForm.vue create mode 100644 src/page/main/Search.vue diff --git a/src/common/constants.js b/src/common/constants.js new file mode 100644 index 0000000..086e22f --- /dev/null +++ b/src/common/constants.js @@ -0,0 +1,2 @@ +export const PICKED_TAB_PROPS = ['url', 'title', 'favIconUrl', 'pinned'] +export const PICKED_LIST_RPOPS = ['tabs', 'title', 'time', 'pinned', 'expand', 'color'] diff --git a/src/common/list.js b/src/common/list.js index 25bfcf7..e73091f 100644 --- a/src/common/list.js +++ b/src/common/list.js @@ -1,3 +1,9 @@ +import _ from 'lodash' +import { + PICKED_TAB_PROPS, + PICKED_LIST_RPOPS, +} from './constants' + const createNewTabList = ({tabs, title, time}) => ({ tabs: tabs || [], title: title || '', @@ -5,6 +11,14 @@ const createNewTabList = ({tabs, title, time}) => ({ titleEditing: false, pinned: false, expand: true, + color: '', }) -export default {createNewTabList} +// Preserving the needed properties before store lists. +const normalize = list => { + const normalizedList = _.pick(list, PICKED_LIST_RPOPS) + normalizedList.tabs = normalizedList.tabs.map(tab => _.pick(tab, PICKED_TAB_PROPS)) + return normalizedList +} + +export default {createNewTabList, normalize} diff --git a/src/common/options.js b/src/common/options.js index 51b918b..2bb6d43 100644 --- a/src/common/options.js +++ b/src/common/options.js @@ -95,6 +95,7 @@ export const optionsList = [ label: __('opt_label_right'), }, ], + deprecated: '1.4', }, { cate: cate.APPEARANCE, @@ -137,6 +138,7 @@ export const optionsList = [ desc: __('opt_desc_fixedToolbar'), type: Boolean, default: false, + deprecated: '1.4', }, { cate: cate.BEHAVIOUR, @@ -212,6 +214,7 @@ export const optionsList = [ type: Boolean, default: true, new: '1.3.7', + deprecated: '1.4', }, { cate: cate.BEHAVIOUR, @@ -229,13 +232,38 @@ export const optionsList = [ default: true, new: '1.4.0', }, + { + cate: cate.APPEARANCE, + name: 'listsPerPage', + desc: __('opt_desc_listsPerPage'), + type: String, + default: 10, + items: [ + { + value: 5, + label: 5, + }, + { + value: 10, + label: 10, + }, + { + value: 15, + label: 15, + }, + ], + new: '1.4.0', + }, ] +const availableOptionsList = optionsList.filter(i => !i.deprecated) + if (DEBUG) { - console.debug('current options number', optionsList.length) - window.printOptionsMap = () => console.debug(optionsList.map(i => i.name + ': ' + i.type.name + ',').join('\n')) + console.debug('current options number', availableOptionsList.length) + window.printOptionsMap = () => console.debug(availableOptionsList.map(i => i.name + ': ' + i.type.name + ',').join('\n')) } -const getDefaultOptions = () => _.mapValues(_.keyBy(optionsList, 'name'), i => i.default) + +const getDefaultOptions = () => _.mapValues(_.keyBy(availableOptionsList, 'name'), i => i.default) export default {getDefaultOptions, optionsList} diff --git a/src/common/storage.js b/src/common/storage.js index d51f9c7..82e2197 100644 --- a/src/common/storage.js +++ b/src/common/storage.js @@ -1,4 +1,5 @@ import _ from 'lodash' +import list from '@/common/list' import browser from 'webextension-polyfill' import boss from '@/common/service/boss' @@ -18,7 +19,7 @@ const getLists = () => get('lists') const setLists = async lists => { if (!Array.isArray(lists)) throw new TypeError(lists) - const handledLists = lists.filter(i => Array.isArray(i.tabs)) + const handledLists = lists.filter(i => Array.isArray(i.tabs)).map(list.normalize) const {opts} = await get('opts') if (opts && opts.removeDuplicate) { handledLists.forEach(list => { diff --git a/src/common/tabs.js b/src/common/tabs.js index 5a51b42..967e33b 100644 --- a/src/common/tabs.js +++ b/src/common/tabs.js @@ -2,11 +2,10 @@ import storage from './storage' import list from './list' import _ from 'lodash' import browser from 'webextension-polyfill' - -const pickedTabAttrs = ['url', 'title', 'favIconUrl', 'pinned'] +import {PICKED_TAB_PROPS} from './constants' const pickTabs = tabs => tabs.map(tab => { - const pickedTab = _.pick(tab, pickedTabAttrs) + const pickedTab = _.pick(tab, PICKED_TAB_PROPS) pickedTab.muted = tab.mutedInfo && tab.mutedInfo.muted return pickedTab }) diff --git a/src/component/main/Drawer.vue b/src/component/main/Drawer.vue index 622b898..a26f526 100644 --- a/src/component/main/Drawer.vue +++ b/src/component/main/Drawer.vue @@ -18,7 +18,7 @@ - fas fa-cog + settings {{ __('ui_options') }} @@ -26,7 +26,7 @@ - fas fa-exclamation-circle + info {{ __('ui_about') }} @@ -42,15 +42,18 @@ - fas fa-keyboard + keyboard {{ __('ui_keyboard_shortcuts') }} + + open_in_new + - fas fa-bug + feedback {{ __('ui_create_issue') }} @@ -78,7 +81,6 @@ + diff --git a/src/component/main/Toolbar.vue b/src/component/main/Toolbar.vue index 9ecdf5e..5eacf42 100644 --- a/src/component/main/Toolbar.vue +++ b/src/component/main/Toolbar.vue @@ -3,6 +3,8 @@ OneTab + + @@ -10,21 +12,20 @@ {{ tooltip }} - - - {{ __('ui_nightmode') }} - - - {{ __('ui_tab_list') }} + + + {{ nightmode ? 'brightness_5' : 'brightness_4' }} - + {{ __('ui_nightmode') }} + @@ -426,8 +595,32 @@ export default { } } } + .checkbox-column { + max-width: 40px; + margin-left: 16px; + .checkbox { + margin-left: 20px; + margin-top: 0; + padding-top: (40px - 24px) / 2; + } + } +} +.sortable-ghost, .sortable-chosen { + opacity: .5; + box-shadow: 0 3px 3px -2px rgba(0,0,0,.2), 0 3px 4px 0 rgba(0,0,0,.14), 0 1px 8px 0 rgba(0,0,0,.12); + &.list-item { + .drag-indicator { + display: flex; + } + } +} +.sortable-drag { + opacity: 0; } .list-item { + .checkbox { + margin-left: 20px; + } .clear-btn { display: none; } @@ -435,6 +628,26 @@ export default { .clear-btn { display: block; } + .drag-indicator { + display: flex; + } + } + .drag-indicator { + position: absolute; + cursor: move; + z-index: 1000; + display: none; + flex-direction: column; + justify-content: center; + height: 100%; + i { + display: inline-block; + width: 16px; + height: 16px; + background-repeat: no-repeat; + background-size: contain; + background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAQAAABIkb+zAAABiElEQVR4Ae3ZgYYCYRQF4AVi9QhVMECJuefNtl6h9AS79QhJz1ADQFX7HFtUrMVWdxdYMv+PvVz/cL4DcBCYuZ15ov8iIiIiIqJeA31Z4fSbJQbdpr3vqFPHXO7Qv8gd807d3neR1bCBlmST1ex9BzKBlkcm1r4DyeQW/EE3yQx9H/kIGk4+MvR9YAcNR/aGvg98QSP5NPR94AKN5GLo+5A9NBx5N/R9YAyNZGzo+8jbscciWoZ+Ai+yqb3vckrIvvyRmNXsfadjThaPx5ksOnVD31+3iYEUOOIoRfg8jvT9ERERcRfiLsRdiLsQdyHuQtyFuAtxF+IuxF2IuxAREXEXyl+kwAEHWaHfa1RsF5KZ3B7+ns/wXJldSNalx9m6IrsQXqGBvFVgF0ILV2gg33k7+V1IhtBwZJj+LrSBRrJNfxc6QSM5pb8LnaGRnJPfhbCFRrIz7EKpjbv2Xcj/MXq170LVepFNE/vElNwuZDjOUtuFAudxgQ8csMSg1+AuREREREREXn4A0o+voNRuPgAAAAAASUVORK5CYII=); + } } } .no-list-tip { diff --git a/src/page/main/ImportExport.vue b/src/page/main/ImportExport.vue index 34e3b4b..60f3383 100644 --- a/src/page/main/ImportExport.vue +++ b/src/page/main/ImportExport.vue @@ -34,6 +34,13 @@ {{ __('ui_export_json') }} {{ __('ui_copy') }} {{ __('ui_save_as_file') }} + + {{ __('ui_save_to_gdrive') }} + fab fa-google-drive + diff --git a/src/page/main/Search.vue b/src/page/main/Search.vue new file mode 100644 index 0000000..786d913 --- /dev/null +++ b/src/page/main/Search.vue @@ -0,0 +1,164 @@ + + + + diff --git a/src/page/main/SyncInfo.vue b/src/page/main/SyncInfo.vue index 5e00f82..a3bc147 100644 --- a/src/page/main/SyncInfo.vue +++ b/src/page/main/SyncInfo.vue @@ -118,48 +118,6 @@ - - - - - - - {{ __('ui_save_to_gdrive') }} - - - - - {{ __('ui_save_immediately') }} - - - - - - - @@ -170,9 +128,8 @@ diff --git a/src/router/index.js b/src/router/index.js index a12b7f4..567540b 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -6,6 +6,7 @@ const SyncInfo = () => import(/* webpackChunkName: "main" */ '@/page/main/SyncIn const Options = () => import(/* webpackChunkName: "main" */ '@/page/main/Options') const About = () => import(/* webpackChunkName: "main" */ '@/page/main/About') const ImportExport = () => import(/* webpackChunkName: "main" */ '@/page/main/ImportExport') +const Search = () => import(/* webpackChunkName: "main" */ '@/page/main/Search') const DetailList = () => import(/* webpackChunkName: "main" */ '@/page/main/DetailList') Vue.use(Router) @@ -42,10 +43,19 @@ const router = new Router({ name: 'import-export', }, { - path: '*', + path: 'search', + component: Search, + name: 'search', + }, + { + path: 'list', component: DetailList, name: 'detailList', }, + { + path: '*', + redirect: { name: 'detailList' } + }, ], }, ] diff --git a/src/store/index.js b/src/store/index.js index 193adb1..74d9892 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -10,12 +10,13 @@ Vue.use(Vuex) export default new Vuex.Store({ state: { - opts: options.getDefaultOptions(), - hasToken: false, - conflict: null, - drawer: false, - nightmode: false, - snackbar: { status: false, msg: '' }, + opts: options.getDefaultOptions(), // all options + hasToken: false, // whether token exists + conflict: null, // conflict object + drawer: false, // drawer status + nightmode: false, // nightmode status + snackbar: { status: false, msg: '' }, // snackbar status + listColors: [], // be used colors of lists }, mutations: { setOption(state, payload) { @@ -29,8 +30,8 @@ export default new Vuex.Store({ setConflict(state, payload) { state.conflict = _.isEmpty(payload) ? null : payload }, - switchDrawer(state) { - state.drawer = !state.drawer + setDrawer(state, drawer) { + state.drawer = drawer }, setNightmode(state, payload) { state.nightmode = payload @@ -39,6 +40,9 @@ export default new Vuex.Store({ state.snackbar.msg = message state.snackbar.status = true }, + setListColors(state, colors) { + state.listColors = colors + }, }, actions: { async loadOptions({commit}) { @@ -51,6 +55,15 @@ export default new Vuex.Store({ const {conflict} = await browser.storage.local.get('conflict') commit('setConflict', conflict) }, + async loadDrawer({commit}) { + const window = await browser.runtime.getBackgroundPage() + window.drawer = _.defaultTo(window.drawer, true) + commit('setDrawer', window.drawer) + }, + async switchDrawer({commit, state}) { + const window = await browser.runtime.getBackgroundPage() + commit('setDrawer', window.drawer = !state.drawer) + }, async loadNightmode({commit, state}) { const window = await browser.runtime.getBackgroundPage() window.nightmode = _.defaultTo(window.nightmode, state.opts.defaultNightMode) @@ -60,5 +73,13 @@ export default new Vuex.Store({ const window = await browser.runtime.getBackgroundPage() commit('setNightmode', window.nightmode = !state.nightmode) }, + async loadListColors({commit}) { + const lists = await storage.getLists() + const colors = new Set() + lists.forEach(list => { + colors.add(list.color || '') + }) + commit('setListColors', Array.from(colors)) + }, } }) From 1a429421edb036fd7f0c4f649280453afc5619e3 Mon Sep 17 00:00:00 2001 From: WangJie Date: Wed, 31 Oct 2018 10:27:29 +0800 Subject: [PATCH 03/11] refactor list modifier & sync logic --- src/common/constants.js | 5 +- src/common/list.js | 2 + src/common/listManager.js | 65 +++++++ src/common/options.js | 2 +- src/common/service/boss.js | 289 ++++++++++-------------------- src/common/utils.js | 4 + src/component/main/SearchForm.vue | 1 + src/page/main/DetailList.vue | 6 +- 8 files changed, 174 insertions(+), 200 deletions(-) create mode 100644 src/common/listManager.js diff --git a/src/common/constants.js b/src/common/constants.js index 086e22f..d929280 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -1,2 +1,5 @@ export const PICKED_TAB_PROPS = ['url', 'title', 'favIconUrl', 'pinned'] -export const PICKED_LIST_RPOPS = ['tabs', 'title', 'time', 'pinned', 'expand', 'color'] +export const PICKED_LIST_RPOPS = ['_id', 'tabs', 'title', 'time', 'pinned', 'expand', 'color'] + +export const TOKEN_KEY = 'token' +export const AUTH_HEADER = 'auth' diff --git a/src/common/list.js b/src/common/list.js index e73091f..5ac7ac2 100644 --- a/src/common/list.js +++ b/src/common/list.js @@ -3,8 +3,10 @@ import { PICKED_TAB_PROPS, PICKED_LIST_RPOPS, } from './constants' +import {genObjectId} from './utils' const createNewTabList = ({tabs, title, time}) => ({ + _id: genObjectId(), tabs: tabs || [], title: title || '', time: time || Date.now(), diff --git a/src/common/listManager.js b/src/common/listManager.js new file mode 100644 index 0000000..ac10760 --- /dev/null +++ b/src/common/listManager.js @@ -0,0 +1,65 @@ +import _ from 'lodash' +import browser from 'webextension-polyfill' + +const cache = { lists: null, ops: null } +const getStorage = async () => { + if (cache.lists && cache.ops) return cache + const {lists, ops} = await browser.storage.local.get(['lists', 'ops']) + cache.lists = lists + cache.ops = ops + return cache +} +const saveStorage = _.debounce(async () => { + await browser.storage.local.set(cache) + cache.lists = cache.ops = null +}, 5000) +const manager = {} +manager.modifier = { + addList(lists, [list]) { + lists.unshift(list) + }, + updateListById(lists, [listId, newList]) { + for (const list of lists) { + if (list._id !== listId) continue + for (const [k, v] of Object.entries(newList)) { + list[k] = v + } + return + } + }, + removeListById(lists, [listId]) { + const index = lists.findIndex(list => list._id === listId) + lists.splice(index, 1) + }, + changeListOrderRelatively(lists, [listId, diff]) { + const index = lists.findIndex(list => list._id === listId) + const [list] = lists.splice(index, 1) + lists.splice(index + diff, 0, list) + }, +} +const addEventListener = () => browser.runtime.onMessage.addListener(({listModifed}) => { + if (!listModifed) return + const {method, args} = listModifed + manager[method](args) +}) +const genMethods = isBackground => { + for (const [name, func] of Object.entries(manager.methods)) { + manager[name] = isBackground ? async (...args) => { + const {lists, ops} = await getStorage() + ops.push({method: name, args, time: Date.now()}) + func(lists, args) + saveStorage() + } : async (...args) => { + await browser.runtime.sendMessage({listModifed: {method: name, args}}) + } + } +} +manager.init = async () => { + if (manager.inited) return + const background = await browser.runtime.getBackgroundPage() + const isBackground = window === background + if (isBackground) await addEventListener() + genMethods(isBackground) + manager.inited = true +} +export default manager diff --git a/src/common/options.js b/src/common/options.js index 2bb6d43..8bf280b 100644 --- a/src/common/options.js +++ b/src/common/options.js @@ -266,4 +266,4 @@ if (DEBUG) { const getDefaultOptions = () => _.mapValues(_.keyBy(availableOptionsList, 'name'), i => i.default) -export default {getDefaultOptions, optionsList} +export default {getDefaultOptions, optionsList: availableOptionsList} diff --git a/src/common/service/boss.js b/src/common/service/boss.js index c3fa5b1..1fa7ee7 100644 --- a/src/common/service/boss.js +++ b/src/common/service/boss.js @@ -1,85 +1,70 @@ import _ from 'lodash' +import { + TOKEN_KEY, + AUTH_HEADER, +} from '../constants' import browser from 'webextension-polyfill' const apiUrl = 'https://boss.cnwangjie.com' -const tokenKey = 'boss_token' -const tokenHeader = 'auth' - -const hasToken = async () => tokenKey in await browser.storage.local.get(tokenKey) - -const getToken = async auth => { - const {[tokenKey]: existedToken, sync_info} = await browser.storage.local.get([tokenKey, 'sync_info']) - if (auth === 'google' && sync_info && sync_info.googleId && existedToken - || auth === 'github' && sync_info && sync_info.githubId && existedToken - || !auth && existedToken) return existedToken - else if (!['google', 'github'].includes(auth)) throw new Error('[boss]: unsupported auth') - console.log('[boss]: getting token') - const lend = browser.identity.getRedirectURL() - const authUrl = apiUrl + `/auth/${auth}` - const uid = sync_info ? sync_info.uid : null - const uidPart = uid ? `;uid:${uid}` : '' - const url = authUrl + '?state=ext:' + encodeURIComponent(lend) + uidPart - console.log('[boss]: url', url) - const to = await new Promise((resolve, reject) => { - chrome.identity.launchWebAuthFlow({ - url, - interactive: true, - }, to => { - const err = chrome.runtime.lastError - if (err) reject(err) - resolve(to) - }) - }) - const [, token] = /#(.*)#/.exec(to) - console.log('[boss]: got token', token) - await browser.storage.local.set({[tokenKey]: token}) - return token + +const hasToken = async () => TOKEN_KEY in await browser.storage.local.get(TOKEN_KEY) + +const getToken = async () => { + const {token: localToken} = await browser.storage.local.get(TOKEN_KEY) + if (localToken) return localToken + const {token: remoteToken} = await browser.storage.sync.get(TOKEN_KEY) + if (remoteToken) return remoteToken +} + +const setToken = async token => { + await browser.storage.local.set({[TOKEN_KEY]: token}) + await browser.storage.sync.set({[TOKEN_KEY]: token}) +} + +const removeToken = async () => { + await browser.storage.local.remove(TOKEN_KEY) + await browser.storage.sync.remove(TOKEN_KEY) } const fetchData = async (uri = '', method = 'GET', data = {}) => { const headers = new Headers() const token = await getToken() - if (token) headers.append(tokenHeader, token) - + if (token) headers.append(AUTH_HEADER, token) const option = { headers, method, mode: 'cors', } - const requestBody = Object.keys(data).map(key => { - if (typeof data[key] === 'object') data[key] = JSON.stringify(data[key]) - return key + '=' + encodeURIComponent(data[key]) - }).filter(i => i).join('&') - - if (['POST', 'PUT'].includes(method)) { - headers.append('Content-Type', 'application/x-www-form-urlencoded') - option.body = requestBody + if (['POST', 'PUT', 'PATCH'].includes(method)) { + headers.append('Content-Type', 'application/json') + option.body = JSON.stringify(data) } else { - uri += '?' + requestBody + uri += '?' + Object.keys(data).map(key => { + if (typeof data[key] === 'object') data[key] = JSON.stringify(data[key]) + return key + '=' + encodeURIComponent(data[key]) + }).filter(i => i).join('&') } return fetch(apiUrl + uri, option) .then(async res => { // use new token - if (res.headers.has(tokenHeader)) { - console.debug('[boss]: got new token', res.headers.get(tokenHeader)) - await browser.storage.local.set({[tokenKey]: res.headers.get(tokenHeader)}) + if (res.headers.has(AUTH_HEADER)) { + const newToken = res.headers.get(AUTH_HEADER) + console.debug('[boss]: got new token', newToken) + await setToken(newToken) } return res }) - .then(async res => { - if (res.status === 200) { - const json = await res.json() - if (json.status === 'error') throw new Error(json.message) - return json - } else return res.text() + .then(res => res.json()) + .then(res => { + if (res.status === 'error') throw new Error(res.message) + return res }) .catch(async err => { // remove expired token if (err.status === 401) { - await browser.storage.local.remove(tokenKey) - await browser.storage.local.remove('sync_info') + await removeToken() } else { console.error(err) throw new Error('Internal Server Error') @@ -87,159 +72,77 @@ const fetchData = async (uri = '', method = 'GET', data = {}) => { }) } -const getInfo = () => fetchData('/api/info') +const getInfo = () => fetchData('/api/info').then(info => { + info.optsUpdatedAt = Date.parse(info.optsUpdatedAt) || 0 + info.listsUpdatedAt = Date.parse(info.listsUpdatedAt) || 0 + return info +}) const getLists = () => fetchData('/api/lists') -const setLists = lists => fetchData('/api/lists', 'PUT', {lists}) const getOpts = () => fetchData('/api/opts') -const setOpts = opts => fetchData('/api/opts', 'PUT', {opts}) +const setOpts = opts => fetchData('/api/v2/opts', 'PUT', {opts}) +const changeListBulk = changes => fetchData('/api/v2/lists/bulk', 'POST', {changes}) -const forceDownloadRemoteImmediate = async () => { - if (!await hasToken()) return - const {conflict} = await browser.storage.local.get('conflict') - if (conflict) return browser.runtime.sendMessage({downloaded: {conflict}}) - const localInfo = await browser.storage.local.get(['listsUpdatedAt', 'optsUpdatedAt']) - const {listsUpdatedAt, optsUpdatedAt} = _.defaults(localInfo, {listsUpdatedAt: 0, optsUpdatedAt: 0}) - const info = await getInfo() - const works = [] - if (Date.parse(info.listsUpdatedAt) > listsUpdatedAt) { - works.push(async () => { - const remoteLists = await getLists() - await browser.storage.local.set({lists: remoteLists, listsUpdatedAt: Date.parse(info.listsUpdatedAt)}) - }) +const uploadOperations = async () => { + const {ops} = await browser.storage.local.get('ops') + if (!ops) return + const time = Date.now() + const changes = ops.sort((a, b) => a.time - b.time).map(op => ([op.method, ...op.args])) + const result = await changeListBulk(changes) + if (result.status === 'success') { + const {ops} = await browser.storage.local.get('ops') + await browser.storage.local.set({ops: ops.filter(op => op.time > time)}) } - if (Date.parse(info.optsUpdatedAt) > optsUpdatedAt) { - works.push(async () => { - const remoteOpts = await getOpts() - await browser.storage.local.set({opts: remoteOpts, optsUpdatedAt: Date.parse(info.optsUpdatedAt)}) - }) - } - await Promise.all(works.map(i => i())) - browser.storage.sendMessage({downloaded: 'success'}) + result.listsUpdatedAt = Date.parse(result.listsUpdatedAt) + return result } -const forceUpdate = async ({lists, opts}) => { - const works = [] - const conflict = (await browser.storage.local.get('conflict')).conflict || {} - if (lists) { - delete conflict.lists - works.push(async () => { - const {listsUpdatedAt} = await setLists(lists) - await browser.storage.local.set({listsUpdatedAt: Date.parse(listsUpdatedAt)}) - }) - } - if (opts) { - delete conflict.opts - works.push(async () => { - const {optsUpdatedAt} = setOpts(opts) - await browser.storage.local.set({optsUpdatedAt: Date.parse(optsUpdatedAt)}) - }) - } - await browser.storage.local.set({conflict}) - try { - await Promise.all(works.map(i => i())) - browser.runtime.sendMessage({uploaded: {conflict}}) - } catch (error) { - browser.runtime.sendMessage({uploaded: {error}}) - } +const applyRemoteLists = async () => { + const lists = await getLists() + const {listsUpdatedAt} = browser.storage.local.set(lists) + return Date.parse(listsUpdatedAt) +} + +const uploadOpts = async () => { + const {opts} = await browser.storage.local.get('opts') + const optsUpdatedAt = await setOpts(opts) + const result = await browser.storage.local.set(optsUpdatedAt) + result.optsUpdatedAt = Date.parse(result.optsUpdatedAt) + return result +} + +const applyRemoteOpts = async () => { + const opts = await getOpts() + return browser.storage.local.set(opts) } -const uploadImmediate = async () => { +const refresh = async () => { + const remoteInfo = await getInfo() const localInfo = await browser.storage.local.get(['listsUpdatedAt', 'optsUpdatedAt']) - const {listsUpdatedAt, optsUpdatedAt} = _.defaults(localInfo, {listsUpdatedAt: 0, optsUpdatedAt: 0}) - const info = await getInfo() - const todo = {} - const conflict = (await browser.storage.local.get('conflict')).conflict || {} - if (Date.parse(info.listsUpdatedAt) === listsUpdatedAt) { - const {lists} = await browser.storage.local.get('lists') - todo.lists = lists - delete conflict.lists - } else { - const lists = await getLists() - conflict.lists = { - local: {time: listsUpdatedAt}, - remote: {time: Date.parse(info.listsUpdatedAt), lists} - } - } - if (Date.parse(info.optsUpdatedAt) === optsUpdatedAt) { - const {opts} = await browser.storage.local.get('opts') - todo.opts = opts - delete conflict.opts - } else { - const opts = await getOpts() - const {opts: localOpts} = await browser.storage.local.get('opts') - if (Object.keys(localOpts).some(key => opts[key] !== localOpts[key])) { - const diff = _.pickBy(opts, (v, k) => (k in localOpts) && v !== localOpts[k]) - if (_.isEmpty(diff)) { - todo.opts = localOpts - delete conflict.opts - } else { - conflict.opts = { - local: {time: optsUpdatedAt}, - remote: {time: Date.parse(info.optsUpdatedAt), opts: diff} - } - } - } else { - todo.opts = opts - delete conflict.opts - } + localInfo.listsUpdatedAt = localInfo.listsUpdatedAt || 0 + localInfo.optsUpdatedAt = localInfo.optsUpdatedAt || 0 + + // normal lists sync logic: apply local operations firstly + const {ops} = await browser.storage.local.get('ops') + if (ops && ops.length) { + const {listsUpdatedAt} = await uploadOperations() + await browser.storage.local.set({listsUpdatedAt}) } - console.group('upload') - console.log('todo', todo) - console.log('conflict', conflict) - console.groupEnd('upload') - await forceUpdate(todo) - await browser.storage.local.set({conflict}) - return conflict -} -/** - * 同步规则:2018年08月22日22:36:51 - * - 每次操作上传 debounce - * - 期望服务器上次更新时间与本地相同 - * - 不相同的话显示同步冲突由用户选择是否上传 - * - 定时下载 - * - 信任远程,直接覆盖 - */ -const resolveConflict = async ({type, result}) => { - const {conflict} = await browser.storage.local.get('conflict') - if (type === 'lists') { - const {lists: local} = await browser.storage.local.get('lists') - const remote = conflict.lists.remote.lists - if (result === 'local') await forceUpdate({lists: local}) - if (result === 'remote') { - await browser.storage.local.set({lists: remote}) - await forceUpdate({lists: remote}) - } - if (result === 'both') { - const both = _.concat(local, remote) - await browser.storage.local.set({lists: both}) - await forceUpdate({lists: both}) - } - delete conflict.lists + // apply remote lists if remote lists update time later than local + if (remoteInfo.listsUpdatedAt > localInfo.listsUpdatedAt) { + const listsUpdatedAt = await applyRemoteLists() + await browser.storage.local.set({listsUpdatedAt}) } - if (type === 'opts') { - const {opts: local} = await browser.storage.local.get('opts') - const remote = conflict.opts.remote.opts - if (result === 'local') await forceUpdate({opts: local}) - if (result === 'remote') { - for (const key in local) { - if (key in remote) { - local[key] = remote[key] - } - } - await browser.storage.local.set({opts: local}) - await forceUpdate({opts: local}) - } - delete conflict.opts + + if (localInfo.optsUpdatedAt > remoteInfo.optsUpdatedAt) { + const {optsUpdatedAt} = await uploadOpts() + await browser.storage.local.set({optsUpdatedAt}) + } else if (localInfo.optsUpdatedAt < remoteInfo.optsUpdatedAt) { + await applyRemoteOpts() + await browser.storage.local.set({optsUpdatedAt: remoteInfo.optsUpdatedAt}) } - return browser.storage.local.set({conflict}) } export default { - getToken, - getInfo, hasToken, - forceUpdate, - uploadImmediate, - forceDownloadRemoteImmediate, - resolveConflict, + refresh, } diff --git a/src/common/utils.js b/src/common/utils.js index 16ea2c7..0cc0ca9 100644 --- a/src/common/utils.js +++ b/src/common/utils.js @@ -35,3 +35,7 @@ export const readFile = file => new Promise((resolve, reject) => { reader.onerror = reject reader.readAsText(file) }) +export const genObjectId = () => { + const timestamp = (new Date().getTime() / 1000 | 0).toString(16) + return timestamp + 'xxxxxxxxxxxxxxxx'.replace(/[x]/g, () => (Math.random() * 16 | 0).toString(16)).toLowerCase() +} diff --git a/src/component/main/SearchForm.vue b/src/component/main/SearchForm.vue index b54b589..106430f 100644 --- a/src/component/main/SearchForm.vue +++ b/src/component/main/SearchForm.vue @@ -1,6 +1,7 @@