diff --git a/.eslintrc.yml b/.eslintrc.yml index 87af0074..0871bcad 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -1,7 +1,7 @@ # https://github.com/eslint/eslint/blob/master/docs/rules/README.md parserOptions: - ecmaVersion: 2015 + ecmaVersion: 2017 env: browser: true diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 95cf61d4..e5f505f3 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1173,6 +1173,10 @@ "message": "Case-sensitive", "description": "Tooltip for the 'Aa' icon that enables case-sensitive search in the editor shown on Ctrl-F" }, + "searchGlobalStyles": { + "message": "Also search global styles", + "description": "Checkbox label in the popup's inline style search, shown when the text to search is entered" + }, "searchNumberOfResults": { "message": "Number of matches", "description": "Tooltip for the number of found search results in the editor shown on Ctrl-F" @@ -1181,6 +1185,10 @@ "message": "Number of matches in code and applies-to values", "description": "Tooltip for the number of found search results in the editor shown on Ctrl-F" }, + "searchStyleQueryHint": { + "message": "Search style names case-insensitively:\nsome words - all words in any order\n\"some phrase\" - this exact phrase without quotes\n2020 - a year like this also shows styles updated in 2020", + "description": "Tooltip shown for the text input in the popup's inline style finder" + }, "searchRegexp": { "message": "Use /re/ syntax for regexp search", "description": "Label after the search input field in the editor shown on Ctrl-F" diff --git a/background/background-worker.js b/background/background-worker.js index f44013e5..ddb33d53 100644 --- a/background/background-worker.js +++ b/background/background-worker.js @@ -12,7 +12,6 @@ createAPI({ compileUsercss, parseUsercssMeta(text, indexOffset = 0) { loadScript( - '/js/polyfill.js', '/vendor/usercss-meta/usercss-meta.min.js', '/vendor-overwrites/colorpicker/colorconverter.js', '/js/meta-parser.js' @@ -21,7 +20,6 @@ createAPI({ }, nullifyInvalidVars(vars) { loadScript( - '/js/polyfill.js', '/vendor/usercss-meta/usercss-meta.min.js', '/vendor-overwrites/colorpicker/colorconverter.js', '/js/meta-parser.js' diff --git a/background/background.js b/background/background.js index 6851006e..d03250b6 100644 --- a/background/background.js +++ b/background/background.js @@ -1,8 +1,8 @@ /* global download prefs openURL FIREFOX CHROME - URLS ignoreChromeError usercssHelper + URLS ignoreChromeError chromeLocal semverCompare styleManager msg navigatorUtil workerUtil contentScripts sync findExistingTab activateTab isTabReplaceable getActiveTab - tabManager */ +*/ 'use strict'; @@ -111,14 +111,6 @@ navigatorUtil.onUrlChange(({tabId, frameId}, type) => { } }); -tabManager.onUpdate(({tabId, url, oldUrl = ''}) => { - if (usercssHelper.testUrl(url) && !oldUrl.startsWith(URLS.installUsercss)) { - usercssHelper.testContents(tabId, url).then(data => { - if (data.code) usercssHelper.openInstallerPage(tabId, url, data); - }); - } -}); - if (FIREFOX) { // FF misses some about:blank iframes so we inject our content script explicitly navigatorUtil.onDOMContentLoaded(webNavIframeHelperFF, { @@ -139,7 +131,7 @@ if (chrome.commands) { } // ************************************************************************* -chrome.runtime.onInstalled.addListener(({reason}) => { +chrome.runtime.onInstalled.addListener(({reason, previousVersion}) => { // save install type: "admin", "development", "normal", "sideload" or "other" // "normal" = addon installed from webstore chrome.management.getSelf(info => { @@ -156,6 +148,14 @@ chrome.runtime.onInstalled.addListener(({reason}) => { }); // themes may change delete localStorage.codeMirrorThemes; + // inline search cache for USO is not needed anymore, TODO: remove this by the middle of 2021 + if (semverCompare(previousVersion, '1.5.13') <= 0) { + setTimeout(async () => { + const del = Object.keys(await chromeLocal.get()) + .filter(key => key.startsWith('usoSearchCache')); + if (del.length) chromeLocal.remove(del); + }, 15e3); + } }); // ************************************************************************* diff --git a/background/db.js b/background/db.js index 2549a3ce..223d3870 100644 --- a/background/db.js +++ b/background/db.js @@ -1,4 +1,4 @@ -/* global chromeLocal ignoreChromeError workerUtil createChromeStorageDB */ +/* global chromeLocal workerUtil createChromeStorageDB */ /* exported db */ /* Initialize a database. There are some problems using IndexedDB in Firefox: @@ -10,29 +10,18 @@ https://www.reddit.com/r/firefox/comments/7ijuaq/firefox_59_webextensions_can_us 'use strict'; const db = (() => { - let exec; - const preparing = prepare(); - return { - exec: (...args) => - preparing.then(() => exec(...args)) + const DATABASE = 'stylish'; + const STORE = 'styles'; + const FALLBACK = 'dbInChromeStorage'; + const dbApi = { + async exec(...args) { + dbApi.exec = await tryUsingIndexedDB().catch(useChromeStorage); + return dbApi.exec(...args); + }, }; + return dbApi; - function prepare() { - return withPromise(shouldUseIndexedDB).then( - ok => { - if (ok) { - useIndexedDB(); - } else { - useChromeStorage(); - } - }, - err => { - useChromeStorage(err); - } - ); - } - - function shouldUseIndexedDB() { + async function tryUsingIndexedDB() { // we use chrome.storage.local fallback if IndexedDB doesn't save data, // which, once detected on the first run, is remembered in chrome.storage.local // for reliablility and in localStorage for fast synchronous access @@ -42,115 +31,81 @@ const db = (() => { if (typeof indexedDB === 'undefined') { throw new Error('indexedDB is undefined'); } - // test localStorage - const fallbackSet = localStorage.dbInChromeStorage; - if (fallbackSet === 'true') { - return false; + switch (await getFallback()) { + case true: throw null; + case false: break; + default: await testDB(); } - if (fallbackSet === 'false') { - return true; - } - // test storage.local - return chromeLocal.get('dbInChromeStorage') - .then(data => { - if (data && data.dbInChromeStorage) { - return false; - } - return testDBSize() - .then(ok => ok || testDBMutation()); - }); + return useIndexedDB(); } - function withPromise(fn) { - try { - return Promise.resolve(fn()); - } catch (err) { - return Promise.reject(err); - } + async function getFallback() { + return localStorage[FALLBACK] === 'true' ? true : + localStorage[FALLBACK] === 'false' ? false : + chromeLocal.getValue(FALLBACK); } - function testDBSize() { - return dbExecIndexedDB('getAllKeys', IDBKeyRange.lowerBound(1), 1) - .then(event => ( - event.target.result && - event.target.result.length && - event.target.result[0] - )); - } - - function testDBMutation() { - return dbExecIndexedDB('put', {id: -1}) - .then(() => dbExecIndexedDB('get', -1)) - .then(event => { - if (!event.target.result) { - throw new Error('failed to get previously put item'); - } - if (event.target.result.id !== -1) { - throw new Error('item id is wrong'); - } - return dbExecIndexedDB('delete', -1); - }) - .then(() => true); + async function testDB() { + let e = await dbExecIndexedDB('getAllKeys', IDBKeyRange.lowerBound(1), 1); + // throws if result is null + e = e.target.result[0]; + const id = `${performance.now()}.${Math.random()}.${Date.now()}`; + await dbExecIndexedDB('put', {id}); + e = await dbExecIndexedDB('get', id); + // throws if result or id is null + await dbExecIndexedDB('delete', e.target.result.id); } function useChromeStorage(err) { - exec = createChromeStorageDB().exec; - chromeLocal.set({dbInChromeStorage: true}, ignoreChromeError); + chromeLocal.setValue(FALLBACK, true); if (err) { - chromeLocal.setValue('dbInChromeStorageReason', workerUtil.cloneError(err)); + chromeLocal.setValue(FALLBACK + 'Reason', workerUtil.cloneError(err)); console.warn('Failed to access indexedDB. Switched to storage API.', err); } - localStorage.dbInChromeStorage = 'true'; + localStorage[FALLBACK] = 'true'; + return createChromeStorageDB().exec; } function useIndexedDB() { - exec = dbExecIndexedDB; - chromeLocal.set({dbInChromeStorage: false}, ignoreChromeError); - localStorage.dbInChromeStorage = 'false'; + chromeLocal.setValue(FALLBACK, false); + localStorage[FALLBACK] = 'false'; + return dbExecIndexedDB; } - function dbExecIndexedDB(method, ...args) { - return open().then(database => { - if (!method) { - return database; - } - if (method === 'putMany') { - return putMany(database, ...args); - } - const mode = method.startsWith('get') ? 'readonly' : 'readwrite'; - const transaction = database.transaction(['styles'], mode); - const store = transaction.objectStore('styles'); - return storeRequest(store, method, ...args); + async function dbExecIndexedDB(method, ...args) { + const mode = method.startsWith('get') ? 'readonly' : 'readwrite'; + const store = (await open()).transaction([STORE], mode).objectStore(STORE); + const fn = method === 'putMany' ? putMany : storeRequest; + return fn(store, method, ...args); + } + + function storeRequest(store, method, ...args) { + return new Promise((resolve, reject) => { + const request = store[method](...args); + request.onsuccess = resolve; + request.onerror = reject; }); + } - function storeRequest(store, method, ...args) { - return new Promise((resolve, reject) => { - const request = store[method](...args); - request.onsuccess = resolve; - request.onerror = reject; + function putMany(store, _method, items) { + return Promise.all(items.map(item => storeRequest(store, 'put', item))); + } + + function open() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE, 2); + request.onsuccess = () => resolve(request.result); + request.onerror = reject; + request.onupgradeneeded = create; + }); + } + + function create(event) { + if (event.oldVersion === 0) { + event.target.result.createObjectStore(STORE, { + keyPath: 'id', + autoIncrement: true, }); } - - function open() { - return new Promise((resolve, reject) => { - const request = indexedDB.open('stylish', 2); - request.onsuccess = () => resolve(request.result); - request.onerror = reject; - request.onupgradeneeded = event => { - if (event.oldVersion === 0) { - event.target.result.createObjectStore('styles', { - keyPath: 'id', - autoIncrement: true, - }); - } - }; - }); - } - - function putMany(database, items) { - const transaction = database.transaction(['styles'], 'readwrite'); - const store = transaction.objectStore('styles'); - return Promise.all(items.map(item => storeRequest(store, 'put', item))); - } } })(); diff --git a/background/style-manager.js b/background/style-manager.js index 4ddb6411..4ae2a5c2 100644 --- a/background/style-manager.js +++ b/background/style-manager.js @@ -1,6 +1,6 @@ /* eslint no-eq-null: 0, eqeqeq: [2, "smart"] */ /* global createCache db calcStyleDigest db tryRegExp styleCodeEmpty styleSectionGlobal - getStyleWithNoCode msg sync uuidv4 */ + getStyleWithNoCode msg sync uuidv4 URLS */ /* exported styleManager */ 'use strict'; @@ -226,6 +226,13 @@ const styleManager = (() => { if (!reason) { reason = style ? 'update' : 'install'; } + let url = !data.url && data.updateUrl; + if (url) { + const usoId = URLS.extractUsoArchiveId(url); + url = usoId && `${URLS.usoArchive}?style=${usoId}` || + URLS.extractGreasyForkId(url) && url.match(/^.*?\/\d+/)[0]; + if (url) data.url = data.installationUrl = url; + } // FIXME: update updateDate? what about usercss config? return calcStyleDigest(data) .then(digest => { diff --git a/background/usercss-helper.js b/background/usercss-helper.js index 3f6081f6..00b3a99b 100644 --- a/background/usercss-helper.js +++ b/background/usercss-helper.js @@ -1,15 +1,8 @@ -/* global API_METHODS usercss styleManager deepCopy openURL download URLS */ +/* global API_METHODS usercss styleManager deepCopy */ /* exported usercssHelper */ 'use strict'; const usercssHelper = (() => { - const installCodeCache = {}; - const clearInstallCode = url => delete installCodeCache[url]; - const isResponseText = r => /^text\/(css|plain)(;.*?)?$/i.test(r.headers.get('content-type')); - // in Firefox we have to use a content script to read file:// - const fileLoader = !chrome.app && // not relying on navigator.ua which can be spoofed - (tabId => browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}).then(r => r[0])); - API_METHODS.installUsercss = installUsercss; API_METHODS.editSaveUsercss = editSaveUsercss; API_METHODS.configUsercssVars = configUsercssVars; @@ -17,50 +10,6 @@ const usercssHelper = (() => { API_METHODS.buildUsercss = build; API_METHODS.findUsercss = find; - API_METHODS.getUsercssInstallCode = url => { - // when the installer tab is reloaded after the cache is expired, this will throw intentionally - const {code, timer} = installCodeCache[url]; - clearInstallCode(url); - clearTimeout(timer); - return code; - }; - - return { - - testUrl(url) { - return url.includes('.user.') && - /^(https?|file|ftps?):/.test(url) && - /\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]); - }, - - /** @return {Promise<{ code:string, inTab:boolean } | false>} */ - testContents(tabId, url) { - const isFile = url.startsWith('file:'); - const inTab = isFile && Boolean(fileLoader); - return Promise.resolve(isFile || fetch(url, {method: 'HEAD'}).then(isResponseText)) - .then(ok => ok && (inTab ? fileLoader(tabId) : download(url))) - .then(code => /==userstyle==/i.test(code) && {code, inTab}); - }, - - openInstallerPage(tabId, url, {code, inTab} = {}) { - const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`; - if (inTab) { - browser.tabs.get(tabId).then(tab => - openURL({ - url: `${newUrl}&tabId=${tabId}`, - active: tab.active, - index: tab.index + 1, - openerTabId: tabId, - currentWindow: null, - })); - } else { - const timer = setTimeout(clearInstallCode, 10e3, url); - installCodeCache[url] = {code, timer}; - chrome.tabs.update(tabId, {url: newUrl}); - } - }, - }; - function buildMeta(style) { if (style.usercssData) { return Promise.resolve(style); diff --git a/background/usercss-install-helper.js b/background/usercss-install-helper.js new file mode 100644 index 00000000..b854564a --- /dev/null +++ b/background/usercss-install-helper.js @@ -0,0 +1,82 @@ +/* global API_METHODS openURL download URLS tabManager */ +'use strict'; + +(() => { + const installCodeCache = {}; + const clearInstallCode = url => delete installCodeCache[url]; + const isContentTypeText = type => /^text\/(css|plain)(;.*?)?$/i.test(type); + + // in Firefox we have to use a content script to read file:// + const fileLoader = !chrome.app && ( + async tabId => + (await browser.tabs.executeScript(tabId, {file: '/content/install-hook-usercss.js'}))[0]); + + const urlLoader = + async (tabId, url) => ( + url.startsWith('file:') || + tabManager.get(tabId, isContentTypeText.name) || + isContentTypeText((await fetch(url, {method: 'HEAD'})).headers.get('content-type')) + ) && download(url); + + API_METHODS.getUsercssInstallCode = url => { + // when the installer tab is reloaded after the cache is expired, this will throw intentionally + const {code, timer} = installCodeCache[url]; + clearInstallCode(url); + clearTimeout(timer); + return code; + }; + + // Faster installation on known distribution sites to avoid flicker of css text + chrome.webRequest.onBeforeSendHeaders.addListener(({tabId, url}) => { + openInstallerPage(tabId, url, {}); + // Silently suppressing navigation like it never happened + return {redirectUrl: 'javascript:void 0'}; // eslint-disable-line no-script-url + }, { + urls: [ + URLS.usoArchiveRaw + 'usercss/*.user.css', + '*://greasyfork.org/scripts/*/code/*.user.css', + '*://sleazyfork.org/scripts/*/code/*.user.css', + ], + types: ['main_frame'], + }, ['blocking']); + + // Remember Content-Type to avoid re-fetching of the headers in urlLoader as it can be very slow + chrome.webRequest.onHeadersReceived.addListener(({tabId, responseHeaders}) => { + const h = responseHeaders.find(h => h.name.toLowerCase() === 'content-type'); + tabManager.set(tabId, isContentTypeText.name, h && isContentTypeText(h.value) || undefined); + }, { + urls: '%css,%css?*,%styl,%styl?*'.replace(/%/g, '*://*/*.user.').split(','), + types: ['main_frame'], + }, ['responseHeaders']); + + tabManager.onUpdate(async ({tabId, url, oldUrl = ''}) => { + if (url.includes('.user.') && + /^(https?|file|ftps?):/.test(url) && + /\.user\.(css|styl)$/.test(url.split(/[#?]/, 1)[0]) && + !oldUrl.startsWith(URLS.installUsercss)) { + const inTab = url.startsWith('file:') && Boolean(fileLoader); + const code = await (inTab ? fileLoader : urlLoader)(tabId, url); + if (/==userstyle==/i.test(code)) { + openInstallerPage(tabId, url, {code, inTab}); + } + } + }); + + function openInstallerPage(tabId, url, {code, inTab} = {}) { + const newUrl = `${URLS.installUsercss}?updateUrl=${encodeURIComponent(url)}`; + if (inTab) { + browser.tabs.get(tabId).then(tab => + openURL({ + url: `${newUrl}&tabId=${tabId}`, + active: tab.active, + index: tab.index + 1, + openerTabId: tabId, + currentWindow: null, + })); + } else { + const timer = setTimeout(clearInstallCode, 10e3, url); + installCodeCache[url] = {code, timer}; + chrome.tabs.update(tabId, {url: newUrl}); + } + } +})(); diff --git a/edit/editor-worker.js b/edit/editor-worker.js index 6ef51eef..62ef380c 100644 --- a/edit/editor-worker.js +++ b/edit/editor-worker.js @@ -16,7 +16,6 @@ createAPI({ }, metalint: code => { loadScript( - '/js/polyfill.js', '/vendor/usercss-meta/usercss-meta.min.js', '/vendor-overwrites/colorpicker/colorconverter.js', '/js/meta-parser.js' diff --git a/edit/source-editor.js b/edit/source-editor.js index a9f8bf22..b3292606 100644 --- a/edit/source-editor.js +++ b/edit/source-editor.js @@ -46,7 +46,7 @@ function createSourceEditor({style, onTitleChanged}) { metaCompiler.onUpdated(meta => { style.usercssData = meta; style.name = meta.name; - style.url = meta.homepageURL; + style.url = meta.homepageURL || style.installationUrl; updateMeta(); }); diff --git a/install-usercss/install-usercss.js b/install-usercss/install-usercss.js index a72b8359..f78044a7 100644 --- a/install-usercss/install-usercss.js +++ b/install-usercss/install-usercss.js @@ -318,7 +318,9 @@ let sequence = null; if (tabId < 0) { getData = DirectDownloader(); - sequence = API.getUsercssInstallCode(initialUrl).catch(getData); + sequence = API.getUsercssInstallCode(initialUrl) + .then(code => code || getData()) + .catch(getData); } else { getData = PortDownloader(); sequence = getData({timer: false}); diff --git a/js/dom.js b/js/dom.js index db9d9f0f..4e40bc83 100644 --- a/js/dom.js +++ b/js/dom.js @@ -98,7 +98,11 @@ document.addEventListener('wheel', event => { return; } if (el.tagName === 'SELECT') { - el.selectedIndex = Math.max(0, Math.min(el.length - 1, el.selectedIndex + Math.sign(event.deltaY))); + const old = el.selectedIndex; + el.selectedIndex = Math.max(0, Math.min(el.length - 1, old + Math.sign(event.deltaY))); + if (el.selectedIndex !== old) { + el.dispatchEvent(new Event('change', {bubbles: true})); + } event.preventDefault(); } event.stopImmediatePropagation(); diff --git a/js/messaging.js b/js/messaging.js index e7f0e9da..ec51267c 100644 --- a/js/messaging.js +++ b/js/messaging.js @@ -62,7 +62,19 @@ const URLS = { // TODO: remove when "minimum_chrome_version": "61" or higher chromeProtectsNTP: CHROME >= 61, - userstylesOrgJson: 'https://userstyles.org/styles/chrome/', + uso: 'https://userstyles.org/', + usoJson: 'https://userstyles.org/styles/chrome/', + + usoArchive: 'https://33kk.github.io/uso-archive/', + usoArchiveRaw: 'https://raw.githubusercontent.com/33kk/uso-archive/flomaster/data/', + extractUsoArchiveId: url => + url && + url.startsWith(URLS.usoArchiveRaw) && + parseInt(url.match(/\/(\d+)\.user\.css|$/)[1]), + + extractGreasyForkId: url => + /^https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/(\d+)[^/]*\/code\/[^/]*\.user\.css$/.test(url) && + RegExp.$1, supported: url => ( url.startsWith('http') && (FIREFOX || !url.startsWith(URLS.browserWebStore)) || @@ -438,7 +450,7 @@ function download(url, { function collapseUsoVars(url) { if (queryPos < 0 || url.length < 2000 || - !url.startsWith(URLS.userstylesOrgJson) || + !url.startsWith(URLS.usoJson) || !/^get$/i.test(method)) { return url; } diff --git a/js/polyfill.js b/js/polyfill.js index 3b22c96d..859de6e2 100644 --- a/js/polyfill.js +++ b/js/polyfill.js @@ -3,27 +3,33 @@ // eslint-disable-next-line no-unused-expressions self.INJECTED !== 1 && (() => { - // this part runs in workers, content scripts, our extension pages + //#region for content scripts and our extension pages - if (!Object.entries) { - Object.entries = obj => Object.keys(obj).map(k => [k, obj[k]]); + if (!window.browser || !browser.runtime) { + const createTrap = (base, parent) => { + const target = typeof base === 'function' ? () => {} : {}; + target.isTrap = true; + return new Proxy(target, { + get: (target, prop) => { + if (target[prop]) return target[prop]; + if (base[prop] && (typeof base[prop] === 'object' || typeof base[prop] === 'function')) { + target[prop] = createTrap(base[prop], base); + return target[prop]; + } + return base[prop]; + }, + apply: (target, thisArg, args) => base.apply(parent, args) + }); + }; + window.browser = createTrap(chrome, null); } - if (!Object.values) { - Object.values = obj => Object.keys(obj).map(k => obj[k]); - } - - // don't use self.chrome. It is undefined in Firefox - if (typeof chrome !== 'object') return; - // the rest is for content scripts and our extension pages - - self.browser = polyfillBrowser(); /* Promisifies the specified `chrome` methods into `browser`. The definitions is an object like this: { 'storage.sync': ['get', 'set'], // if deeper than one level, combine the path via `.` windows: ['create', 'update'], // items and sub-objects will only be created if present in `chrome` } */ - self.promisifyChrome = definitions => { + window.promisifyChrome = definitions => { for (const [scopeName, methods] of Object.entries(definitions)) { const path = scopeName.split('.'); const src = path.reduce((obj, p) => obj && obj[p], chrome); @@ -43,90 +49,18 @@ self.INJECTED !== 1 && (() => { }; if (!chrome.tabs) return; - // the rest is for our extension pages - if (typeof document === 'object') { - const ELEMENT_METH = { - append: { - base: [Element, Document, DocumentFragment], - fn: (node, frag) => { - node.appendChild(frag); - } - }, - prepend: { - base: [Element, Document, DocumentFragment], - fn: (node, frag) => { - node.insertBefore(frag, node.firstChild); - } - }, - before: { - base: [Element, CharacterData, DocumentType], - fn: (node, frag) => { - node.parentNode.insertBefore(frag, node); - } - }, - after: { - base: [Element, CharacterData, DocumentType], - fn: (node, frag) => { - node.parentNode.insertBefore(frag, node.nextSibling); - } - } - }; + //#endregion + //#region for our extension pages - for (const [key, {base, fn}] of Object.entries(ELEMENT_METH)) { - for (const cls of base) { - if (cls.prototype[key]) { - continue; - } - cls.prototype[key] = function (...nodes) { - const frag = document.createDocumentFragment(); - for (const node of nodes) { - frag.appendChild(typeof node === 'string' ? document.createTextNode(node) : node); - } - fn(this, frag); - }; - } + for (const storage of ['localStorage', 'sessionStorage']) { + try { + window[storage]._access_check = 1; + delete window[storage]._access_check; + } catch (err) { + Object.defineProperty(window, storage, {value: {}}); } } - try { - if (!localStorage) { - throw new Error('localStorage is null'); - } - localStorage._access_check = 1; - delete localStorage._access_check; - } catch (err) { - Object.defineProperty(self, 'localStorage', {value: {}}); - } - try { - if (!sessionStorage) { - throw new Error('sessionStorage is null'); - } - sessionStorage._access_check = 1; - delete sessionStorage._access_check; - } catch (err) { - Object.defineProperty(self, 'sessionStorage', {value: {}}); - } - function polyfillBrowser() { - if (typeof browser === 'object' && browser.runtime) { - return browser; - } - return createTrap(chrome, null); - - function createTrap(base, parent) { - const target = typeof base === 'function' ? () => {} : {}; - target.isTrap = true; - return new Proxy(target, { - get: (target, prop) => { - if (target[prop]) return target[prop]; - if (base[prop] && (typeof base[prop] === 'object' || typeof base[prop] === 'function')) { - target[prop] = createTrap(base[prop], base); - return target[prop]; - } - return base[prop]; - }, - apply: (target, thisArg, args) => base.apply(parent, args) - }); - } - } + //#endregion })(); diff --git a/manifest.json b/manifest.json index 9d59db44..a3fc105f 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Stylus", "version": "1.5.13", - "minimum_chrome_version": "49", + "minimum_chrome_version": "55", "description": "__MSG_description__", "homepage_url": "https://add0n.com/stylus.html", "manifest_version": 2, @@ -51,6 +51,7 @@ "background/icon-manager.js", "background/background.js", "background/usercss-helper.js", + "background/usercss-install-helper.js", "background/style-via-api.js", "background/search-db.js", "background/update.js", diff --git a/popup.html b/popup.html index 41cd7852..677edb81 100644 --- a/popup.html +++ b/popup.html @@ -120,9 +120,7 @@
- +
@@ -254,6 +252,27 @@ diff --git a/popup/hotkeys.js b/popup/hotkeys.js index fe058ea1..157a34bb 100644 --- a/popup/hotkeys.js +++ b/popup/hotkeys.js @@ -33,7 +33,8 @@ const hotkeys = (() => { } function onKeyDown(event) { - if (event.ctrlKey || event.altKey || event.metaKey || !enabled) { + if (event.ctrlKey || event.altKey || event.metaKey || !enabled || + /^(text|search)$/.test((document.activeElement || {}).type)) { return; } let entry; diff --git a/popup/popup.js b/popup/popup.js index f6aea8c9..fb2b10bf 100644 --- a/popup/popup.js +++ b/popup/popup.js @@ -13,7 +13,8 @@ const handleEvent = {}; const ABOUT_BLANK = 'about:blank'; const ENTRY_ID_PREFIX_RAW = 'style-'; -const ENTRY_ID_PREFIX = '#' + ENTRY_ID_PREFIX_RAW; + +$.entry = styleOrId => $(`#${ENTRY_ID_PREFIX_RAW}${styleOrId.id || styleOrId}`); if (CHROME >= 66 && CHROME <= 69) { // Chrome 66-69 adds a gap, https://crbug.com/821143 document.head.appendChild($create('style', 'html { overflow: overlay }')); @@ -27,7 +28,7 @@ initTabUrls() onDOMready().then(() => initPopup(frames)), ...frames .filter(f => f.url && !f.isDupe) - .map(({url}) => API.getStylesByUrl(url).then(styles => ({styles, url}))), + .map(({url}) => getStyleDataMerged(url).then(styles => ({styles, url}))), ])) .then(([, ...results]) => { if (results[0]) { @@ -53,17 +54,19 @@ if (CHROME_HAS_BORDER_BUG) { } function onRuntimeMessage(msg) { + if (!tabURL) return; + let ready = Promise.resolve(); switch (msg.method) { case 'styleAdded': case 'styleUpdated': if (msg.reason === 'editPreview' || msg.reason === 'editPreviewEnd') return; - handleUpdate(msg); + ready = handleUpdate(msg); break; case 'styleDeleted': handleDelete(msg.style.id); break; } - dispatchEvent(new CustomEvent(msg.method, {detail: msg})); + ready.then(() => dispatchEvent(new CustomEvent(msg.method, {detail: msg}))); } @@ -141,8 +144,7 @@ function initPopup(frames) { } if (!tabURL) { - document.body.classList.add('blocked'); - document.body.insertBefore(template.unavailableInfo, document.body.firstChild); + blockPopup(); return; } @@ -315,24 +317,30 @@ function showStyles(frameResults) { const entries = new Map(); frameResults.forEach(({styles = [], url}, index) => { styles.forEach(style => { - const {id} = style.data; + const {id} = style; if (!entries.has(id)) { style.frameUrl = index === 0 ? '' : url; - entries.set(id, createStyleElement(Object.assign(style.data, style))); + entries.set(id, createStyleElement(style)); } }); }); if (entries.size) { - installed.append(...sortStyles([...entries.values()])); + resortEntries([...entries.values()]); } else { - installed.appendChild(template.noStyles.cloneNode(true)); + installed.appendChild(template.noStyles); } window.dispatchEvent(new Event('showStyles:done')); } +function resortEntries(entries) { + // `entries` is specified only at startup, after that we respect the prefs + if (entries || prefs.get('popup.autoResort')) { + installed.append(...sortStyles(entries || $$('.entry', installed))); + } +} function createStyleElement(style) { - let entry = $(ENTRY_ID_PREFIX + style.id); + let entry = $.entry(style); if (!entry) { entry = template.style.cloneNode(true); entry.setAttribute('style-id', style.id); @@ -469,11 +477,7 @@ Object.assign(handleEvent, { event.stopPropagation(); API .toggleStyle(handleEvent.getClickedStyleId(event), this.checked) - .then(() => { - if (prefs.get('popup.autoResort')) { - installed.append(...sortStyles($$('.entry', installed))); - } - }); + .then(() => resortEntries()); }, toggleExclude(event, type) { @@ -672,38 +676,25 @@ Object.assign(handleEvent, { }); -function handleUpdate({style, reason}) { - if (!tabURL) return; - - fetchStyle() - .then(style => { - if (!style) { - return; - } - if ($(ENTRY_ID_PREFIX + style.id)) { - createStyleElement(style); - return; - } - document.body.classList.remove('blocked'); - $$.remove('.blocked-info, #no-styles'); - createStyleElement(style); - }) - .catch(console.error); - - function fetchStyle() { - if (reason === 'toggle' && $(ENTRY_ID_PREFIX + style.id)) { - return Promise.resolve(style); - } - return API.getStylesByUrl(tabURL, style.id) - .then(([result]) => result && Object.assign(result.data, result)); +async function handleUpdate({style, reason}) { + if (reason !== 'toggle' || !$.entry(style)) { + style = await getStyleDataMerged(tabURL, style.id); + if (!style) return; } + const el = createStyleElement(style); + if (!el.parentNode) { + installed.appendChild(el); + blockPopup(false); + } + resortEntries(); } function handleDelete(id) { - $.remove(ENTRY_ID_PREFIX + id); - if (!$('.entry')) { - installed.appendChild(template.noStyles.cloneNode(true)); + const el = $.entry(id); + if (el) { + el.remove(); + if (!$('.entry')) installed.appendChild(template.noStyles); } } @@ -721,3 +712,21 @@ function waitForTabUrlFF(tab) { ]); }); } + +/* Merges the extra props from API into style data. + * When `id` is specified returns a single object otherwise an array */ +async function getStyleDataMerged(url, id) { + const styles = (await API.getStylesByUrl(url, id)) + .map(r => Object.assign(r.data, r)); + return id ? styles[0] : styles; +} + +function blockPopup(isBlocked = true) { + document.body.classList.toggle('blocked', isBlocked); + if (isBlocked) { + document.body.prepend(template.unavailableInfo); + } else { + template.unavailableInfo.remove(); + template.noStyles.remove(); + } +} diff --git a/popup/search-results.css b/popup/search-results.css index e1c805a6..a7eb69cf 100755 --- a/popup/search-results.css +++ b/popup/search-results.css @@ -54,21 +54,15 @@ body.search-results-shown { background-color: #fff; } -.search-result .lds-spinner { +#search-results .lds-spinner { transform: scale(.5); filter: invert(1) drop-shadow(1px 1px 3px #000); } -.search-result-empty .lds-spinner { - transform: scale(.5); +#search-results .search-result-empty .lds-spinner { filter: opacity(.2); } -.search-result-fadein { - animation: fadein 1s; - animation-fill-mode: both; -} - .search-result-screenshot { height: 140px; width: 100%; @@ -257,6 +251,24 @@ body.search-results-shown { padding-left: 16px; } +#search-params { + display: flex; + position: relative; + margin-top: -.5rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} + +#search-params > * { + margin-top: .5rem; +} + +#search-query { + min-width: 3em; + margin-right: .5em; + flex: 1 1 0; +} + /* spinner: https://github.com/loadingio/css-spinner */ .lds-spinner { -webkit-user-select: none; diff --git a/popup/search-results.js b/popup/search-results.js index d46982ac..590a2337 100755 --- a/popup/search-results.js +++ b/popup/search-results.js @@ -1,105 +1,100 @@ -/* global tabURL handleEvent $ $$ prefs template FIREFOX chromeLocal debounce - $create t API tWordBreak formatDate tryCatch tryJSONparse LZString - promisifyChrome download */ +/* global URLS tabURL handleEvent $ $$ prefs template FIREFOX debounce + $create t API tWordBreak formatDate tryCatch download */ 'use strict'; -window.addEventListener('showStyles:done', function _() { - window.removeEventListener('showStyles:done', _); - - if (!tabURL) { - return; - } - - //region Init - - const BODY_CLASS = 'search-results-shown'; +window.addEventListener('showStyles:done', () => { + if (!tabURL) return; const RESULT_ID_PREFIX = 'search-result-'; - - const BASE_URL = 'https://userstyles.org'; - const JSON_URL = BASE_URL + '/styles/chrome/'; - const API_URL = BASE_URL + '/api/v1/styles/'; - const UPDATE_URL = 'https://update.userstyles.org/%.md5'; - + const INDEX_URL = URLS.usoArchiveRaw + 'search-index.json'; const STYLUS_CATEGORY = 'chrome-extension'; - - const DISPLAY_PER_PAGE = 10; - // Millisecs to wait before fetching next batch of search results. - const DELAY_AFTER_FETCHING_STYLES = 0; - // Millisecs to wait before fetching .JSON for next search result. - const DELAY_BEFORE_SEARCHING_STYLES = 0; - - // update USO style install counter - // if the style isn't uninstalled in the popup - const PINGBACK_DELAY = 60e3; - - const BLANK_PIXEL_DATA = 'data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAA' + - 'C1HAwCAAAAC0lEQVR42mOcXQ8AAbsBHLLDr5MAAAAASUVORK5CYII='; - - const CACHE_SIZE = 1e6; - const CACHE_PREFIX = 'usoSearchCache/'; - const CACHE_DURATION = 24 * 3600e3; - const CACHE_CLEANUP_THROTTLE = 10e3; - const CACHE_CLEANUP_NEEDED = CACHE_PREFIX + 'clean?'; - const CACHE_EXCEPT_PROPS = ['css', 'discussions', 'additional_info']; - - let searchTotalPages; - let searchCurrentPage = 1; - let searchExhausted = 0; // 1: once, 2: twice (first host.jp, then host) - - // currently active USO requests - const xhrSpoofIds = new Set(); - // used as an HTTP header name to identify spoofed requests - const xhrSpoofTelltale = getRandomId(); - - const processedResults = []; - const unprocessedResults = []; - - let loading = false; - // Category for the active tab's URL. - let category; + const PAGE_LENGTH = 10; + // update USO style install counter if the style isn't uninstalled immediately + const PINGBACK_DELAY = 5e3; + const BUSY_DELAY = .5e3; + const USO_AUTO_PIC_SUFFIX = '-after.png'; + const BLANK_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; + const dom = {}; + /** + * @typedef IndexEntry + * @prop {'uso' | 'uso-android'} f - format + * @prop {Number} i - id + * @prop {string} n - name + * @prop {string} c - category + * @prop {Number} u - updatedTime + * @prop {Number} t - totalInstalls + * @prop {Number} w - weeklyInstalls + * @prop {Number} r - rating + * @prop {Number} ai - authorId + * @prop {string} an - authorName + * @prop {string} sn - screenshotName + * @prop {boolean} sa - screenshotArchived + */ + /** @type IndexEntry[] */ + let results; + /** @type IndexEntry[] */ + let index; + let category = ''; + let searchGlobals = $('#search-globals').checked; + /** @type string[] */ + let query = []; + /** @type 'n' | 'u' | 't' | 'w' | 'r' */ + let order = 't'; let scrollToFirstResult = true; - let displayedPage = 1; let totalPages = 1; - let totalResults = 0; + let ready; - // fade-in when the entry took that long to replace its placeholder - const FADEIN_THRESHOLD = 50; + calcCategory(); - const dom = {}; + const $class = sel => (sel instanceof Node ? sel : $(sel)).classList; + const show = sel => $class(sel).remove('hidden'); + const hide = sel => $class(sel).add('hidden'); Object.assign($('#find-styles-link'), { - href: BASE_URL + '/styles/browse/' + getCategory(), + href: URLS.usoArchive, onclick(event) { if (!prefs.get('popup.findStylesInline') || dom.container) { + this.search = `${new URLSearchParams({category, search: $('#search-query').value})}`; handleEvent.openURLandHide.call(this, event); return; } event.preventDefault(); - this.textContent = this.title; this.title = ''; - init(); - load(); + ready = start(); }, }); return; function init() { - promisifyChrome({ - 'storage.local': ['getBytesInUse'], // FF doesn't implement it - }); - setTimeout(() => document.body.classList.add(BODY_CLASS)); - - $('#find-styles-inline-group').classList.add('hidden'); - + setTimeout(() => document.body.classList.add('search-results-shown')); + hide('#find-styles-inline-group'); + $('#search-globals').onchange = function () { + searchGlobals = this.checked; + ready = ready.then(start); + }; + $('#search-query').oninput = function () { + query = []; + const text = this.value.trim().toLocaleLowerCase(); + const thisYear = new Date().getFullYear(); + for (let re = /"(.+?)"|(\S+)/g, m; (m = re.exec(text));) { + const n = Number(m[2]); + query.push(n >= 2000 && n <= thisYear ? n : m[1] || m[2]); + } + ready = ready.then(start); + }; + $('#search-order').value = order; + $('#search-order').onchange = function () { + order = this.value; + results.sort(comparator); + render(); + }; + dom.list = $('#search-results-list'); dom.container = $('#search-results'); dom.container.dataset.empty = ''; - dom.error = $('#search-results-error'); - dom.nav = {}; const navOnClick = {prev, next}; for (const place of ['top', 'bottom']) { @@ -113,10 +108,6 @@ window.addEventListener('showStyles:done', function _() { } } - dom.list = $('#search-results-list'); - - addEventListener('scroll', loadMoreIfNeeded, {passive: true}); - if (FIREFOX) { let lastShift; addEventListener('resize', () => { @@ -130,43 +121,24 @@ window.addEventListener('showStyles:done', function _() { } addEventListener('styleDeleted', ({detail: {style: {id}}}) => { - const result = processedResults.find(r => r.installedStyleId === id); + restoreScrollPosition(); + const result = results.find(r => r.installedStyleId === id); if (result) { - result.installed = false; - result.installedStyleId = -1; - window.clearTimeout(result.pingbackTimer); - renderActionButtons($('#' + RESULT_ID_PREFIX + result.id)); + clearTimeout(result.pingbackTimer); + renderActionButtons(result.i, -1); } }); - addEventListener('styleAdded', ({detail: {style: {id, md5Url}}}) => { - const usoId = parseInt(md5Url && md5Url.match(/\d+|$/)[0]); - const result = usoId && processedResults.find(r => r.id === usoId); - if (result) { - result.installed = true; - result.installedStyleId = id; - renderActionButtons($('#' + RESULT_ID_PREFIX + usoId)); + addEventListener('styleAdded', async ({detail: {style}}) => { + restoreScrollPosition(); + const usoId = calcUsoId(style) || + calcUsoId(await API.getStyle(style.id, true)); + if (usoId && results.find(r => r.i === usoId)) { + renderActionButtons(usoId, style.id); } }); - - chromeLocal.getValue(CACHE_CLEANUP_NEEDED).then(value => - value && debounce(cleanupCache, CACHE_CLEANUP_THROTTLE)); } - //endregion - //region Loader - - /** - * Sets loading status of search results. - * @param {Boolean} isLoading If search results are idle (false) or still loading (true). - */ - function setLoading(isLoading) { - if (loading !== isLoading) { - loading = isLoading; - // Refresh elements that depend on `loading` state. - render(); - } - } function showSpinner(parent) { parent = parent instanceof Node ? parent : $(parent); @@ -174,166 +146,92 @@ window.addEventListener('showStyles:done', function _() { new Array(12).fill($create('div')).map(e => e.cloneNode()))); } - /** Increments displayedPage and loads results. */ function next() { - if (loading) { - debounce(next, 100); - return; - } - displayedPage += 1; + displayedPage = Math.min(totalPages, displayedPage + 1); scrollToFirstResult = true; render(); - loadMoreIfNeeded(); } - /** Decrements currentPage and loads results. */ function prev() { - if (loading) { - debounce(next, 100); - return; - } displayedPage = Math.max(1, displayedPage - 1); scrollToFirstResult = true; render(); } - /** - * Display error message to user. - * @param {string} message Message to display to user. - */ function error(reason) { - dom.error.textContent = reason === 404 ? t('searchResultNoneFound') : reason; - dom.error.classList.remove('hidden'); - dom.container.classList.toggle('hidden', !processedResults.length); - document.body.classList.toggle('search-results-shown', processedResults.length > 0); + dom.error.textContent = reason; + show(dom.error); + hide(dom.list); if (dom.error.getBoundingClientRect().bottom < 0) { dom.error.scrollIntoView({behavior: 'smooth', block: 'start'}); } } - /** - * Initializes search results container, starts fetching results. - */ - function load() { - if (searchExhausted > 1) { - if (!processedResults.length) { - error(404); + async function start() { + show(dom.container); + show(dom.list); + hide(dom.error); + results = []; + try { + for (let retry = 0; !results.length && retry <= 2; retry++) { + results = await search({retry}); } - return; - } - - setLoading(true); - dom.container.classList.remove('hidden'); - dom.error.classList.add('hidden'); - - category = category || getCategory(); - - search({category}) - .then(function process(results) { - const data = results.data.filter(sameCategoryNoDupes); - - if (!data.length && searchExhausted <= 1) { - const old = category; - const uso = (processedResults[0] || {}).subcategory; - category = uso !== category && uso || getCategory({retry: true}); - if (category !== old) return search({category, restart: true}).then(process); - } - - const numIrrelevant = results.data.length - data.length; - totalResults += results.current_page === 1 ? results.total_entries : 0; - totalResults = Math.max(0, totalResults - numIrrelevant); - totalPages = Math.ceil(totalResults / DISPLAY_PER_PAGE); - - setLoading(false); - - if (data.length) { - unprocessedResults.push(...data); - processNextResult(); - } else if (numIrrelevant) { - load(); - } else if (!processedResults.length) { - return Promise.reject(404); - } - }) - .catch(error); - } - - function loadMoreIfNeeded(event) { - let pageToPrefetch = displayedPage; - if (event instanceof Event) { - if ((loadMoreIfNeeded.prefetchedPage || 0) <= pageToPrefetch && - document.scrollingElement.scrollTop > document.scrollingElement.scrollHeight / 2) { - loadMoreIfNeeded.prefetchedPage = ++pageToPrefetch; - } else { - return; + if (results.length) { + const installedStyles = await API.getAllStyles(true); + const allUsoIds = new Set(installedStyles.map(calcUsoId)); + results = results.filter(r => !allUsoIds.has(r.i)); } - } - if (processedResults.length < pageToPrefetch * DISPLAY_PER_PAGE) { - setTimeout(load, DELAY_BEFORE_SEARCHING_STYLES); + render(); + (results.length ? show : hide)(dom.list); + if (!results.length && !$('#search-query').value) { + error(t('searchResultNoneFound')); + } + } catch (reason) { + error(reason); } } - /** - * Processes the next search result in `unprocessedResults` and adds to `processedResults`. - * Skips installed/non-applicable styles. - * Fetches more search results if unprocessedResults is empty. - * Recurses until shouldLoadMore() is false. - */ - function processNextResult() { - const result = unprocessedResults.shift(); - if (!result) { - loadMoreIfNeeded(); - return; - } - const md5Url = UPDATE_URL.replace('%', result.id); - API.styleExists({md5Url}).then(exist => { - if (exist) { - totalResults = Math.max(0, totalResults - 1); - } else { - processedResults.push(result); - render(); - } - setTimeout(processNextResult, !exist && DELAY_AFTER_FETCHING_STYLES); - }); - } - - //endregion - //region UI - function render() { - let start = (displayedPage - 1) * DISPLAY_PER_PAGE; - const end = displayedPage * DISPLAY_PER_PAGE; - + totalPages = Math.ceil(results.length / PAGE_LENGTH); + displayedPage = Math.min(displayedPage, totalPages) || 1; + let start = (displayedPage - 1) * PAGE_LENGTH; + const end = displayedPage * PAGE_LENGTH; let plantAt = 0; let slot = dom.list.children[0]; - // keep rendered elements with ids in the range of interest while ( - plantAt < DISPLAY_PER_PAGE && - slot && slot.id === 'search-result-' + (processedResults[start] || {}).id + plantAt < PAGE_LENGTH && + slot && slot.id === 'search-result-' + (results[start] || {}).i ) { slot = slot.nextElementSibling; plantAt++; start++; } - - const plantEntry = entry => { + // add new elements + while (start < Math.min(end, results.length)) { + const entry = createSearchResultNode(results[start++]); if (slot) { dom.list.replaceChild(entry, slot); slot = entry.nextElementSibling; } else { dom.list.appendChild(entry); } - entry.classList.toggle('search-result-fadein', - !slot || performance.now() - slot._plantedTime > FADEIN_THRESHOLD); - return entry; - }; - - while (start < Math.min(end, processedResults.length)) { - plantEntry(createSearchResultNode(processedResults[start++])); plantAt++; } - + // remove extraneous elements + const pageLen = end > results.length && + results.length % PAGE_LENGTH || + Math.min(results.length, PAGE_LENGTH); + while (dom.list.children.length > pageLen) { + dom.list.lastElementChild.remove(); + } + if (results.length && 'empty' in dom.container.dataset) { + delete dom.container.dataset.empty; + } + if (scrollToFirstResult && (!FIREFOX || FIREFOX >= 55)) { + debounce(doScrollToFirstResult); + } + // navigation for (const place in dom.nav) { const nav = dom.nav[place]; nav._prev.disabled = displayedPage <= 1; @@ -341,34 +239,6 @@ window.addEventListener('showStyles:done', function _() { nav._page.textContent = displayedPage; nav._total.textContent = totalPages; } - - // Fill in remaining search results with blank results + spinners - const maxResults = end > totalResults && - totalResults % DISPLAY_PER_PAGE || - DISPLAY_PER_PAGE; - while (plantAt < maxResults) { - if (!slot || slot.id.startsWith(RESULT_ID_PREFIX)) { - const entry = plantEntry(template.emptySearchResult.cloneNode(true)); - entry._plantedTime = performance.now(); - showSpinner(entry); - } - plantAt++; - if (!processedResults.length) { - break; - } - } - - while (dom.list.children.length > maxResults) { - dom.list.lastElementChild.remove(); - } - - if (processedResults.length && 'empty' in dom.container.dataset) { - delete dom.container.dataset.empty; - } - - if (scrollToFirstResult && (!FIREFOX || FIREFOX >= 55)) { - debounce(doScrollToFirstResult); - } } function doScrollToFirstResult() { @@ -379,96 +249,61 @@ window.addEventListener('showStyles:done', function _() { } /** - * Constructs and adds the given search result to the popup's Search Results container. - * @param {Object} result The SearchResult object from userstyles.org + * @param {IndexEntry} result + * @returns {Node} */ function createSearchResultNode(result) { - /* - userstyleSearchResult format: { - id: 100835, - name: "Reddit Flat Dark", - screenshot_url: "19339_after.png", - description: "...", - user: { - id: 48470, - name: "holloh" - }, - style_settings: [...] - } - */ - const entry = template.searchResult.cloneNode(true); - Object.assign(entry, { - _result: result, - id: RESULT_ID_PREFIX + result.id, - }); - + const { + i: id, + n: name, + r: rating, + u: updateTime, + w: weeklyInstalls, + t: totalInstalls, + an: author, + sa: shotArchived, + sn: shotName, + } = entry._result = result; + entry.id = RESULT_ID_PREFIX + id; + // title Object.assign($('.search-result-title', entry), { onclick: handleEvent.openURLandHide, - href: BASE_URL + result.url + href: URLS.usoArchive + `?category=${category}&style=${id}` }); - - const displayedName = result.name.length < 300 ? result.name : result.name.slice(0, 300) + '...'; - $('.search-result-title span', entry).textContent = tWordBreak(displayedName); - - const screenshot = $('.search-result-screenshot', entry); - let url = result.screenshot_url; - if (!url) { - url = BLANK_PIXEL_DATA; - screenshot.classList.add('no-screenshot'); - } else if (/^[0-9]*_after.(jpe?g|png|gif)$/i.test(url)) { - url = BASE_URL + '/style_screenshot_thumbnails/' + url; - } - screenshot.src = url; - if (url !== BLANK_PIXEL_DATA) { - screenshot.classList.add('search-result-fadein'); - screenshot.onload = () => { - screenshot.classList.remove('search-result-fadein'); - }; - } - - const description = result.description - .replace(/<[^>]*>/g, ' ') - .replace(/([^.][.。?!]|[\s,].{50,70})\s+/g, '$1\n') - .replace(/([\r\n]\s*){3,}/g, '\n\n'); - Object.assign($('.search-result-description', entry), { - textContent: description, - title: description, + $('.search-result-title span', entry).textContent = + tWordBreak(name.length < 300 ? name : name.slice(0, 300) + '...'); + // screenshot + const auto = URLS.uso + `auto_style_screenshots/${id}${USO_AUTO_PIC_SUFFIX}`; + Object.assign($('.search-result-screenshot', entry), { + src: shotName && !shotName.endsWith(USO_AUTO_PIC_SUFFIX) + ? `${shotArchived ? URLS.usoArchiveRaw : URLS.uso + 'style_'}screenshots/${shotName}` + : auto, + _src: auto, + onerror: fixScreenshot, }); - + // author Object.assign($('[data-type="author"] a', entry), { - textContent: result.user.name, - title: result.user.name, - href: BASE_URL + '/users/' + result.user.id, + textContent: author, + title: author, + href: URLS.usoArchive + '?author=' + encodeURIComponent(author).replace(/%20/g, '+'), onclick: handleEvent.openURLandHide, }); - - let ratingClass; - let ratingValue = result.rating; - if (ratingValue === null) { - ratingClass = 'none'; - ratingValue = ''; - } else if (ratingValue >= 2.5) { - ratingClass = 'good'; - ratingValue = ratingValue.toFixed(1); - } else if (ratingValue >= 1.5) { - ratingClass = 'okay'; - ratingValue = ratingValue.toFixed(1); - } else { - ratingClass = 'bad'; - ratingValue = ratingValue.toFixed(1); - } - $('[data-type="rating"]', entry).dataset.class = ratingClass; - $('[data-type="rating"] dd', entry).textContent = ratingValue; - + // rating + $('[data-type="rating"]', entry).dataset.class = + !rating ? 'none' : + rating >= 2.5 ? 'good' : + rating >= 1.5 ? 'okay' : + 'bad'; + $('[data-type="rating"] dd', entry).textContent = rating && rating.toFixed(1) || ''; + // time Object.assign($('[data-type="updated"] time', entry), { - dateTime: result.updated, - textContent: formatDate(result.updated) + dateTime: updateTime * 1000, + textContent: formatDate(updateTime * 1000) }); - - $('[data-type="weekly"] dd', entry).textContent = formatNumber(result.weekly_install_count); - $('[data-type="total"] dd', entry).textContent = formatNumber(result.total_install_count); - + // totals + $('[data-type="weekly"] dd', entry).textContent = formatNumber(weeklyInstalls); + $('[data-type="total"] dd', entry).textContent = formatNumber(totalInstalls); renderActionButtons(entry); return entry; } @@ -484,141 +319,123 @@ window.addEventListener('showStyles:done', function _() { ); } - function renderActionButtons(entry) { - if (!entry) { - return; + function fixScreenshot() { + const {_src} = this; + if (_src && _src !== this.src) { + this.src = _src; + delete this._src; + } else { + this.src = BLANK_PIXEL; + this.onerror = null; } - const result = entry._result; + } - if (result.installed && !('installed' in entry.dataset)) { + function renderActionButtons(entry, installedId) { + if (Number(entry)) { + entry = $('#' + RESULT_ID_PREFIX + entry); + } + if (!entry) return; + const result = entry._result; + if (typeof installedId === 'number') { + result.installed = installedId > 0; + result.installedStyleId = installedId; + } + const isInstalled = result.installed; + if (isInstalled && !('installed' in entry.dataset)) { entry.dataset.installed = ''; $('.search-result-status', entry).textContent = t('clickToUninstall'); - } else if (!result.installed && 'installed' in entry.dataset) { + } else if (!isInstalled && 'installed' in entry.dataset) { delete entry.dataset.installed; $('.search-result-status', entry).textContent = ''; + hide('.search-result-customize', entry); } + Object.assign($('.search-result-screenshot', entry), { + onclick: isInstalled ? uninstall : install, + title: isInstalled ? '' : t('installButton'), + }); + $('.search-result-uninstall', entry).onclick = uninstall; + $('.search-result-install', entry).onclick = install; + } - const screenshot = $('.search-result-screenshot', entry); - screenshot.onclick = result.installed ? onUninstallClicked : onInstallClicked; - screenshot.title = result.installed ? '' : t('installButton'); - - const uninstallButton = $('.search-result-uninstall', entry); - uninstallButton.onclick = onUninstallClicked; - - const installButton = $('.search-result-install', entry); - installButton.onclick = onInstallClicked; - if ((result.style_settings || []).length > 0) { - // Style has customizations - installButton.classList.add('customize'); - uninstallButton.classList.add('customize'); - - const customizeButton = $('.search-result-customize', entry); - customizeButton.dataset.href = BASE_URL + result.url; - customizeButton.dataset.sendMessage = JSON.stringify({method: 'openSettings'}); - customizeButton.classList.remove('hidden'); - customizeButton.onclick = function (event) { - event.stopPropagation(); - handleEvent.openURLandHide.call(this, event); - }; + function renderFullInfo(entry, style) { + let {description, vars} = style.usercssData; + // description + description = (description || '') + .replace(/<[^>]*>/g, ' ') + .replace(/([^.][.。?!]|[\s,].{50,70})\s+/g, '$1\n') + .replace(/([\r\n]\s*){3,}/g, '\n\n'); + Object.assign($('.search-result-description', entry), { + textContent: description, + title: description, + }); + // config button + if (vars) { + const btn = $('.search-result-customize', entry); + btn.onclick = () => $('.configure', $.entry(style)).click(); + show(btn); } } - function onUninstallClicked(event) { - event.stopPropagation(); + async function install() { const entry = this.closest('.search-result'); - saveScrollPosition(entry); - API.deleteStyle(entry._result.installedStyleId) - .then(restoreScrollPosition); - } - - /** Installs the current userstyleSearchResult into Stylus. */ - function onInstallClicked(event) { - event.stopPropagation(); - - const entry = this.closest('.search-result'); - const result = entry._result; + const result = /** @type IndexEntry */ entry._result; + const {i: id} = result; const installButton = $('.search-result-install', entry); showSpinner(entry); saveScrollPosition(entry); installButton.disabled = true; entry.style.setProperty('pointer-events', 'none', 'important'); + // FIXME: move this to background page and create an API like installUSOStyle + result.pingbackTimer = setTimeout(download, PINGBACK_DELAY, + `${URLS.uso}/styles/install/${id}?source=stylish-ch`); - // Fetch settings to see if we should display "configure" button - Promise.all([ - fetchStyleJson(result), - fetchStyleSettings(result), - API.download({url: UPDATE_URL.replace('%', result.id)}) - ]) - .then(([style, settings, md5]) => { - pingback(result); - // show a 'config-on-homepage' icon in the popup - style.updateUrl += settings.length ? '?' : ''; - style.originalMd5 = md5; - return API.installStyle(style); - }) - .catch(reason => { - const usoId = result.id; - console.debug('install:saveStyle(usoID:', usoId, ') => [ERROR]: ', reason); - error('Error while downloading usoID:' + usoId + '\nReason: ' + reason); - }) - .then(() => { - $.remove('.lds-spinner', entry); - installButton.disabled = false; - entry.style.pointerEvents = ''; - restoreScrollPosition(); - }); - - function fetchStyleSettings(result) { - return result.style_settings || - fetchStyle(result.id).then(style => { - result.style_settings = style.style_settings || []; - return result.style_settings; - }); + const updateUrl = `${URLS.usoArchiveRaw}usercss/${id}.user.css`; + try { + const sourceCode = await download(updateUrl); + const style = await API.installUsercss({sourceCode, updateUrl}); + renderFullInfo(entry, style); + } catch (reason) { + error(`Error while downloading usoID:${id}\nReason: ${reason}`); } + $.remove('.lds-spinner', entry); + installButton.disabled = false; + entry.style.pointerEvents = ''; } - function pingback(result) { - const wnd = window; - // FIXME: move this to background page and create an API like installUSOStyle - result.pingbackTimer = wnd.setTimeout(wnd.download, PINGBACK_DELAY, - BASE_URL + '/styles/install/' + result.id + '?source=stylish-ch'); + function uninstall() { + const entry = this.closest('.search-result'); + saveScrollPosition(entry); + API.deleteStyle(entry._result.installedStyleId); } function saveScrollPosition(entry) { - dom.scrollPosition = entry.getBoundingClientRect().top; - dom.scrollPositionElement = entry; + dom.scrollPos = entry.getBoundingClientRect().top; + dom.scrollPosElement = entry; } function restoreScrollPosition() { - const t0 = performance.now(); - new MutationObserver((mutations, observer) => { - if (performance.now() - t0 < 1000) { - window.scrollBy(0, dom.scrollPositionElement.getBoundingClientRect().top - dom.scrollPosition); - } - observer.disconnect(); - }).observe(document.body, {childList: true, subtree: true, attributes: true}); + window.scrollBy(0, dom.scrollPosElement.getBoundingClientRect().top - dom.scrollPos); } - //endregion - //region USO API wrapper - /** * Resolves the Userstyles.org "category" for a given URL. + * @returns {boolean} true if the category has actually changed */ - function getCategory({retry} = {}) { + function calcCategory({retry} = {}) { const u = tryCatch(() => new URL(tabURL)); + const old = category; if (!u) { // Invalid URL - return ''; + category = ''; } else if (u.protocol === 'file:') { - return 'file:'; + category = 'file:'; } else if (u.protocol === location.protocol) { - return STYLUS_CATEGORY; + category = STYLUS_CATEGORY; } else { const parts = u.hostname.replace(/\.(?:com?|org)(\.\w{2,3})$/, '$1').split('.'); const [tld, main = u.hostname, third, fourth] = parts.reverse(); - const keepTld = !retry && !( + const keepTld = retry !== 1 && !( tld === 'com' || tld === 'org' && main !== 'userstyles' ); @@ -626,214 +443,63 @@ window.addEventListener('showStyles:done', function _() { fourth || third && third !== 'www' && third !== 'm' ); - return (keepThird && `${third}.` || '') + main + (keepTld || keepThird ? `.${tld}` : ''); + category = (keepThird && `${third}.` || '') + main + (keepTld || keepThird ? `.${tld}` : ''); } + return category !== old; } - function sameCategoryNoDupes(result) { + async function fetchIndex() { + const timer = setTimeout(showSpinner, BUSY_DELAY, dom.list); + index = (await download(INDEX_URL, {responseType: 'json'})) + .filter(res => res.f === 'uso'); + clearTimeout(timer); + $.remove(':scope > .lds-spinner', dom.list); + return index; + } + + async function search({retry} = {}) { + return retry && !calcCategory({retry}) + ? [] + : (index || await fetchIndex()).filter(isResultMatching).sort(comparator); + } + + function isResultMatching(res) { return ( - result.subcategory && - !processedResults.some(pr => pr.id === result.id) && - (category !== STYLUS_CATEGORY || /\bStylus\b/i.test(result.name + result.description)) && - category.split('.').includes(result.subcategory.split('.')[0]) + res.c === category || + searchGlobals && res.c === 'global' && (query.length || calcHaystack(res)._nLC.includes(category)) + ) && ( + category === STYLUS_CATEGORY + ? /\bStylus\b/.test(res.n) + : !query.length || query.every(isInHaystack, calcHaystack(res)) ); } - /** - * Fetches the JSON style object from userstyles.org (containing code, sections, updateUrl, etc). - * Stores (caches) the JSON within the given result, to avoid unnecessary network usage. - * Style JSON is fetched from the /styles/chrome/{id}.json endpoint. - * @param {Object} result A search result object from userstyles.org - * @returns {Promise} Promises the response as a JSON object. - */ - function fetchStyleJson(result) { - return Promise.resolve( - result.json || - downloadFromUSO(JSON_URL + result.id + '.json').then(json => { - result.json = json; - return json; - })); + /** @this {IndexEntry} haystack */ + function isInHaystack(needle) { + return this._year === needle && this.c !== 'global' || + this._nLC.includes(needle); } /** - * Fetches style information from userstyles.org's /api/v1/styles/{ID} API. - * @param {number} userstylesId The internal "ID" for a style on userstyles.org - * @returns {Promise} An object containing info about the style, e.g. name, author, etc. + * @param {IndexEntry} a + * @param {IndexEntry} b */ - function fetchStyle(userstylesId) { - return readCache(userstylesId).then(json => - json || - downloadFromUSO(API_URL + userstylesId).then(writeCache)); + function comparator(a, b) { + return ( + order === 'n' + ? a.n < b.n ? -1 : a.n > b.n + : b[order] - a[order] + ) || b.t - a.t; } - /** - * Fetches (and JSON-parses) search results from a userstyles.org search API. - * Automatically sets searchCurrentPage and searchTotalPages. - * @param {string} category The usrestyles.org "category" (subcategory) OR a any search string. - * @return {Object} Response object from userstyles.org - */ - function search({category, restart}) { - if (restart) { - searchCurrentPage = 1; - searchTotalPages = undefined; - } - if (searchTotalPages !== undefined && searchCurrentPage > searchTotalPages) { - return Promise.resolve({'data':[]}); - } - - const searchURL = API_URL + 'subcategory' + - '?search=' + encodeURIComponent(category) + - '&page=' + searchCurrentPage + - '&per_page=10' + - '&country=NA'; - - const cacheKey = category + '/' + searchCurrentPage; - - return readCache(cacheKey) - .then(json => - json || - downloadFromUSO(searchURL).then(writeCache)) - .then(json => { - searchCurrentPage = json.current_page + 1; - searchTotalPages = json.total_pages; - searchExhausted += searchCurrentPage > searchTotalPages; - return json; - }).catch(reason => { - searchExhausted++; - return Promise.reject(reason); - }); + function calcUsoId({md5Url: m, updateUrl}) { + return parseInt(m && m.match(/\d+|$/)[0]) || + URLS.extractUsoArchiveId(updateUrl); } - //endregion - //region Cache - - function readCache(id) { - const key = CACHE_PREFIX + id; - return chromeLocal.getValue(key).then(item => { - if (!cacheItemExpired(item)) { - return chromeLocal.loadLZStringScript().then(() => - tryJSONparse(LZString.decompressFromUTF16(item.payload))); - } else if (item) { - chromeLocal.remove(key); - } - }); + function calcHaystack(res) { + if (!res._nLC) res._nLC = res.n.toLocaleLowerCase(); + if (!res._year) res._year = new Date(res.u * 1000).getFullYear(); + return res; } - - function writeCache(data, debounced) { - data.id = data.id || category + '/' + data.current_page; - for (const prop of CACHE_EXCEPT_PROPS) { - delete data[prop]; - } - if (!debounced) { - // using plain setTimeout because debounce() replaces previous parameters - setTimeout(writeCache, 100, data, true); - return data; - } else { - chromeLocal.setValue(CACHE_CLEANUP_NEEDED, true); - debounce(cleanupCache, CACHE_CLEANUP_THROTTLE); - return chromeLocal.loadLZStringScript().then(() => - chromeLocal.setValue(CACHE_PREFIX + data.id, { - payload: LZString.compressToUTF16(JSON.stringify(data)), - date: Date.now(), - })).then(() => data); - } - } - - function cacheItemExpired(item) { - return !item || !item.date || Date.now() - item.date > CACHE_DURATION; - } - - function cleanupCache() { - chromeLocal.remove(CACHE_CLEANUP_NEEDED); - Promise.resolve(!browser.storage.local.getBytesInUse ? 1e99 : browser.storage.local.getBytesInUse()) - .then(size => size > CACHE_SIZE && chromeLocal.get().then(cleanupCacheInternal)); - } - - function cleanupCacheInternal(storage) { - const sortedByTime = Object.keys(storage) - .filter(key => key.startsWith(CACHE_PREFIX)) - .map(key => Object.assign(storage[key], {key})) - .sort((a, b) => a.date - b.date); - const someExpired = cacheItemExpired(sortedByTime[0]); - const expired = someExpired ? sortedByTime.filter(cacheItemExpired) : - sortedByTime.slice(0, sortedByTime.length / 2); - const toRemove = expired.length ? expired : sortedByTime; - if (toRemove.length) { - chromeLocal.remove(toRemove.map(item => item.key)); - } - } - - //endregion - //region USO referrer spoofing - - function downloadFromUSO(url) { - const requestId = getRandomId(); - xhrSpoofIds.add(requestId); - xhrSpoofStart(); - return download(url, { - body: null, - responseType: 'json', - timeout: 60e3, - headers: { - 'Referrer-Policy': 'origin-when-cross-origin', - [xhrSpoofTelltale]: requestId, - } - }).then(data => { - xhrSpoofDone(requestId); - return data; - }).catch(data => { - xhrSpoofDone(requestId); - return Promise.reject(data); - }); - } - - function xhrSpoofStart() { - if (chrome.webRequest.onBeforeSendHeaders.hasListener(xhrSpoof)) { - return; - } - const urls = [API_URL + '*', JSON_URL + '*']; - const types = ['xmlhttprequest']; - const options = ['blocking', 'requestHeaders']; - // spoofing Referer requires extraHeaders in Chrome 72+ - if (chrome.webRequest.OnBeforeSendHeadersOptions.EXTRA_HEADERS) { - options.push(chrome.webRequest.OnBeforeSendHeadersOptions.EXTRA_HEADERS); - } - chrome.webRequest.onBeforeSendHeaders.addListener(xhrSpoof, {urls, types}, options); - } - - function xhrSpoofDone(requestId) { - xhrSpoofIds.delete(requestId); - if (!xhrSpoofIds.size) { - chrome.webRequest.onBeforeSendHeaders.removeListener(xhrSpoof); - } - } - - function xhrSpoof({requestHeaders}) { - let referer, hasTelltale; - for (let i = requestHeaders.length; --i >= 0;) { - const header = requestHeaders[i]; - if (header.name.toLowerCase() === 'referer') { - referer = header; - } else if (header.name === xhrSpoofTelltale) { - hasTelltale = xhrSpoofIds.has(header.value); - requestHeaders.splice(i, 1); - } - } - if (!hasTelltale) { - // not our request (unlikely but just in case) - return; - } - if (referer) { - referer.value = BASE_URL; - } else { - requestHeaders.push({name: 'Referer', value: BASE_URL}); - } - return {requestHeaders}; - } - - function getRandomId() { - return btoa(Math.random()).replace(/[^a-z]/gi, ''); - } - - //endregion -}); +}, {once: true});