浏览代码

feat: search in script code, /regexp/, lowercase on demand (#701)

* feat: search in script code, /regexp/

* refactor: rework sort + show enabledFirst for lastUpdateTime

* refactor: extract common/storage.js

* refactor: deduplicate ensureArray()

* refactor: move request() from index.js to util.js
tophf 6 年之前
父节点
当前提交
893f140068

+ 2 - 3
src/background/index.js

@@ -1,4 +1,4 @@
-import { noop, getUniqId } from '#/common';
+import { noop, getUniqId, ensureArray } from '#/common';
 import { objectGet } from '#/common/object';
 import * as sync from './sync';
 import {
@@ -200,8 +200,7 @@ const commands = {
     }, {});
   },
   SetOptions(data) {
-    const items = Array.isArray(data) ? data : [data];
-    items.forEach((item) => { setOption(item.key, item.value); });
+    ensureArray(data).forEach(item => setOption(item.key, item.value));
   },
   ConfirmInstall: confirmInstall,
   CheckScript({ name, namespace }) {

+ 2 - 3
src/background/sync/base.js

@@ -1,5 +1,5 @@
 import {
-  debounce, normalizeKeys, request, noop, makePause,
+  debounce, normalizeKeys, request, noop, makePause, ensureArray,
 } from '#/common';
 import {
   objectGet, objectSet, objectPick, objectPurify,
@@ -106,8 +106,7 @@ function serviceState(validStates, initialState, onChange) {
     return get();
   }
   function is(states) {
-    const stateArray = Array.isArray(states) ? states : [states];
-    return stateArray.includes(state);
+    return ensureArray(states).includes(state);
   }
   return { get, set, is };
 }

+ 5 - 110
src/background/utils/db.js

@@ -1,8 +1,9 @@
 import {
-  i18n, request, buffer2string, getFullUrl, isRemote, getRnd4,
+  i18n, getFullUrl, isRemote, getRnd4,
 } from '#/common';
 import { objectGet, objectSet } from '#/common/object';
 import { CMD_SCRIPT_ADD, CMD_SCRIPT_UPDATE } from '#/common/consts';
+import storage from '#/common/storage';
 import pluginEvents from '../plugin/events';
 import {
   getNameURI, parseMeta, newScript, getDefaultCustom,
@@ -13,117 +14,11 @@ import patchDB from './patch-db';
 import { setOption } from './options';
 import { sendMessageOrIgnore } from './message';
 
-function cacheOrFetch(handle) {
-  const requests = {};
-  return function cachedHandle(url, ...args) {
-    let promise = requests[url];
-    if (!promise) {
-      promise = handle.call(this, url, ...args)
-      .catch((err) => {
-        console.error(`Error fetching: ${url}`, err);
-      })
-      .then(() => {
-        delete requests[url];
-      });
-      requests[url] = promise;
-    }
-    return promise;
-  };
-}
-function ensureListArgs(handle) {
-  return function handleList(data) {
-    let items = Array.isArray(data) ? data : [data];
-    items = items.filter(Boolean);
-    if (!items.length) return Promise.resolve();
-    return handle.call(this, items);
-  };
-}
-
 const store = {};
-const storage = {
-  base: {
-    prefix: '',
-    getKey(id) {
-      return `${this.prefix}${id}`;
-    },
-    getOne(id) {
-      const key = this.getKey(id);
-      return browser.storage.local.get(key).then(data => data[key]);
-    },
-    getMulti(ids, def) {
-      return browser.storage.local.get(ids.map(id => this.getKey(id)))
-      .then((data) => {
-        const result = {};
-        ids.forEach((id) => { result[id] = data[this.getKey(id)] || def; });
-        return result;
-      });
-    },
-    set(id, value) {
-      if (!id) return Promise.resolve();
-      return browser.storage.local.set({
-        [this.getKey(id)]: value,
-      });
-    },
-    remove(id) {
-      if (!id) return Promise.resolve();
-      return browser.storage.local.remove(this.getKey(id));
-    },
-    removeMulti(ids) {
-      return browser.storage.local.remove(ids.map(id => this.getKey(id)));
-    },
-  },
+
+storage.script.onDump = (item) => {
+  store.scriptMap[item.props.id] = item;
 };
-storage.script = Object.assign({}, storage.base, {
-  prefix: 'scr:',
-  dump: ensureListArgs(function dump(items) {
-    const updates = {};
-    items.forEach((item) => {
-      updates[this.getKey(item.props.id)] = item;
-      store.scriptMap[item.props.id] = item;
-    });
-    return browser.storage.local.set(updates)
-    .then(() => items);
-  }),
-});
-storage.code = Object.assign({}, storage.base, {
-  prefix: 'code:',
-});
-storage.value = Object.assign({}, storage.base, {
-  prefix: 'val:',
-  dump(dict) {
-    const updates = {};
-    Object.keys(dict)
-    .forEach((id) => {
-      const value = dict[id];
-      updates[this.getKey(id)] = value;
-    });
-    return browser.storage.local.set(updates);
-  },
-});
-storage.require = Object.assign({}, storage.base, {
-  prefix: 'req:',
-  fetch: cacheOrFetch(function fetch(uri) {
-    return request(uri).then(({ data }) => this.set(uri, data));
-  }),
-});
-storage.cache = Object.assign({}, storage.base, {
-  prefix: 'cac:',
-  fetch: cacheOrFetch(function fetch(uri, check) {
-    return request(uri, { responseType: 'arraybuffer' })
-    .then(({ data: buffer, xhr }) => {
-      const contentType = (xhr.getResponseHeader('content-type') || '').split(';')[0];
-      const data = {
-        contentType,
-        buffer,
-        blob: options => new Blob([buffer], Object.assign({ type: contentType }, options)),
-        string: () => buffer2string(buffer),
-        base64: () => window.btoa(data.string()),
-      };
-      return (check ? Promise.resolve(check(data)) : Promise.resolve())
-      .then(() => this.set(uri, `${contentType},${data.base64()}`));
-    });
-  }),
-});
 
 register(initialize());
 

+ 0 - 65
src/common/index.js

@@ -85,71 +85,6 @@ export function getLocaleString(meta, key) {
   return localeMeta || meta[key] || '';
 }
 
-const binaryTypes = [
-  'blob',
-  'arraybuffer',
-];
-
-/**
- * Make a request.
- * @param {string} url
- * @param {RequestInit} options
- * @return Promise
- */
-export function request(url, options = {}) {
-  return new Promise((resolve, reject) => {
-    const xhr = new XMLHttpRequest();
-    const { responseType } = options;
-    xhr.open(options.method || 'GET', url, true);
-    if (binaryTypes.includes(responseType)) xhr.responseType = responseType;
-    const headers = Object.assign({}, options.headers);
-    let { body } = options;
-    if (body && Object.prototype.toString.call(body) === '[object Object]') {
-      headers['Content-Type'] = 'application/json';
-      body = JSON.stringify(body);
-    }
-    Object.keys(headers).forEach((key) => {
-      xhr.setRequestHeader(key, headers[key]);
-    });
-    xhr.onload = () => {
-      const res = getResponse(xhr, {
-        // status for `file:` protocol will always be `0`
-        status: xhr.status || 200,
-      });
-      if (res.status > 300) reject(res);
-      else resolve(res);
-    };
-    xhr.onerror = () => {
-      const res = getResponse(xhr, { status: -1 });
-      reject(res);
-    };
-    xhr.onabort = xhr.onerror;
-    xhr.ontimeout = xhr.onerror;
-    xhr.send(body);
-  });
-  function getResponse(xhr, extra) {
-    const { responseType } = options;
-    let data;
-    if (binaryTypes.includes(responseType)) {
-      data = xhr.response;
-    } else {
-      data = xhr.responseText;
-    }
-    if (responseType === 'json') {
-      try {
-        data = JSON.parse(data);
-      } catch (e) {
-        // Ignore invalid JSON
-      }
-    }
-    return Object.assign({
-      url,
-      data,
-      xhr,
-    }, extra);
-  }
-}
-
 export function getFullUrl(url, base) {
   const obj = new URL(url, base);
   // Use protocol whitelist to filter URLs

+ 118 - 0
src/common/storage.js

@@ -0,0 +1,118 @@
+import { browser } from './consts';
+import { request, buffer2string, ensureArray } from './util';
+
+const base = {
+  prefix: '',
+  getKey(id) {
+    return `${this.prefix}${id}`;
+  },
+  getOne(id) {
+    const key = this.getKey(id);
+    return browser.storage.local.get(key).then(data => data[key]);
+  },
+  async getMulti(ids, def) {
+    const data = await browser.storage.local.get(ids.map(this.getKey, this));
+    return ids.reduce((res, id) => {
+      res[id] = data[this.getKey(id)] || def;
+      return res;
+    }, {});
+  },
+  set(id, value) {
+    return id
+      ? browser.storage.local.set({ [this.getKey(id)]: value })
+      : Promise.resolve();
+  },
+  remove(id) {
+    return id
+      ? browser.storage.local.remove(this.getKey(id))
+      : Promise.resolve();
+  },
+  removeMulti(ids) {
+    return browser.storage.local.remove(ids.map(this.getKey, this));
+  },
+  async dump(data) {
+    const output = !this.prefix
+      ? data
+      : Object.entries(data).reduce((res, [key, value]) => {
+        res[this.getKey(key)] = value;
+        return res;
+      }, {});
+    await browser.storage.local.set(output);
+    return data;
+  },
+};
+
+const cacheOrFetch = (handle) => {
+  const requests = {};
+  return function cachedHandle(url, ...args) {
+    let promise = requests[url];
+    if (!promise) {
+      promise = handle.call(this, url, ...args)
+      .catch((err) => {
+        console.error(`Error fetching: ${url}`, err);
+      })
+      .then(() => {
+        delete requests[url];
+      });
+      requests[url] = promise;
+    }
+    return promise;
+  };
+};
+
+export default {
+
+  base,
+
+  cache: {
+    ...base,
+    prefix: 'cac:',
+    fetch: cacheOrFetch(async function fetch(uri, check) {
+      const { data: { buffer, xhr } } = await request(uri, { responseType: 'arraybuffer' });
+      const contentType = (xhr.getResponseHeader('content-type') || '').split(';')[0];
+      const data = {
+        contentType,
+        buffer,
+        blob: options => new Blob([buffer], Object.assign({ type: contentType }, options)),
+        string: () => buffer2string(buffer),
+        base64: () => window.btoa(data.string()),
+      };
+      if (check) await check(data);
+      return this.set(uri, `${contentType},${data.base64()}`);
+    }),
+  },
+
+  code: {
+    ...base,
+    prefix: 'code:',
+  },
+
+  require: {
+    ...base,
+    prefix: 'req:',
+    fetch: cacheOrFetch(async function fetch(uri) {
+      return this.set(uri, (await request(uri)).data);
+    }),
+  },
+
+  script: {
+    ...base,
+    prefix: 'scr:',
+    async dump(items) {
+      items = ensureArray(items).filter(Boolean);
+      if (!items.length) return;
+      const data = items.reduce((res, item) => {
+        res[this.getKey(item.props.id)] = item;
+        if (this.onDump) this.onDump(item);
+        return res;
+      }, {});
+      await base.dump(data);
+      return items;
+    },
+  },
+
+  value: {
+    ...base,
+    prefix: 'val:',
+  },
+};

+ 73 - 0
src/common/util.js

@@ -117,3 +117,76 @@ export function isEmpty(obj) {
   }
   return true;
 }
+
+export function ensureArray(data) {
+  return Array.isArray(data) ? data : [data];
+}
+
+const binaryTypes = [
+  'blob',
+  'arraybuffer',
+];
+
+/**
+ * Make a request.
+ * @param {string} url
+ * @param {RequestInit} options
+ * @return Promise
+ */
+export function request(url, options = {}) {
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    const { responseType } = options;
+    xhr.open(options.method || 'GET', url, true);
+    if (binaryTypes.includes(responseType)) xhr.responseType = responseType;
+    const headers = Object.assign({}, options.headers);
+    let { body } = options;
+    if (body && Object.prototype.toString.call(body) === '[object Object]') {
+      headers['Content-Type'] = 'application/json';
+      body = JSON.stringify(body);
+    }
+    Object.keys(headers).forEach((key) => {
+      xhr.setRequestHeader(key, headers[key]);
+    });
+    xhr.onload = () => {
+      const res = getResponse(xhr, {
+        // status for `file:` protocol will always be `0`
+        status: xhr.status || 200,
+      });
+      if (res.status > 300) {
+        reject(res);
+      } else {
+        resolve(res);
+      }
+    };
+    xhr.onerror = () => {
+      const res = getResponse(xhr, { status: -1 });
+      reject(res);
+    };
+    xhr.onabort = xhr.onerror;
+    xhr.ontimeout = xhr.onerror;
+    xhr.send(body);
+  });
+
+  function getResponse(xhr, extra) {
+    const { responseType } = options;
+    let data;
+    if (binaryTypes.includes(responseType)) {
+      data = xhr.response;
+    } else {
+      data = xhr.responseText;
+    }
+    if (responseType === 'json') {
+      try {
+        data = JSON.parse(data);
+      } catch (e) {
+        // Ignore invalid JSON
+      }
+    }
+    return Object.assign({
+      url,
+      data,
+      xhr,
+    }, extra);
+  }
+}

+ 1 - 1
src/options/index.js

@@ -43,7 +43,7 @@ function initScript(script) {
     getLocaleString(meta, 'description'),
     script.custom.name,
     script.custom.description,
-  ].filter(Boolean).join('\n').toLowerCase();
+  ].filter(Boolean).join('\n');
   const name = script.custom.name || localeName;
   const lowerName = name.toLowerCase();
   script.$cache = { search, name, lowerName };

