Răsfoiți Sursa

fix #2137: allow to sort by last run date

tophf 3 săptămâni în urmă
părinte
comite
2a7e37e241

+ 9 - 0
src/_locales/en/messages.yml

@@ -268,6 +268,12 @@ filterExecutionOrder:
 filterLastUpdateOrder:
   description: Label for option to sort scripts by last update time.
   message: last update time
+filterLastVisitOrder:
+  description: Label for option to sort scripts by the last time the user visited any site targeted by this script.
+  message: last visit time
+filterLastVisitOrderTooltip:
+  description: Tooltip for option to sort scripts.
+  message: The last time you visited any site targeted by this script.
 filterScopeAll:
   description: Option in dashboard's search scope filter.
   message: All
@@ -553,6 +559,9 @@ labelSettings:
 labelShowOrder:
   description: Label for option in dashboard -> script list
   message: Show execution order positions
+labelShowVisited:
+  description: Label for option in dashboard -> script list
+  message: Show last visit time
 labelSync:
   description: Label for sync options.
   message: Sync

+ 13 - 30
src/background/utils/db.js

@@ -7,31 +7,30 @@ import {
 import { FETCH_OPTS, INFERRED, TIMEOUT_24HOURS, TIMEOUT_WEEK, TL_AWAIT } from '@/common/consts';
 import { deepSize, forEachEntry, forEachKey, forEachValue } from '@/common/object';
 import pluginEvents from '../plugin/events';
-import { getDefaultCustom, getNameURI, inferScriptProps, newScript, parseMeta } from './script';
+import {
+  aliveScripts, getDefaultCustom, getNameURI, inferScriptProps, newScript, parseMeta,
+  removedScripts, scriptMap,
+} from './script';
 import { testBlacklist, testerBatch, testScript } from './tester';
 import { getImageData } from './icon';
 import { addOwnCommands, addPublicCommands, commands, resolveInit } from './init';
 import patchDB from './patch-db';
-import { initOptions, kOptions, kVersion, setOption } from './options';
+import { initOptions, kVersion, setOption } from './options';
 import storage, {
   S_CACHE, S_CODE, S_REQUIRE, S_SCRIPT, S_VALUE,
   S_CACHE_PRE, S_CODE_PRE, S_MOD_PRE, S_REQUIRE_PRE, S_SCRIPT_PRE, S_VALUE_PRE,
   getStorageKeys,
 } from './storage';
-import { dbKeys, storageCacheHas } from './storage-cache';
+import { storageCacheHas } from './storage-cache';
 import { reloadTabForScript } from './tabs';
 import { vetUrl } from './url';
 
 let maxScriptId = 0;
 let maxScriptPosition = 0;
+/** @type {Map<string,number>} */
+export let dbKeys = new Map(); // 1: exists, 0: known to be absent
 /** @type {{ [url:string]: number }} */
 export let scriptSizes = {};
-/** @type {{ [id: string]: VMScript }} */
-const scriptMap = {};
-/** @type {VMScript[]} */
-const aliveScripts = [];
-/** @type {VMScript[]} */
-const removedScripts = [];
 /** Ensuring slow icons don't prevent installation/update */
 const ICON_TIMEOUT = 1000;
 export const kTryVacuuming = 'Try vacuuming database in options.';
@@ -121,11 +120,10 @@ addOwnCommands({
   let allKeys, keys;
   if (getStorageKeys) {
     allKeys = await getStorageKeys();
-    keys = allKeys.filter(key => {
-      dbKeys.set(key, 1);
-      return key.startsWith(S_SCRIPT_PRE);
-    });
-    keys.push(kOptions);
+    // Filtering and creating Map in atomic native code operations instead of js loop
+    keys = allKeys.join('\n').replace(/^(?:(options|version|(?:scr|mod):\d+)|\S+)$/gm, '$1').trim();
+    dbKeys = new Map(JSON.parse(`[${keys.replace(/\S+/g, '["$&",1],').slice(0, -1)}]`));
+    keys = keys.split(/\n+/);
   }
   const lastVersion = (!getStorageKeys || dbKeys.has(kVersion))
     && await storage.base.getOne(kVersion);
@@ -210,22 +208,6 @@ function updateLastModified() {
   setOption('lastModified', Date.now());
 }
 
-export function updateScriptMap(key, val) {
-  const id = +storage[S_SCRIPT].toId(key);
-  if (!id) return;
-  if (val) {
-    const oldScript = scriptMap[id];
-    const i1 = aliveScripts.indexOf(oldScript);
-    const i2 = removedScripts.indexOf(oldScript);
-    if (i1 >= 0) aliveScripts[i1] = val;
-    if (i2 >= 0) removedScripts[i2] = val;
-    scriptMap[id] = val;
-  } else {
-    delete scriptMap[id];
-  }
-  return true;
-}
-
 /** @return {Promise<boolean>} */
 export async function normalizePosition() {
   const updates = aliveScripts.reduce((res, script, index) => {
@@ -885,6 +867,7 @@ export async function vacuum(data) {
       downloadUrls[id] = updUrls[0];
     }
     touch(S_CODE_PRE, id, id);
+    touch(S_MOD_PRE, id, id);
     touch(S_VALUE_PRE, id, id);
     meta.require.forEach(url => touch(S_REQUIRE_PRE, url, id, pathMap));
     meta.resources::forEachValue(url => touch(S_CACHE_PRE, url, id, pathMap));

+ 10 - 2
src/background/utils/preinject.js

@@ -1,5 +1,5 @@
 import {
-  getActiveTab, getScriptName, getScriptPrettyUrl, getUniqId, sendTabCmd
+  getActiveTab, getScriptName, getScriptPrettyUrl, getUniqId, sendTabCmd,
 } from '@/common';
 import {
   __CODE, TL_AWAIT, UNWRAP, XHR_COOKIE_RE,
@@ -17,6 +17,7 @@ import { hookOptionsInit } from './options';
 import { popupTabs } from './popup-tracker';
 import { clearRequestsByTabId, reifyRequests } from './requests';
 import { kSetCookie } from './requests-core';
+import { updateVisitedTime } from './script';
 import {
   S_CACHE, S_CACHE_PRE, S_CODE, S_CODE_PRE, S_REQUIRE, S_REQUIRE_PRE, S_SCRIPT_PRE, S_VALUE,
   S_VALUE_PRE,
@@ -173,6 +174,9 @@ addPublicCommands({
     if (scripts) {
       triageRealms(scripts, bag[FORCE_CONTENT] || forceContent, tabId, frameId, bag);
       addValueOpener(scripts, tabId, frameDoc);
+      if (isTop < 2/* skip prerendered pages*/ && scripts.length) {
+        updateVisitedTime(scripts);
+      }
     }
     if (popupTabs[tabId]) {
       sendPopupShown(tabId, frameDoc);
@@ -201,6 +205,9 @@ addPublicCommands({
     const scripts = prepareScripts(env);
     triageRealms(scripts, forceContent, tabId, frameId);
     addValueOpener(scripts, tabId, getFrameDocId(isTop, src[kDocumentId], frameId));
+    if (isTop < 2/* skip prerendered pages*/ && scripts.length) {
+      updateVisitedTime(scripts);
+    }
     return {
       [SCRIPTS]: scripts,
       [S_CACHE]: envCache,
@@ -216,6 +223,7 @@ addPublicCommands({
     setBadge(ids, reset, src);
     if (isTop === 3) {
       if (hasIds) reifyValueOpener(ids, docId);
+      if (ids.length) updateVisitedTime(ids, true);
       reifyRequests(tabId, docId);
       clearNotifications(tabId);
     }
@@ -576,7 +584,7 @@ function triageRealms(scripts, forceContent, tabId, frameId, bag) {
   let code;
   let wantsPage;
   const toContent = [];
-  for (const scr of scripts) {
+  for (const /**@type{VMInjection.Script}*/ scr of scripts) {
     const metaStr = scr[META_STR];
     if (isContentRealm(scr[INJECT_INTO], forceContent)) {
       if (!metaStr[0]) {

+ 50 - 12
src/background/utils/script.js

@@ -1,5 +1,5 @@
 import {
-  encodeFilename, getFullUrl, getScriptHome, getScriptSupportUrl, i18n, noop,
+  encodeFilename, getFullUrl, getScriptHome, getScriptSupportUrl, i18n, noop, sendCmd,
 } from '@/common';
 import {
   __CODE, HOMEPAGE_URL, INFERRED, METABLOCK_RE, SUPPORT_URL, TL_AWAIT, UNWRAP,
@@ -9,6 +9,7 @@ import { mapEntry } from '@/common/object';
 import defaults, { kScriptTemplate } from '@/common/options-defaults';
 import { addOwnCommands, commands } from './init';
 import { getOption, hookOptionsInit } from './options';
+import storage, { S_MOD_PRE, S_SCRIPT_PRE } from './storage';
 import { injectableRe } from './tabs';
 
 addOwnCommands({
@@ -33,6 +34,14 @@ hookOptionsInit((changes, firstRun) => {
   }
 });
 
+/** @type {{ [id: string]: VMScript }} */
+export const scriptMap = {};
+/** @type {{ [id: string]: number }} */
+export const scriptSiteVisited = {};
+/** @type {VMScript[]} */
+export const aliveScripts = [];
+/** @type {VMScript[]} */
+export const removedScripts = [];
 /** @return {boolean|?RegExpExecArray} */
 export const matchUserScript = text => !/^\s*</.test(text) /*HTML*/ && METABLOCK_RE.exec(text);
 
@@ -86,7 +95,7 @@ export const ERR_META_SPACE_INSIDE = 'Expected a single space after "//" in ';
  * @param {Array} [opts.errors] - to collect errors
  * @param {boolean} [opts.retDefault] - returns the default empty meta if no meta is found
  * @param {boolean} [opts.retMetaStr] - adds the matched part as [__CODE] prop in result
- * @return {VMScript.Meta | false}
+ * @return {VMScript['meta'] | false}
  */
 export function parseMeta(code, { errors, retDefault, retMetaStr } = {}) {
   // initialize meta
@@ -231,17 +240,46 @@ function inferScriptSupportUrl(script, home = getScriptHome(script)) {
   }
 }
 
+/** @param {VMScript} script */
 export function inferScriptProps(script) {
-  if (!script || hasOwnProperty(script, INFERRED)) {
-    return;
-  }
-  let url, res;
-  if (!(url = getScriptHome(script)) && (url = inferScriptHome(script))) {
-    (res || (res = {}))[HOMEPAGE_URL] = url;
+  const data = script[INFERRED] ??= {};
+  const home = data[HOMEPAGE_URL] ??= getScriptHome(script) || inferScriptHome(script);
+  data[SUPPORT_URL] ??= !getScriptSupportUrl(script) && inferScriptSupportUrl(script, home);
+  data.visit = scriptSiteVisited[script.props.id];
+}
+
+/**
+ * @param {VMInjection.Script[] | number[]} arr
+ * @param {boolean} [isIds]
+ */
+export function updateVisitedTime(arr, isIds) {
+  const now = Date.now();
+  const toBroadcast = {};
+  const toWrite = {};
+  for (let v of arr) {
+    if (!isIds) v = v.id;
+    scriptSiteVisited[v] = toBroadcast[v] = toWrite[S_MOD_PRE + v] = now;
   }
-  if (!getScriptSupportUrl(script) && (url = inferScriptSupportUrl(script, url))) {
-    (res || (res = {}))[SUPPORT_URL] = url;
+  sendCmd('Visited', toBroadcast);
+  storage.api.set(toWrite);
+}
+
+/** `key` must be already verified to start with S_SCRIPT_PRE */
+export function updateScriptMap(key, val) {
+  if ((key = +key.slice(S_SCRIPT_PRE.length))) {
+    if (val) {
+      const oldScript = scriptMap[key];
+      if (oldScript) {
+        const i1 = aliveScripts.indexOf(oldScript);
+        const i2 = removedScripts.indexOf(oldScript);
+        if (i1 >= 0) aliveScripts[i1] = val;
+        if (i2 >= 0) removedScripts[i2] = val;
+        val[INFERRED] ??= oldScript[INFERRED];
+      }
+      scriptMap[key] = val;
+    } else {
+      delete scriptMap[key];
+    }
+    return true;
   }
-  script[INFERRED] = res;
-  // Setting the key when failed to `undefined` makes it detectable via hasOwnProperty above
 }

+ 11 - 7
src/background/utils/storage-cache.js

@@ -2,8 +2,9 @@ import { ensureArray, ignoreChromeErrors, initHooks, isEmpty, sendCmd } from '@/
 import initCache from '@/common/cache';
 import { INFERRED, WATCH_STORAGE } from '@/common/consts';
 import { deepCopy, deepCopyDiff, deepSize, forEachEntry } from '@/common/object';
-import { scriptSizes, sizesPrefixRe, updateScriptMap } from './db';
-import storage, { S_SCRIPT_PRE, S_VALUE, S_VALUE_PRE } from './storage';
+import { dbKeys, scriptSizes, sizesPrefixRe } from './db';
+import { scriptSiteVisited, updateScriptMap } from './script';
+import storage, { S_MOD_PRE, S_SCRIPT_PRE, S_VALUE, S_VALUE_PRE } from './storage';
 import { clearValueOpener } from './values';
 
 /** Throttling browser API for `storage.value`, processing requests sequentially,
@@ -23,7 +24,6 @@ const TTL_MAIN = 3600e3;
 /** Keeping tiny info for extended period of time as it's inexpensive. */
 const TTL_TINY = 24 * 3600e3;
 const cache = initCache({ lifetime: TTL_MAIN });
-export const dbKeys = new Map(); // 1: exists, 0: known to be absent
 const api = /** @type {browser.storage.StorageArea} */ storage.api;
 /** Using a simple delay with setTimeout to avoid infinite debouncing due to periodic activity */
 const FLUSH_DELAY = 100;
@@ -54,13 +54,17 @@ export const cachedStorageApi = storage.api = {
       return !ok && dbKeys.get(key) !== 0;
     });
     if (!keys || keys.length) {
-      let lifetime;
+      let lifetime, id;
       if (!keys) lifetime = TTL_SKIM; // DANGER! Must be `undefined` otherwise.
       (await api.get(keys))::forEachEntry(([key, val]) => {
         res[key] = val;
         dbKeys.set(key, 1);
         cache.put(key, deepCopy(val), lifetime);
-        updateScriptMap(key, val);
+        if (key.startsWith(S_SCRIPT_PRE)) {
+          updateScriptMap(key, val);
+        } else if (key.startsWith(S_MOD_PRE) && (id = +key.slice(S_MOD_PRE.length))) {
+          scriptSiteVisited[id] = val;
+        }
       });
       keys?.forEach(key => dbKeys.set(key, +hasOwnProperty(res, key)));
     }
@@ -86,7 +90,7 @@ export const cachedStorageApi = storage.api = {
           valuesToFlush[key] = copy;
         } else {
           toWrite[key] = val;
-          if (updateScriptMap(key, val) && val[INFERRED]) {
+          if (key.startsWith(S_SCRIPT_PRE) && updateScriptMap(key, val) && val[INFERRED]) {
             delete (toWrite[key] = { ...val })[INFERRED];
           }
           updateScriptSizeContributor(key, val);
@@ -111,7 +115,7 @@ export const cachedStorageApi = storage.api = {
           valuesToFlush[key] = null;
           ok = false;
         } else {
-          updateScriptMap(key);
+          if (key.startsWith(S_SCRIPT_PRE)) updateScriptMap(key);
           updateScriptSizeContributor(key);
         }
       }

+ 3 - 3
src/common/options-defaults.js

@@ -60,11 +60,11 @@ export default {
   ffInject: true,
   xhrInject: false,
   filters: {
-    /** @type {'name' | 'code' | 'all'} */
-    searchScope: 'name',
     /** @type {boolean} */
     showOrder: false,
-    /** @type {'exec'|'exec-' | 'alpha'|'alpha-' | 'update'|'update-'} */
+    /** @type {boolean} */
+    showVisit: false,
+    /** @type {'exec'|'exec-' | 'alpha'|'alpha-' | 'update'|'update-' | 'visit'|'visit-'} */
     sort: 'exec',
     /** @type {boolean} */
     viewSingleColumn: false,

+ 1 - 0
src/options/utils/index.js

@@ -15,6 +15,7 @@ export const store = reactive({
   loaded: false,
   /** Whether removed scripts need to be filtered from `store.scripts`. */
   needRefresh: false,
+  now: Date.now(),
   sync: [],
   title: null,
 });

+ 42 - 3
src/options/views/script-item.vue

@@ -64,6 +64,10 @@
           <span class="ellipsis" v-else v-text="author.name" />
         </tooltip>
         <span class="version ellipsis" v-text="script.meta.version"/>
+        <tooltip class="visit hidden-sm ml-1c" :content="visit.title" align="end"
+                 v-if="showVisit">
+          {{ visit.show }}
+        </tooltip>
         <tooltip class="size hidden-sm" :content="script.$cache.sizes" align="end" v-if="!isRemoved">
           {{ script.$cache.size }}
         </tooltip>
@@ -146,12 +150,17 @@
 
 <script>
 import { formatTime, getLocaleString, getScriptHome, getScriptSupportUrl, i18n } from '@/common';
+import { INFERRED } from '@/common/consts';
 import { EXTERNAL_LINK_PROPS, getActiveElement, showConfirmation } from '@/common/ui';
 import { isInput, keyboardService, toggleTip } from '@/common/keyboard';
 import { kDescription, store, TOGGLE_OFF, TOGGLE_ON } from '../utils';
 
 const itemMargin = 8;
+const visitedRecently = new Set();
+const refreshVisited = () => { store.now = Date.now(); };
+const scheduleRefreshVisited = () => setInterval(refreshVisited, 60e3);
 const setScriptFocus = val => keyboardService.setContext('scriptFocus', val);
+let visitedRecentlyInterval;
 </script>
 
 <script setup>
@@ -167,6 +176,7 @@ const props = defineProps([
   'focused',
   'hotkeys',
   'showHotkeys',
+  'showVisit',
   'activeTags',
 ]);
 const emit = defineEmits([
@@ -209,12 +219,20 @@ const updatedAt = computed(() => {
   const lastModified = !isRemoved.value && scrProps.lastUpdated || scrProps.lastModified;
   const dateStr = lastModified && new Date(lastModified).toLocaleString();
   return lastModified ? {
-    show: formatTime(Date.now() - lastModified),
+    show: formatDynamicTime(scrProps, lastModified),
     title: isRemoved.value
       ? i18n('labelRemovedAt', dateStr)
       : i18n('labelLastUpdatedAt', dateStr)
   } : {};
 });
+const visit = computed(() => {
+  const { script } = props;
+  const time = script[INFERRED].visit;
+  return time && props.showVisit ? {
+    show: formatDynamicTime(script.props, time),
+    title: new Date(time).toLocaleString() + '. ' + i18n('filterLastVisitOrderTooltip'),
+  } : {};
+});
 const url = computed(() => `#${
   isRemoved.value ? TAB_RECYCLE : SCRIPTS}/${props.script.props.id}
 `);
@@ -234,6 +252,24 @@ const onUpdate = async () => {
     emitScript('update');
   }
 };
+/**
+ * @param {VMScript['props']} scriptProps
+ * @param {number} time
+ * @return {string}
+ */
+function formatDynamicTime({ id }, time) {
+  time = store.now - time;
+  if (time < 24 * 3600e3) {
+    visitedRecently.add(id);
+    visitedRecentlyInterval ??= scheduleRefreshVisited();
+  } else {
+    visitedRecently.delete(id);
+    if (visitedRecentlyInterval && !visitedRecently.size) {
+      visitedRecentlyInterval = clearInterval(refreshVisited);
+    }
+  }
+  return formatTime(time);
+}
 
 watch(() => props.visible, visible => {
   // Leave it if the element is already rendered
@@ -456,6 +492,9 @@ $removedItemHeight: calc(
   &-message {
     white-space: nowrap;
   }
+  .visit {
+    color: var(--fill-8);
+  }
 }
 
 .hotkeys [data-hotkey] {
@@ -569,11 +608,11 @@ $removedItemHeight: calc(
           width: 3em;
           text-align: right;
         }
-        .updated, .version {
+        .updated, .version, .visit {
           text-align: right;
           color: var(--fill-8);
         }
-        .updated {
+        .updated, .visit {
           width: 3em;
         }
         .version:not(:empty)::before {

+ 49 - 20
src/options/views/tab-installed.vue

@@ -56,8 +56,9 @@
         <span class="ml-1">{{ i18n('sortOrder') }}
           <select :value="filters.sort" @change="handleOrderChange" class="h-100">
             <option
-              v-for="(option, name) in sortModes"
-              v-text="option.title"
+              v-for="({text, title}, name) in sortModes"
+              v-text="text"
+              :title
               :key="name"
               :value="name">
             </option>
@@ -77,6 +78,9 @@
             <div>
               <SettingCheck name="filters.showOrder" :label="i18n('labelShowOrder')" />
             </div>
+            <div>
+              <SettingCheck name="filters.showVisit" :label="i18n('labelShowVisited')" />
+            </div>
             <div class="mr-2c">
               <SettingCheck name="filters.viewTable" :label="i18n('labelViewTable')" />
               <SettingCheck name="filters.viewSingleColumn" :label="i18n('labelViewSingleColumn')" />
@@ -129,6 +133,7 @@
           :key="script.props.id"
           :focused="selectedScript === script"
           :showHotkeys="state.showHotkeys"
+          :showVisit="filters.showVisit || filters.sort.startsWith('visit')"
           :script
           :draggable
           :visible="index < state.batchRender.limit"
@@ -162,6 +167,8 @@
 <script setup>
 import { computed, reactive, nextTick, onMounted, watch, ref, onBeforeUnmount } from 'vue';
 import { i18n, sendCmdDirectly, debounce, ensureArray, makePause, trueJoin } from '@/common';
+import { INFERRED } from '@/common/consts';
+import handlers from '@/common/handlers';
 import options from '@/common/options';
 import { EXTERNAL_LINK_PROPS, getActiveElement, isTouch, showConfirmation, showMessage, vFocus } from '@/common/ui';
 import hookSetting from '@/common/hook-setting';
@@ -201,16 +208,19 @@ const UPDATE = 'update';
 /** @type {{ [key:string]: SortMode }} */
 const sortModes = [
   ['exec', i18n('filterExecutionOrder')],
-  ['alpha', i18n('filterAlphabeticalOrder'),
+  ['alpha', i18n('filterAlphabeticalOrder'), '',
     ({ $cache: { lowerName: a } }, { $cache: { lowerName: b } }) => (a < b ? -1 : a > b)],
-  [UPDATE, i18n('filterLastUpdateOrder'),
+  [UPDATE, i18n('filterLastUpdateOrder'), '',
     (a, b) => (+b.props.lastUpdated || 0) - (+a.props.lastUpdated || 0)],
-  ['size', i18n('filterSize'),
+  ['visit', i18n('filterLastVisitOrder'), i18n('filterLastVisitOrderTooltip'),
+    (a, b) => (b[INFERRED].visit || 0) - (a[INFERRED].visit || 0)],
+  ['size', i18n('filterSize'), '',
     (a, b) => a.$cache.sizeNum - b.$cache.sizeNum],
-].reduce((res, [key, title, compare]) => (
-  (res[key] = {title, compare}),
+].reduce((res, [key, text, title, compare]) => (
+  (res[key] = {text, title, compare}),
   (res[key + '-'] = /**@namespace SortMode*/{
-    title: title + ' ⯆',
+    text: text + ' ⯆',
+    title: title,
     compare: compare ? (a, b) => compare(b, a) :
       /** @param {VMScript} a
        * @param {VMScript} b */
@@ -219,12 +229,12 @@ const sortModes = [
   res
 ), {});
 const filters = reactive({
-  searchScope: null,
-  showEnabledFirst: null,
-  showOrder: null,
-  viewSingleColumn: null,
-  viewTable: null,
-  sort: null,
+  /** @type {Boolean} */ showEnabledFirst: null,
+  /** @type {Boolean} */ showOrder: null,
+  /** @type {Boolean} */ showVisit: null,
+  /** @type {Boolean} */ viewSingleColumn: null,
+  /** @type {Boolean} */ viewTable: null,
+  sort: '',
 });
 const combinedCompare = cmpFunc => (
   filters.showEnabledFirst
@@ -233,8 +243,9 @@ const combinedCompare = cmpFunc => (
 );
 filters::forEachKey(key => {
   hookSetting(`filters.${key}`, (val) => {
-    filters[key] = val;
-    if (key === 'sort' && !sortModes[val]) filters[key] = Object.keys(sortModes)[0];
+    filters[key] = key === 'sort' && !sortModes[val]
+      ? Object.keys(sortModes)[0]
+      : val;
   });
 });
 
@@ -295,7 +306,7 @@ const state = reactive({
 });
 
 const showRecycle = computed(() => store.route.paths[0] === TAB_RECYCLE);
-const draggableRaw = computed(() => !showRecycle.value && /^exec/.test(filters.sort));
+const draggableRaw = computed(() => !showRecycle.value && filters.sort.startsWith('exec'));
 const draggable = computed(() => isTouch && draggableRaw.value);
 const currentSortCompare = computed(() => sortModes[filters.sort]?.compare);
 const selectedScript = computed(() => state.filteredScripts[state.focusedIndex]);
@@ -372,13 +383,16 @@ async function refreshUI() {
   onUpdate();
   onHashChange();
 }
+function sortScripts(scripts) {
+  const cmp = currentSortCompare.value;
+  if (cmp) scripts.sort(combinedCompare(cmp));
+  state.sortedScripts = scripts;
+}
 function onUpdate() {
   const scripts = [...getCurrentList()];
   const rules = state.search.rules;
   const numFound = rules.length ? performSearch(scripts, rules) : scripts.length;
-  const cmp = currentSortCompare.value;
-  if (cmp) scripts.sort(combinedCompare(cmp));
-  state.sortedScripts = scripts;
+  sortScripts(scripts);
   state.filteredScripts = rules.length ? scripts.filter(({ $cache }) => $cache.show) : scripts;
   selectScript(state.focusedIndex);
   if (!step || numFound < step) renderScripts();
@@ -765,6 +779,21 @@ watch(() => state.showHotkeys, value => {
 
 const disposables = [];
 
+Object.assign(handlers, {
+  Visited(data) {
+    let dirty;
+    for (const list of [store.scripts, store.removedScripts]) {
+      for (const /** @type {VMScript} */ scr of list) {
+        const val = data[scr.props.id];
+        if (val) dirty = scr[INFERRED].visit = val;
+      }
+    }
+    if (dirty && filters.sort.startsWith('visit')) {
+      sortScripts([...getCurrentList()]);
+    }
+  },
+});
+
 onMounted(() => {
   // Ensure the correct UI is shown when mounted:
   // * on subsequent navigation via history back/forward;

+ 20 - 26
src/types.d.ts

@@ -7,6 +7,10 @@ declare type NumBoolNull = 0 | 1 | null
 declare type StringMap = { [key: string]: string }
 declare type PlainJSONValue = browser.extensionTypes.PlainJSONValue;
 
+type DeepPartial<T> = {
+  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
+};
+
 //#endregion Generic
 //#region GM-specific
 
@@ -153,26 +157,14 @@ declare type VMBadgeData = {
  * Internal script representation
  */
 declare interface VMScript {
-  config: VMScript.Config;
-  custom: VMScript.Custom;
-  meta: VMScript.Meta;
-  props: VMScript.Props;
-  /** Automatically inferred from other props in getData, in-memory only and not in storage */
-  inferred?: {
-    homepageURL?: string;
-    supportURL?: string;
-  },
-}
-
-declare namespace VMScript {
-  type Config = {
+  config: {
     enabled: NumBool;
     removed: NumBool;
     /** 2 = allow updates and local edits */
     shouldUpdate: NumBool | 2;
     notifyUpdates?: NumBoolNull;
-  }
-  type Custom = {
+  };
+  custom: {
     name?: string;
     /** Installation web page that will be used for inferring a missing @homepageURL */
     from?: string;
@@ -194,8 +186,8 @@ declare namespace VMScript {
     pathMap?: StringMap;
     runAt?: VMScriptRunAt;
     tags?: string;
-  }
-  type Meta = {
+  };
+  meta: {
     description?: string;
     downloadURL?: string;
     exclude: string[];
@@ -216,23 +208,25 @@ declare namespace VMScript {
     topLevelAwait?: boolean;
     unwrap?: boolean;
     version?: string;
-  }
-  type Props = {
+  };
+  props: {
     id: number;
     lastModified: number;
     lastUpdated: number;
     position: number;
     uri: string;
     uuid: string;
-  }
+  };
+  /** Automatically inferred from other props in getData, in-memory only and not in storage */
+  inferred?: {
+    homepageURL?: string;
+    supportURL?: string;
+    visit: number;
+  },
 }
 
-declare interface VMScriptSourceOptions {
+declare interface VMScriptSourceOptions extends DeepPartial<Omit<VMScript, 'inferred'>> {
   code?: string;
-  config?: VMScript.Config;
-  custom?: VMScript.Custom;
-  meta?: VMScript.Meta;
-  props?: VMScript.Props;
 
   id?: number;
   isNew?: boolean;
@@ -344,7 +338,7 @@ declare namespace VMInjection {
     injectInto: VMScriptInjectInto;
     key: { data: string, win: string };
     /** `resources` is still an object, converted later in makeGmApiWrapper */
-    meta: VMScript.Meta | VMScriptGMInfoScriptMeta;
+    meta: VMScript['meta'] | VMScriptGMInfoScriptMeta;
     metaStr: (string|number)[];
     pathMap: StringMap;
     runAt?: RunAt;