diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 95cf61d4..a4f6eee9 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1181,6 +1181,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/js/messaging.js b/js/messaging.js index e7f0e9da..8c215cac 100644 --- a/js/messaging.js +++ b/js/messaging.js @@ -62,7 +62,15 @@ 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]), supported: url => ( url.startsWith('http') && (FIREFOX || !url.startsWith(URLS.browserWebStore)) || @@ -438,7 +446,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/popup.html b/popup.html index 41cd7852..980eb227 100644 --- a/popup.html +++ b/popup.html @@ -120,9 +120,7 @@
- +
@@ -254,6 +252,20 @@ 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..d4c8d4e9 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,18 @@ body.search-results-shown { padding-left: 16px; } +#search-params { + display: flex; + position: relative; + margin-bottom: 1.25rem; +} + +#search-query { + min-width: 3em; + margin-right: .5em; + flex: auto; +} + /* 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..a29b6911 100755 --- a/popup/search-results.js +++ b/popup/search-results.js @@ -1,105 +1,95 @@ -/* 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 = ''; + /** @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 $orNode = (sel, base) => sel instanceof Node ? sel : $(sel, base); + const show = (...args) => $orNode(...args).classList.remove('hidden'); + const hide = (...args) => $orNode(...args).classList.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-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 +103,6 @@ window.addEventListener('showStyles:done', function _() { } } - dom.list = $('#search-results-list'); - - addEventListener('scroll', loadMoreIfNeeded, {passive: true}); - if (FIREFOX) { let lastShift; addEventListener('resize', () => { @@ -130,43 +116,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 +141,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); + (results.length ? show : hide)(dom.container); + document.body.classList.toggle('search-results-shown', results.length > 0); 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); + 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); - } - } - - /** - * 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); + if (results.length || $('#search-query').value) { render(); + } else { + error(t('searchResultNoneFound')); } - setTimeout(processNextResult, !exist && DELAY_AFTER_FETCHING_STYLES); - }); + } catch (reason) { + error(reason); + } } - //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 +234,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 +244,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 +314,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 +438,61 @@ 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'}); + 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.f === 'uso' && + res.c === 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 needle === this._year || 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});