+ 84 - 53
src/options/views/tab-installed.vue

@@ -47,23 +47,24 @@
             <locale-group i18n-key="labelFilterSort">
               <select :value="filters.sort.value" @change="onOrderChange">
                 <option
-                  v-for="option in filterOptions.sort"
-                  :key="option.value"
+                  v-for="(option, name) in filterOptions.sort"
                   v-text="option.title"
-                  :value="option.value">
+                  :key="name"
+                  :value="name">
                 </option>
               </select>
             </locale-group>
           </div>
-          <div v-if="filters.sort.value === 'alpha'">
+          <div v-show="currentSortCompare">
             <label>
-              <setting-check name="filters.showEnabledFirst" @change="updateLater"></setting-check>
+              <setting-check name="filters.showEnabledFirst" />
               <span v-text="i18n('optionShowEnabledFirst')"></span>
             </label>
           </div>
         </dropdown>
         <div class="filter-search hidden-sm">
-          <input type="search" :placeholder="i18n('labelSearchScript')" v-model="search">
+          <input type="search" :placeholder="i18n('labelSearchScript')" :title="searchError"
+                 v-model="search">
           <icon name="search"></icon>
         </div>
       </header>
@@ -112,41 +113,62 @@ import hookSetting from '#/common/hook-setting';
 import Icon from '#/common/ui/icon';
 import LocaleGroup from '#/common/ui/locale-group';
 import { setRoute, lastRoute } from '#/common/router';
