diff --git a/popup.html b/popup.html index 88041824..fd125c75 100644 --- a/popup.html +++ b/popup.html @@ -118,6 +118,18 @@ + + @@ -182,7 +194,6 @@ -
@@ -200,16 +211,10 @@ diff --git a/popup/search-results.css b/popup/search-results.css index 960bac83..7bb8c2bf 100755 --- a/popup/search-results.css +++ b/popup/search-results.css @@ -12,7 +12,7 @@ body.search-results-shown { font-weight: bold; padding: 5px; text-align: center; - margin-right: 20px; + margin: -.5rem 0 1rem; } #search-results-list { @@ -20,7 +20,8 @@ body.search-results-shown { min-height: 200px; } -.search-result, .search-result-empty { +.search-result, +.search-result-empty { position: relative; padding: .75rem; min-height: 160px; @@ -41,6 +42,11 @@ body.search-results-shown { transform: scale(.75); } +.search-result-fadein { + animation: fadein 1s; + animation-fill-mode: both; +} + .search-result-screenshot { height: 140px; width: 100%; @@ -182,22 +188,26 @@ body.search-results-shown { margin: 3px; } -#search-results-nav { +#search-results-nav-top, +#search-results-nav-bottom { flex-direction: row; text-align: center; word-break: keep-all; - opacity: 1.0; - margin-bottom: 10px; + margin-bottom: 1rem; } -#search-results-nav label { - width: 40px; - display: inline-block; - word-break: keep-all; +#search-results-nav-top button, +#search-results-nav-bottom button { + -webkit-appearance: none; + background: none; + border: none; + padding: .25rem 1rem; + margin: 0 .5rem; } -#search-results-nav button { - text-align: center; +#search-results-nav-top button:hover, +#search-results-nav-bottom button:hover { + text-shadow: 0 0 2px currentColor; } #find-styles-inline-group label { @@ -212,7 +222,6 @@ body.search-results-shown { -ms-user-select: none; user-select: none; pointer-events: none; - opacity: 0; position: absolute; top: 0; left: 0; @@ -220,12 +229,13 @@ body.search-results-shown { width: 200px; /* don't change! use "transform: scale(.75)" */ height: 200px; /* don't change! use "transform: scale(.75)" */ margin: auto; - animation: fadein 1s; + animation: lds-spinner 1s reverse; + animation-fill-mode: both; } @keyframes lds-spinner { 0% { - opacity: .5; + opacity: .25; } 100% { diff --git a/popup/search-results.js b/popup/search-results.js index e99008cb..20d6647a 100755 --- a/popup/search-results.js +++ b/popup/search-results.js @@ -8,489 +8,550 @@ window.addEventListener('showStyles:done', function _() { return; } + //region Init + + const BODY_CLASS = 'search-results-shown'; + const RESULT_ID_PREFIX = 'search-result-'; + + const BASE_URL = 'https://userstyles.org'; + const UPDATE_URL = 'https://update.userstyles.org/%.md5'; + + // normal category is just one word like 'github' or 'google' + // but for some sites we need a fallback + // key: category.tld + // value : use as category + // value true: fallback to search_terms + const CATEGORY_FALLBACK = { + 'userstyles.org': 'userstyles.org', + 'last.fm': true, + 'Stylus': true, + }; + const RX_CATEGORY = /^(?:.*?)([^.]+)(?:\.com?)?\.(\w+)$/; + + 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; + + const BLANK_PIXEL_DATA = 'data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAA' + + 'C1HAwCAAAAC0lEQVR42mOcXQ8AAbsBHLLDr5MAAAAASUVORK5CYII='; + + // TODO: add - + const CACHE_SIZE = 1e6; + const CACHE_PREFIX = 'usoSearchCache/'; + const CACHE_DURATION = 24 * 3600e3; + const CACHE_CLEANUP_THROTTLE = 60e3; + const CACHE_EXCEPT_PROPS = ['css', 'discussions', 'additional_info']; + + let searchTotalPages; + let searchCurrentPage = 1; + let searchExhausted = false; + + const processedResults = []; + const unprocessedResults = []; + + let loading = false; + // Category for the active tab's URL. + let category; + let scrollToFirstResult = true; + + let displayedPage = 1; + let totalPages = 1; + let totalResults = 0; + + // fade-in when the entry took that long to replace its placeholder + const FADEIN_THRESHOLD = 50; + + const dom = {}; + Object.assign($('#find-styles-link'), { - href: searchUserstyles().getSearchPageURL(tabURL), + href: getSearchPageURL(tabURL), onclick(event) { - if (!prefs.get('popup.findStylesInline')) { + if (!prefs.get('popup.findStylesInline') || dom.container) { handleEvent.openURLandHide.call(this, event); return; } $('#find-styles-inline-group').classList.add('hidden'); + this.textContent = this.title; + this.title = ''; - const searchResults = searchResultsController(); - searchResults.init(); - searchResults.load(); + init(); + load(); event.preventDefault(); }, }); - /** - * Represents the search results within the Stylus popup. - * @returns {Object} Includes load(), next(), and prev() methods to alter the search results. - */ - function searchResultsController() { - const DISPLAYED_RESULTS_PER_PAGE = 10; // Number of results to display in popup.html - const DELAY_AFTER_FETCHING_STYLES = 0; // Millisecs to wait before fetching next batch of search results. - const DELAY_BEFORE_SEARCHING_STYLES = 0; // Millisecs to wait before fetching .JSON for next search result. - const searchAPI = searchUserstyles(); - const unprocessedResults = []; // Search results not yet processed. - const processedResults = []; // Search results that are not installed and apply ot the page (includes 'json' field with full style). - const BLANK_PIXEL_DATA = 'data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAA' + - 'C1HAwCAAAAC0lEQVR42mOcXQ8AAbsBHLLDr5MAAAAASUVORK5CYII='; - const UPDATE_URL = 'https://update.userstyles.org/%.md5'; - const ENTRY_ID_PREFIX = 'search-result-'; + return; - let scrollToFirstResult = true; - let loading = false; - let category; // Category for the active tab's URL. - let currentDisplayedPage = 1; // Current page number in popup.html + function init() { + document.body.classList.add(BODY_CLASS); - return {init, load, next, prev}; + dom.container = $('#search-results'); + dom.error = $('#search-results-error'); - function init() { - $('#search-results-nav-prev').onclick = prev; - $('#search-results-nav-next').onclick = next; - document.body.classList.add('search-results-shown'); - addEventListener('styleDeleted', ({detail}) => { - const entries = [...$('#search-results-list').children]; - const entry = entries.find(el => el._result.installedStyleId === detail.id); - if (entry) { - entry._result.installed = false; - renderActionButtons(entry); - } - }); - } - - /** - * 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; - - render(); // Refresh elements that depend on `loading` state. - - if (isLoading) { - showSpinner('#search-results'); - } else { - $.remove('#search-results > .lds-spinner'); - } + dom.nav = {}; + const navOnClick = {prev, next}; + for (const place of ['top', 'bottom']) { + const nav = $('#search-results-nav-' + place); + nav.appendChild(template.searchNav.cloneNode(true)); + dom.nav[place] = nav; + for (const child of $$('[data-role]', nav)) { + const role = child.dataset.role; + child.onclick = navOnClick[role]; + nav['_' + role] = child; } } - function showSpinner(parent) { - parent = parent instanceof Node ? parent : $(parent); - parent.appendChild($create('.lds-spinner', - new Array(12).fill($create('div')).map(e => e.cloneNode()))); - } + dom.list = $('#search-results-list'); - function render() { - let startIndex = (currentDisplayedPage - 1) * DISPLAYED_RESULTS_PER_PAGE; - const endIndex = currentDisplayedPage * DISPLAYED_RESULTS_PER_PAGE; - - const list = $('#search-results-list'); - - // keep rendered elements with ids in the range of interest - for (let i = 0; i < DISPLAYED_RESULTS_PER_PAGE;) { - const el = list.children[i]; - if (!el) { - break; - } - if (el.id === 'search-result-' + (processedResults[startIndex] || {}).id) { - startIndex++; - i++; - } else { - el.remove(); - } + addEventListener('styleDeleted', ({detail}) => { + const entries = [...dom.list.children]; + const entry = entries.find(el => el._result.installedStyleId === detail.id); + if (entry) { + entry._result.installed = false; + renderActionButtons(entry); } + }); - const displayedResults = processedResults.slice(startIndex, endIndex); - displayedResults.forEach(createSearchResultNode); - - $('#search-results-nav-prev').disabled = (currentDisplayedPage <= 1 || loading); - $('#search-results-nav-current-page').textContent = currentDisplayedPage; - - let totalResultsCount = processedResults.length; - if (unprocessedResults.length > 0) { - // Add 1 page if there's results left to process. - totalResultsCount += DISPLAYED_RESULTS_PER_PAGE; + addEventListener('styleAdded', ({detail: {style: {md5Url}}}) => { + const usoId = md5Url && md5Url.match(/\d+|$/)[0]; + const entry = usoId && $('#' + RESULT_ID_PREFIX + usoId); + if (entry) { + entry._result.installed = true; + renderActionButtons(entry); } - const totalPageCount = Math.ceil(Math.max(1, totalResultsCount / DISPLAYED_RESULTS_PER_PAGE)); - $('#search-results-nav-next').disabled = (currentDisplayedPage >= totalPageCount || loading); - $('#search-results-nav-total-pages').textContent = totalPageCount; - - // Fill in remaining search results with blank results + spinners - const maxResults = currentDisplayedPage < totalPageCount - ? DISPLAYED_RESULTS_PER_PAGE - : displayedResults.length + unprocessedResults.length; - for (let i = list.children.length; i < maxResults; i++) { - const entry = template.emptySearchResult.cloneNode(true); - list.appendChild(entry); - showSpinner(entry); - } - - if (scrollToFirstResult && list.children[0]) { - scrollToFirstResult = false; - if (!FIREFOX || FIREFOX >= 55) { - list.children[0].scrollIntoView({behavior: 'smooth', block: 'start'}); - } - } - } - - /** - * @returns {Boolean} If we should process more results. - */ - function shouldLoadMore() { - return (processedResults.length < currentDisplayedPage * DISPLAYED_RESULTS_PER_PAGE); - } - - function loadMoreIfNeeded() { - if (shouldLoadMore()) { - setTimeout(load, DELAY_BEFORE_SEARCHING_STYLES); - } - } - - /** Increments currentDisplayedPage and loads results. */ - function next() { - currentDisplayedPage += 1; - scrollToFirstResult = true; - render(); - loadMoreIfNeeded(); - } - - /** Decrements currentPage and loads results. */ - function prev() { - currentDisplayedPage = Math.max(1, currentDisplayedPage - 1); - scrollToFirstResult = true; - render(); - } - - /** - * Display error message to user. - * @param {string} message Message to display to user. - */ - function error(reason) { - let message; - if (reason === 404) { - // TODO: i18n message - message = 'No results found'; - } else { - message = 'Error loading search results: ' + reason; - } - $('#search-results').classList.add('hidden'); - $('#search-results-error').textContent = message; - $('#search-results-error').classList.remove('hidden'); - } - - /** - * Initializes search results container, starts fetching results. - */ - function load() { - if (unprocessedResults.length > 0) { - // Keep processing search results if there are any. - processNextResult(); - } else if (searchAPI.isExhausted()) { - // Stop if no more search results. - if (processedResults.length === 0) { - // No results - error(404); - } - } else { - setLoading(true); - // Search for more results. - $('#search-results').classList.remove('hidden'); - $('#search-results-error').classList.add('hidden'); - - // Discover "category" for the URL, then search. - category = searchAPI.getCategory(tabURL); - searchAPI.search(category) - .then(searchResults => { - setLoading(false); - if (searchResults.data.length === 0) { - throw 404; - } - unprocessedResults.push(unprocessedResults, ...searchResults.data); - processNextResult(); - }) - .catch(error); - } - } - - /** - * 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() { - if (!shouldLoadMore()) { - return; - } - - if (unprocessedResults.length === 0) { - // No more results to process - loadMoreIfNeeded(); - return; - } - - // Process the next result in the queue. - const nextResult = unprocessedResults.shift(); - getStylesSafe({md5Url: UPDATE_URL.replace('%', nextResult.id)}) - .then(([installedStyle]) => { - if (installedStyle) { - setTimeout(processNextResult); - return; - } - if (nextResult.category !== 'site') { - setTimeout(processNextResult); - return; - } - // Style not installed. - // Get "style_settings" (customizations) - searchAPI.fetchStyle(nextResult.id) - .then(userstyleObject => { - // Store style settings for detecting customization later. - nextResult.style_settings = userstyleObject.style_settings; - processedResults.push(nextResult); - render(); - setTimeout(processNextResult, DELAY_AFTER_FETCHING_STYLES); - }) - .catch(reason => { - console.log('processNextResult(', nextResult.id, ') => [ERROR]: ', reason); - setTimeout(processNextResult, DELAY_AFTER_FETCHING_STYLES); - }); - }); - } - - /** - * Constructs and adds the given search result to the popup's Search Results container. - * @param {Object} result The SearchResult object from userstyles.org - */ - 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: ENTRY_ID_PREFIX + result.id, - }); - - Object.assign($('.search-result-title', entry), { - onclick: handleEvent.openURLandHide, - href: searchAPI.BASE_URL + result.url - }); - - 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 screenshotUrl = result.screenshot_url; - if (screenshotUrl === null) { - screenshotUrl = BLANK_PIXEL_DATA; - screenshot.classList.add('no-screenshot'); - } else if (RegExp(/^[0-9]*_after.(jpe?g|png|gif)$/i).test(screenshotUrl)) { - screenshotUrl = searchAPI.BASE_URL + '/style_screenshot_thumbnails/' + screenshotUrl; - } - screenshot.src = screenshotUrl; - - const description = result.description - .replace(/<[^>]*>/g, '') - .replace(/[\r\n]{3,}/g, '\n\n'); - Object.assign($('.search-result-description', entry), { - textContent: description, - title: description, - }); - - Object.assign($('.search-result-author-link', entry), { - textContent: result.user.name, - title: result.user.name, - href: searchAPI.BASE_URL + '/users/' + result.user.id, - onclick(event) { - event.stopPropagation(); - handleEvent.openURLandHide.call(this, event); - } - }); - - let ratingClass; - let ratingValue = result.rating; - if (ratingValue === null) { - ratingClass = 'none'; - ratingValue = 'n/a'; - } 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); - } - Object.assign($('.search-result-rating', entry), { - textContent: ratingValue, - className: 'search-result-rating ' + ratingClass - }); - - Object.assign($('.search-result-install-count', entry), { - textContent: result.total_install_count.toLocaleString() - }); - renderActionButtons(entry); - - $('#search-results-list').appendChild(entry); - return entry; - } - - function renderActionButtons(entry) { - const uninstallButton = $('.search-result-uninstall', entry); - uninstallButton.onclick = onUninstallClicked; - - const installButton = $('.search-result-install', entry); - installButton.onclick = onInstallClicked; - - const result = entry._result; - 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 = searchAPI.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); - }; - } - - installButton.classList.toggle('hidden', Boolean(result.installed)); - uninstallButton.classList.toggle('hidden', !result.installed); - } - - function onUninstallClicked(event) { - event.stopPropagation(); - const entry = this.closest('.search-result'); - const result = entry._result; - deleteStyleSafe({id: result.installedStyleId}) - .then(() => { - entry._result.installed = false; - renderActionButtons(entry); - }); - } - - /** Installs the current userstyleSearchResult into Stylus. */ - function onInstallClicked(event) { - event.stopPropagation(); - - const entry = this.closest('.search-result'); - const result = entry._result; - const installButton = $('.search-result-install', entry); - - showSpinner(entry); - installButton.disabled = true; - - // Fetch .JSON style - searchAPI.fetchStyleJson(result) - .then(userstyleJson => { - userstyleJson.reason = 'install'; - if (result.style_settings.length) { - // this will make the popup show a config-on-homepage icon - userstyleJson.updateUrl += '?'; - } - // Install style - saveStyleSafe(userstyleJson) - .then(savedStyle => { - // Success: Store installed styleId, mark as installed. - result.installed = true; - result.installedStyleId = savedStyle.id; - renderActionButtons(entry); - - $.remove('.lds-spinner', entry); - installButton.disabled = false; - }); - }) - .catch(reason => { - const usoId = result.id; - console.log('install:saveStyleSafe(usoID:', usoId, ') => [ERROR]: ', reason); - alert('Error while downloading usoID:' + usoId + '\nReason: ' + reason); - - $.remove('.lds-spinner', entry); - installButton.disabled = false; - }); - return true; - } - - } // End of searchResultsController -}); - -/** - * Library for interacting with userstyles.org - * @returns {Object} Exposed methods representing the search results on userstyles.org - */ -function searchUserstyles() { - const BASE_URL = 'https://userstyles.org'; - const CACHE_PREFIX = 'usoSearchCache'; - const CACHE_DURATION = 1 * 3600e3; - let totalPages; - let currentPage = 1; - let exhausted = false; - - return {BASE_URL, getCategory, getSearchPageURL, isExhausted, search, fetchStyleJson, fetchStyle}; - - /** - * @returns {Boolean} If there are no more results to fetch from userstyles.org - */ - function isExhausted() { - return exhausted; + }); } - function getSearchPageURL(url) { - const category = getCategory(url); - if (category === 'STYLUS') { - return BASE_URL + '/styles/browse/?search_terms=Stylus'; - } else { - return BASE_URL + '/styles/browse/' + category; + //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); + parent.appendChild($create('.lds-spinner', + 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; + 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) { + let message; + if (reason === 404) { + // TODO: i18n message + message = 'No results found'; + } else { + message = 'Error loading search results: ' + reason; + } + dom.error.textContent = message; + dom.error.classList.remove('hidden'); + } + + /** + * Initializes search results container, starts fetching results. + */ + function load() { + if (searchExhausted) { + if (!processedResults.length) { + error(404); + } + return; + } + + setLoading(true); + dom.container.classList.remove('hidden'); + dom.error.classList.add('hidden'); + + let pass = category ? 1 : 0; + category = category || getCategory(); + + search({category}) + .then(function process(results) { + const data = results.data.filter(sameCategory); + pass++; + if (pass === 1 && !data.length) { + category = getCategory({keepTLD: true}); + return search({category, restart: true}).then(process); + } + const numIrrelevant = results.data.length - data.length; + totalResults = results.current_page === 1 ? results.total_entries : totalResults; + totalResults = Math.max(0, totalResults - numIrrelevant); + totalPages = Math.ceil(totalResults / DISPLAY_PER_PAGE); + + if (data.length) { + setLoading(false); + unprocessedResults.push(...data); + processNextResult(); + } else if (numIrrelevant) { + load(); + } else { + return Promise.reject(404); + } + }) + .catch(error); + } + + function loadMoreIfNeeded() { + if (processedResults.length < (displayedPage + 1) * 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); + getStylesSafe({md5Url}).then(([installedStyle]) => { + if (installedStyle) { + totalResults = Math.max(0, totalResults - 1); + } else { + processedResults.push(result); + render(); + } + setTimeout(processNextResult, !installedStyle && DELAY_AFTER_FETCHING_STYLES); + }); + } + + //endregion + //region UI + + function render() { + let start = (displayedPage - 1) * DISPLAY_PER_PAGE; + const end = displayedPage * DISPLAY_PER_PAGE; + + 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 + ) { + slot = slot.nextElementSibling; + plantAt++; + start++; + } + + const plantEntry = entry => { + 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++; + } + + for (const place in dom.nav) { + const nav = dom.nav[place]; + nav._prev.disabled = displayedPage <= 1; + nav._next.disabled = displayedPage >= totalPages; + 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++; + } + + while (dom.list.children.length > maxResults) { + dom.list.lastElementChild.remove(); + } + + if (scrollToFirstResult && dom.list.children[0]) { + scrollToFirstResult = false; + if (!FIREFOX || FIREFOX >= 55) { + setTimeout(() => { + dom.container.scrollIntoView({behavior: 'smooth', block: 'start'}); + }); + } + } + } + + /** + * Constructs and adds the given search result to the popup's Search Results container. + * @param {Object} result The SearchResult object from userstyles.org + */ + 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, + }); + + Object.assign($('.search-result-title', entry), { + onclick: handleEvent.openURLandHide, + href: BASE_URL + result.url + }); + + 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(/[\r\n]{3,}/g, '\n\n'); + Object.assign($('.search-result-description', entry), { + textContent: description, + title: description, + }); + + Object.assign($('.search-result-author-link', entry), { + textContent: result.user.name, + title: result.user.name, + href: BASE_URL + '/users/' + result.user.id, + onclick(event) { + event.stopPropagation(); + handleEvent.openURLandHide.call(this, event); + } + }); + + let ratingClass; + let ratingValue = result.rating; + if (ratingValue === null) { + ratingClass = 'none'; + ratingValue = 'n/a'; + } 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); + } + Object.assign($('.search-result-rating', entry), { + textContent: ratingValue, + className: 'search-result-rating ' + ratingClass + }); + + Object.assign($('.search-result-install-count', entry), { + textContent: result.total_install_count.toLocaleString() + }); + + renderActionButtons(entry); + return entry; + } + + function renderActionButtons(entry) { + const uninstallButton = $('.search-result-uninstall', entry); + uninstallButton.onclick = onUninstallClicked; + + const installButton = $('.search-result-install', entry); + installButton.onclick = onInstallClicked; + + const result = entry._result; + 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); + }; + } + + installButton.classList.toggle('hidden', Boolean(result.installed)); + uninstallButton.classList.toggle('hidden', !result.installed); + } + + function onUninstallClicked(event) { + event.stopPropagation(); + const entry = this.closest('.search-result'); + const result = entry._result; + deleteStyleSafe({id: result.installedStyleId}) + .then(() => { + entry._result.installed = false; + renderActionButtons(entry); + }); + } + + /** Installs the current userstyleSearchResult into Stylus. */ + function onInstallClicked(event) { + event.stopPropagation(); + + const entry = this.closest('.search-result'); + const result = entry._result; + const installButton = $('.search-result-install', entry); + + showSpinner(entry); + installButton.disabled = true; + + // Fetch settings to see if we should display "configure" button + Promise.all([ + fetchStyleJson(result), + fetchStyleSettings(result), + ]) + .then(([style, settings]) => { + // show a 'config-on-homepage' icon in the popup + style.updateUrl += settings.length ? '?' : ''; + // show a 'style installed' tooltip in the manager + style.reason = 'install'; + return saveStyleSafe(style); + }) + .then(savedStyle => { + result.installed = true; + result.installedStyleId = savedStyle.id; + renderActionButtons(entry); + }) + .catch(reason => { + const usoId = result.id; + console.debug('install:saveStyleSafe(usoID:', usoId, ') => [ERROR]: ', reason); + alert('Error while downloading usoID:' + usoId + '\nReason: ' + reason); + }) + .then(() => { + $.remove('.lds-spinner', entry); + installButton.disabled = false; + }); + + function fetchStyleSettings(result) { + return result.style_settings || + fetchStyle(result.id).then(style => { + result.style_settings = style.style_settings || []; + return result.style_settings; + }); + } + } + + //endregion + //region USO API wrapper + + function getSearchPageURL() { + const category = getCategory(); + return BASE_URL + + '/styles/browse/' + + (category in CATEGORY_FALLBACK ? '?search_terms=' : '') + + category; + } + /** * Resolves the Userstyles.org "category" for a given URL. - * @param {String} url The URL to a webpage. - * @returns {Promise} The category for a URL, or the hostname if category is not found. */ - function getCategory(url) { - const u = tryCatch(() => new URL(url)); + function getCategory({keepTLD} = {}) { + const u = tryCatch(() => new URL(tabURL)); if (!u) { - return ''; // Invalid URL + // Invalid URL + return ''; } else if (u.protocol === 'file:') { - return 'file:'; // File page + return 'file:'; } else if (u.protocol === location.protocol) { - return 'STYLUS'; // Stylus page + return 'Stylus'; } else { // Website address, strip TLD & subdomain - let domain = u.hostname.replace(/^www\.|(\.com?)?\.\w+$/g, '').split('.').pop(); - if (domain === 'userstyles') { - domain = 'userstyles.org'; - } - return domain; + const [, category = u.hostname, tld = ''] = u.hostname.match(RX_CATEGORY) || []; + const categoryWithTLD = category + '.' + tld; + const fallback = CATEGORY_FALLBACK[categoryWithTLD]; + return fallback === true && categoryWithTLD || fallback || category + (keepTLD ? tld : ''); } } + function sameCategory(result) { + return result.subcategory && ( + category === result.subcategory || + category === 'Stylus' && /^(chrome|moz)-extension$/.test(result.subcategory) || + category.replace('.', '').toLowerCase() === result.subcategory.replace('.', '').toLowerCase() + ); + } + /** * Fetches the JSON style object from userstyles.org (containing code, sections, updateUrl, etc). * Stores (caches) the JSON within the given usoSearchResult, to avoid unnecessary network usage. @@ -522,37 +583,46 @@ function searchUserstyles() { * @returns {Promise} An object containing info about the style, e.g. name, author, etc. */ function fetchStyle(userstylesId) { - return readCache(userstylesId) - .then(json => json || - download(BASE_URL + '/api/v1/styles/' + userstylesId, { - method: 'GET', - headers: { - 'Content-type': 'application/json', - 'Accept': '*/*' - }, - responseType: 'json', - body: null - }).then(writeCache)); + return readCache(userstylesId).then(json => json || + download(BASE_URL + '/api/v1/styles/' + userstylesId, { + method: 'GET', + headers: { + 'Content-type': 'application/json', + 'Accept': '*/*' + }, + responseType: 'json', + body: null + }).then(json => { + for (const prop of CACHE_EXCEPT_PROPS) { + delete json[prop]; + } + writeCache(json); + return json; + })); } /** * Fetches (and JSON-parses) search results from a userstyles.org search API. - * Automatically sets currentPage and totalPages. + * 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) { - if (totalPages !== undefined && currentPage > totalPages) { + function search({category, restart}) { + if (restart) { + searchCurrentPage = 1; + searchTotalPages = undefined; + } + if (searchTotalPages !== undefined && searchCurrentPage > searchTotalPages) { return Promise.resolve({'data':[]}); } const searchURL = BASE_URL + '/api/v1/styles/subcategory' + '?search=' + encodeURIComponent(category) + - '&page=' + currentPage + + '&page=' + searchCurrentPage + '&country=NA'; - const cacheKey = category + '/' + currentPage; + const cacheKey = category + '/' + searchCurrentPage; return readCache(cacheKey) .then(json => json || @@ -570,55 +640,84 @@ function searchUserstyles() { return json; })) .then(json => { - currentPage = json.current_page + 1; - totalPages = json.total_pages; - exhausted = (currentPage > totalPages); + searchCurrentPage = json.current_page + 1; + searchTotalPages = json.total_pages; + searchExhausted = (searchCurrentPage > searchTotalPages); return json; }).catch(reason => { - exhausted = true; + searchExhausted = true; return Promise.reject(reason); }); } + //endregion + //region Cache + function readCache(id) { - return BG.chromeLocal.getLZValue(CACHE_PREFIX + id).then(data => { - if (!data || Date.now() - data.cacheWriteDate < CACHE_DURATION) { - return data; + const key = CACHE_PREFIX + id; + return BG.chromeLocal.getValue(key).then(item => { + if (!cacheItemExpired(item)) { + return tryJSONparse(BG.LZString.decompressFromUTF16(item.payload)); + } else if (item) { + chrome.storage.local.remove(key); } - BG.chromeLocal.remove(CACHE_PREFIX + id); }); } - function writeCache(data) { - debounce(cleanupCache, 10e3); - data.cacheWriteDate = Date.now(); - return BG.chromeLocal.setLZValue(CACHE_PREFIX + data.id, data) - .then(() => data); + function writeCache(data, debounced) { + if (!debounced) { + debounce(writeCache, 100, data, true); + return data; + } else { + debounce(cleanupCache, CACHE_CLEANUP_THROTTLE); + return BG.chromeLocal.setValue(CACHE_PREFIX + data.id, { + payload: BG.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() { - new Promise(resolve => - chrome.storage.local.getBytesInUse && - chrome.storage.local.getBytesInUse(null, resolve) || - 1e9 - ) - .then(size => size > 1e6 || Promise.reject()) - .then(() => BG.chromeLocal.getValue(CACHE_PREFIX + 'Cleanup')) - .then((lastCleanup = 0) => - Date.now() - lastCleanup > CACHE_DURATION && - chrome.storage.local.get(null, storage => { - const expired = []; - for (const key in storage) { - if (key.startsWith(CACHE_PREFIX) && - Date.now() - storage[key].cacheWriteDate > CACHE_DURATION) { - expired.push(key); - } + if (!chrome.storage.local.getBytesInUse) { + chrome.storage.local.get(null, cleanupCacheInternal); + } else { + chrome.storage.local.getBytesInUse(null, size => { + if (size > CACHE_SIZE) { + chrome.storage.local.get(null, cleanupCacheInternal); } - if (expired.length) { - chrome.storage.local.remove(expired); - } - BG.chromeLocal.setValue(CACHE_PREFIX + 'Cleanup', Date.now()); - })) - .catch(ignoreChromeError); + ignoreChromeError(); + }); + } } -} + + 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) { + chrome.storage.local.remove(toRemove.map(item => item.key), ignoreChromeError); + } + ignoreChromeError(); + } + + //endregion + + function objectPick(obj, keys) { + const result = {}; + for (const k in obj) { + if (keys.includes(k)) { + result[k] = obj[k]; + } + } + return result; + } +});