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