+import storage from '#/common/storage';
 import ScriptItem from './script-item';
 import Edit from './edit';
 import { store, showMessage } from '../utils';
 
-const SORT_EXEC = { value: 'exec', title: i18n('filterExecutionOrder') };
-const SORT_ALPHA = { value: 'alpha', title: i18n('filterAlphabeticalOrder') };
-const SORT_UPDATE = { value: 'update', title: i18n('filterLastUpdateOrder') };
 const filterOptions = {
-  sort: [
-    SORT_EXEC,
-    SORT_ALPHA,
-    SORT_UPDATE,
-  ],
+  sort: {
+    exec: {
+      title: i18n('filterExecutionOrder'),
+    },
+    alpha: {
+      title: i18n('filterAlphabeticalOrder'),
+      compare: (
+        { $cache: { lowerName: a } },
+        { $cache: { lowerName: b } },
+      ) => (a < b ? -1 : a > b),
+    },
+    update: {
+      title: i18n('filterLastUpdateOrder'),
+      compare: (
+        { props: { lastUpdated: a } },
+        { props: { lastUpdated: b } },
+      ) => (+b || 0) - (+a || 0),
+    },
+  },
 };
 const filters = {
+  showEnabledFirst: null,
   sort: {
     value: null,
     title: null,
     set(value) {
-      const option = filterOptions.sort.find(item => item.value === value);
-      const { sort } = filters;
-      if (!option) {
-        sort.set(filterOptions.sort[0].value);
-        return;
+      const option = filterOptions.sort[value];
+      if (option) {
+        filters.sort.value = value;
+        filters.sort.title = option.title;
+      } else {
+        filters.sort.set(Object.keys(filterOptions.sort)[0]);
       }
-      sort.value = option && option.value;
-      sort.title = option && option.title;
     },
   },
 };
+const combinedCompare = cmpFunc => (
+  filters.showEnabledFirst
+    ? ((a, b) => b.config.enabled - a.config.enabled || cmpFunc(a, b))
+    : cmpFunc
+);
+hookSetting('filters.showEnabledFirst', (value) => {
+  filters.showEnabledFirst = value;
+});
 hookSetting('filters.sort', (value) => {
   filters.sort.set(value);
 });
 options.ready.then(() => {
   filters.sort.set(options.get('filters.sort'));
+  filters.showEnabledFirst = options.get('filters.showEnabledFirst');
 });
 
 const MAX_BATCH_DURATION = 100;
@@ -169,6 +191,7 @@ export default {
       filters,
       script: null,
       search: null,
+      searchError: null,
       modal: null,
       menuNewActive: false,
       showRecycle: false,
@@ -183,13 +206,17 @@ export default {
     };
   },
   watch: {
-    search: 'updateLater',
+    search: 'scheduleSearch',
     'filters.sort.value': 'updateLater',
+    'filters.showEnabledFirst': 'updateLater',
     showRecycle: 'onUpdate',
     scripts: 'refreshUI',
     'store.route.paths.1': 'onHashChange',
   },
   computed: {
+    currentSortCompare() {
+      return filterOptions.sort[filters.sort.value]?.compare;
+    },
     message() {
       if (this.store.loading) {
         return null;
@@ -213,37 +240,11 @@ export default {
       this.onHashChange();
     },
     onUpdate() {
-      const { search, filters: { sort }, showRecycle } = this;
-      const scripts = showRecycle ? this.trash : this.scripts;
-      const sortedScripts = [...scripts];
-      if (search) {
-        const lowerSearch = (search || '').toLowerCase();
-        for (const { $cache } of scripts) {
-          $cache.show = $cache.search.includes(lowerSearch);
-        }
-      }
-      if (sort.value === SORT_ALPHA.value) {
-        const showEnabledFirst = options.get('filters.showEnabledFirst');
-        const getSortKey = (item) => {
-          const keys = [];
-          if (showEnabledFirst) {
-            keys.push(item.config.enabled ? 0 : 1);
-          }
-          keys.push(item.$cache.lowerName);
-          return keys.join('');
-        };
-        sortedScripts.sort((a, b) => {
-          const nameA = getSortKey(a);
-          const nameB = getSortKey(b);
-          if (nameA < nameB) return -1;
-          if (nameA > nameB) return 1;
-          return 0;
-        });
-      } else if (sort.value === SORT_UPDATE.value) {
-        const getSortKey = item => +item.props.lastUpdated || 0;
-        sortedScripts.sort((a, b) => getSortKey(b) - getSortKey(a));
-      }
-      this.sortedScripts = sortedScripts;
+      const scripts = [...this.showRecycle ? this.trash : this.scripts];
+      if (this.search) this.performSearch(scripts);
+      const cmp = this.currentSortCompare;
+      if (cmp) scripts.sort(combinedCompare(cmp));
+      this.sortedScripts = scripts;
       this.debouncedRender();
     },
     updateLater() {
@@ -378,6 +379,33 @@ export default {
         if (step) await makePause();
       }
     },
+    performSearch(scripts) {
+      let searchRE;
+      const [,
+        expr = this.search.replace(/[.+^*$?|\\()[\]{}]/g, '\\$&'),
+        flags = 'i',
+      ] = this.search.match(/^\/(.+?)\/(\w*)$|$/);
+      try {
+        searchRE = expr && new RegExp(expr, flags);
+        scripts.forEach(({ $cache }) => {
+          $cache.show = !expr || searchRE.test($cache.search) || searchRE.test($cache.code);
+        });
+        this.searchError = null;
+      } catch (err) {
+        this.searchError = err.message;
+      }
+    },
+    async scheduleSearch() {
+      const { scripts } = this.store;
+      if (scripts[0]?.$cache.code == null) {
+        const ids = scripts.map(({ props: { id } }) => id);
+        const data = await storage.code.getMulti(ids);
+        ids.forEach((id, index) => {
+          scripts[index].$cache.code = data[id];
+        });
+      }
+      this.debouncedUpdate();
+    },
   },
   created() {
     this.debouncedUpdate = debounce(this.onUpdate, 100);
@@ -457,6 +485,9 @@ export default {
     padding-left: .5rem;
     padding-right: 2rem;
     height: 2rem;
+    &[title] {
+      outline: 1px solid red;
+    }
   }
 }
 .filter-sort {