intermediate tweaks and fixes for inline search

* caching of search results and downloaded style info for one day
* no prefetching of styles
* only the next search results page is prefetched in unattended fashion
* the "configure" button is shown only after installation
* join the code in one closure, regroup and simplify some functions
This commit is contained in:
tophf 2017-12-11 05:20:59 +03:00
parent adabb9aeff
commit f4bfeea5a6
3 changed files with 634 additions and 520 deletions

View File

@ -118,6 +118,18 @@
</div> </div>
</template> </template>
<template data-id="searchNav">
<div>
<button data-role="prev" i18n-title="paginationPrevious" disabled></button>
<label>
<span data-role="page" i18n-title="paginationCurrent">-</span>
/
<span data-role="total" i18n-title="paginationTotal">-</span>
</label>
<button data-role="next" i18n-title="paginationNext" disabled></button>
</div>
</template>
<template data-id="emptySearchResult"> <template data-id="emptySearchResult">
<div class="search-result-empty"></div> <div class="search-result-empty"></div>
</template> </template>
@ -182,7 +194,6 @@
</label> </label>
</span> </span>
</div> </div>
<div id="search-results-error" class="hidden"></div>
<div id="write-style"> <div id="write-style">
<span id="write-style-for" i18n-text="writeStyleFor"></span> <span id="write-style-for" i18n-text="writeStyleFor"></span>
</div> </div>
@ -200,16 +211,10 @@
</div> </div>
<div id="search-results" class="hidden"> <div id="search-results" class="hidden">
<div id="search-results-nav-top"></div>
<div id="search-results-error" class="hidden"></div>
<div id="search-results-list"></div> <div id="search-results-list"></div>
<div id="search-results-nav"> <div id="search-results-nav-bottom"></div>
<button id="search-results-nav-prev" i18n-title="paginationPrevious" disabled>&#8592;</button>
<label>
<span id="search-results-nav-current-page" i18n-title="paginationCurrent">-</span>
/
<span id="search-results-nav-total-pages" i18n-title="paginationTotal">-</span>
</label>
<button id="search-results-nav-next" i18n-title="paginationNext" disabled>&#8594;</button>
</div>
</div> </div>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none !important;"> <svg xmlns="http://www.w3.org/2000/svg" style="display: none !important;">

View File

@ -12,7 +12,7 @@ body.search-results-shown {
font-weight: bold; font-weight: bold;
padding: 5px; padding: 5px;
text-align: center; text-align: center;
margin-right: 20px; margin: -.5rem 0 1rem;
} }
#search-results-list { #search-results-list {
@ -20,7 +20,8 @@ body.search-results-shown {
min-height: 200px; min-height: 200px;
} }
.search-result, .search-result-empty { .search-result,
.search-result-empty {
position: relative; position: relative;
padding: .75rem; padding: .75rem;
min-height: 160px; min-height: 160px;
@ -41,6 +42,11 @@ body.search-results-shown {
transform: scale(.75); transform: scale(.75);
} }
.search-result-fadein {
animation: fadein 1s;
animation-fill-mode: both;
}
.search-result-screenshot { .search-result-screenshot {
height: 140px; height: 140px;
width: 100%; width: 100%;
@ -182,22 +188,26 @@ body.search-results-shown {
margin: 3px; margin: 3px;
} }
#search-results-nav { #search-results-nav-top,
#search-results-nav-bottom {
flex-direction: row; flex-direction: row;
text-align: center; text-align: center;
word-break: keep-all; word-break: keep-all;
opacity: 1.0; margin-bottom: 1rem;
margin-bottom: 10px;
} }
#search-results-nav label { #search-results-nav-top button,
width: 40px; #search-results-nav-bottom button {
display: inline-block; -webkit-appearance: none;
word-break: keep-all; background: none;
border: none;
padding: .25rem 1rem;
margin: 0 .5rem;
} }
#search-results-nav button { #search-results-nav-top button:hover,
text-align: center; #search-results-nav-bottom button:hover {
text-shadow: 0 0 2px currentColor;
} }
#find-styles-inline-group label { #find-styles-inline-group label {
@ -212,7 +222,6 @@ body.search-results-shown {
-ms-user-select: none; -ms-user-select: none;
user-select: none; user-select: none;
pointer-events: none; pointer-events: none;
opacity: 0;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -220,12 +229,13 @@ body.search-results-shown {
width: 200px; /* don't change! use "transform: scale(.75)" */ width: 200px; /* don't change! use "transform: scale(.75)" */
height: 200px; /* don't change! use "transform: scale(.75)" */ height: 200px; /* don't change! use "transform: scale(.75)" */
margin: auto; margin: auto;
animation: fadein 1s; animation: lds-spinner 1s reverse;
animation-fill-mode: both;
} }
@keyframes lds-spinner { @keyframes lds-spinner {
0% { 0% {
opacity: .5; opacity: .25;
} }
100% { 100% {

View File

@ -8,60 +8,126 @@ window.addEventListener('showStyles:done', function _() {
return; 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 <string>: 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'), { Object.assign($('#find-styles-link'), {
href: searchUserstyles().getSearchPageURL(tabURL), href: getSearchPageURL(tabURL),
onclick(event) { onclick(event) {
if (!prefs.get('popup.findStylesInline')) { if (!prefs.get('popup.findStylesInline') || dom.container) {
handleEvent.openURLandHide.call(this, event); handleEvent.openURLandHide.call(this, event);
return; return;
} }
$('#find-styles-inline-group').classList.add('hidden'); $('#find-styles-inline-group').classList.add('hidden');
this.textContent = this.title;
this.title = '';
const searchResults = searchResultsController(); init();
searchResults.init(); load();
searchResults.load();
event.preventDefault(); event.preventDefault();
}, },
}); });
/** return;
* 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-';
let scrollToFirstResult = true;
let loading = false;
let category; // Category for the active tab's URL.
let currentDisplayedPage = 1; // Current page number in popup.html
return {init, load, next, prev};
function init() { function init() {
$('#search-results-nav-prev').onclick = prev; document.body.classList.add(BODY_CLASS);
$('#search-results-nav-next').onclick = next;
document.body.classList.add('search-results-shown'); dom.container = $('#search-results');
dom.error = $('#search-results-error');
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;
}
}
dom.list = $('#search-results-list');
addEventListener('styleDeleted', ({detail}) => { addEventListener('styleDeleted', ({detail}) => {
const entries = [...$('#search-results-list').children]; const entries = [...dom.list.children];
const entry = entries.find(el => el._result.installedStyleId === detail.id); const entry = entries.find(el => el._result.installedStyleId === detail.id);
if (entry) { if (entry) {
entry._result.installed = false; entry._result.installed = false;
renderActionButtons(entry); renderActionButtons(entry);
} }
}); });
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);
} }
});
}
//endregion
//region Loader
/** /**
* Sets loading status of search results. * Sets loading status of search results.
@ -70,14 +136,8 @@ window.addEventListener('showStyles:done', function _() {
function setLoading(isLoading) { function setLoading(isLoading) {
if (loading !== isLoading) { if (loading !== isLoading) {
loading = isLoading; loading = isLoading;
// Refresh elements that depend on `loading` state.
render(); // Refresh elements that depend on `loading` state. render();
if (isLoading) {
showSpinner('#search-results');
} else {
$.remove('#search-results > .lds-spinner');
}
} }
} }
@ -87,75 +147,13 @@ window.addEventListener('showStyles:done', function _() {
new Array(12).fill($create('div')).map(e => e.cloneNode()))); new Array(12).fill($create('div')).map(e => e.cloneNode())));
} }
function render() { /** Increments displayedPage and loads results. */
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();
}
}
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;
}
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() { function next() {
currentDisplayedPage += 1; if (loading) {
debounce(next, 100);
return;
}
displayedPage += 1;
scrollToFirstResult = true; scrollToFirstResult = true;
render(); render();
loadMoreIfNeeded(); loadMoreIfNeeded();
@ -163,7 +161,11 @@ window.addEventListener('showStyles:done', function _() {
/** Decrements currentPage and loads results. */ /** Decrements currentPage and loads results. */
function prev() { function prev() {
currentDisplayedPage = Math.max(1, currentDisplayedPage - 1); if (loading) {
debounce(next, 100);
return;
}
displayedPage = Math.max(1, displayedPage - 1);
scrollToFirstResult = true; scrollToFirstResult = true;
render(); render();
} }
@ -180,43 +182,58 @@ window.addEventListener('showStyles:done', function _() {
} else { } else {
message = 'Error loading search results: ' + reason; message = 'Error loading search results: ' + reason;
} }
$('#search-results').classList.add('hidden'); dom.error.textContent = message;
$('#search-results-error').textContent = message; dom.error.classList.remove('hidden');
$('#search-results-error').classList.remove('hidden');
} }
/** /**
* Initializes search results container, starts fetching results. * Initializes search results container, starts fetching results.
*/ */
function load() { function load() {
if (unprocessedResults.length > 0) { if (searchExhausted) {
// Keep processing search results if there are any. if (!processedResults.length) {
processNextResult();
} else if (searchAPI.isExhausted()) {
// Stop if no more search results.
if (processedResults.length === 0) {
// No results
error(404); error(404);
} }
} else { return;
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);
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(); processNextResult();
} else if (numIrrelevant) {
load();
} else {
return Promise.reject(404);
}
}) })
.catch(error); .catch(error);
} }
function loadMoreIfNeeded() {
if (processedResults.length < (displayedPage + 1) * DISPLAY_PER_PAGE) {
setTimeout(load, DELAY_BEFORE_SEARCHING_STYLES);
}
} }
/** /**
@ -226,44 +243,94 @@ window.addEventListener('showStyles:done', function _() {
* Recurses until shouldLoadMore() is false. * Recurses until shouldLoadMore() is false.
*/ */
function processNextResult() { function processNextResult() {
if (!shouldLoadMore()) { const result = unprocessedResults.shift();
return; if (!result) {
}
if (unprocessedResults.length === 0) {
// No more results to process
loadMoreIfNeeded(); loadMoreIfNeeded();
return; return;
} }
const md5Url = UPDATE_URL.replace('%', result.id);
// Process the next result in the queue. getStylesSafe({md5Url}).then(([installedStyle]) => {
const nextResult = unprocessedResults.shift();
getStylesSafe({md5Url: UPDATE_URL.replace('%', nextResult.id)})
.then(([installedStyle]) => {
if (installedStyle) { if (installedStyle) {
setTimeout(processNextResult); totalResults = Math.max(0, totalResults - 1);
return; } else {
} processedResults.push(result);
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(); render();
setTimeout(processNextResult, DELAY_AFTER_FETCHING_STYLES); }
}) setTimeout(processNextResult, !installedStyle && DELAY_AFTER_FETCHING_STYLES);
.catch(reason => {
console.log('processNextResult(', nextResult.id, ') => [ERROR]: ', reason);
setTimeout(processNextResult, 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. * Constructs and adds the given search result to the popup's Search Results container.
@ -287,26 +354,32 @@ window.addEventListener('showStyles:done', function _() {
const entry = template.searchResult.cloneNode(true); const entry = template.searchResult.cloneNode(true);
Object.assign(entry, { Object.assign(entry, {
_result: result, _result: result,
id: ENTRY_ID_PREFIX + result.id, id: RESULT_ID_PREFIX + result.id,
}); });
Object.assign($('.search-result-title', entry), { Object.assign($('.search-result-title', entry), {
onclick: handleEvent.openURLandHide, onclick: handleEvent.openURLandHide,
href: searchAPI.BASE_URL + result.url href: BASE_URL + result.url
}); });
const displayedName = result.name.length < 300 ? result.name : result.name.slice(0, 300) + '...'; const displayedName = result.name.length < 300 ? result.name : result.name.slice(0, 300) + '...';
$('.search-result-title span', entry).textContent = tWordBreak(displayedName); $('.search-result-title span', entry).textContent = tWordBreak(displayedName);
const screenshot = $('.search-result-screenshot', entry); const screenshot = $('.search-result-screenshot', entry);
let screenshotUrl = result.screenshot_url; let url = result.screenshot_url;
if (screenshotUrl === null) { if (!url) {
screenshotUrl = BLANK_PIXEL_DATA; url = BLANK_PIXEL_DATA;
screenshot.classList.add('no-screenshot'); screenshot.classList.add('no-screenshot');
} else if (RegExp(/^[0-9]*_after.(jpe?g|png|gif)$/i).test(screenshotUrl)) { } else if (/^[0-9]*_after.(jpe?g|png|gif)$/i.test(url)) {
screenshotUrl = searchAPI.BASE_URL + '/style_screenshot_thumbnails/' + screenshotUrl; 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');
};
} }
screenshot.src = screenshotUrl;
const description = result.description const description = result.description
.replace(/<[^>]*>/g, '') .replace(/<[^>]*>/g, '')
@ -319,7 +392,7 @@ window.addEventListener('showStyles:done', function _() {
Object.assign($('.search-result-author-link', entry), { Object.assign($('.search-result-author-link', entry), {
textContent: result.user.name, textContent: result.user.name,
title: result.user.name, title: result.user.name,
href: searchAPI.BASE_URL + '/users/' + result.user.id, href: BASE_URL + '/users/' + result.user.id,
onclick(event) { onclick(event) {
event.stopPropagation(); event.stopPropagation();
handleEvent.openURLandHide.call(this, event); handleEvent.openURLandHide.call(this, event);
@ -349,9 +422,8 @@ window.addEventListener('showStyles:done', function _() {
Object.assign($('.search-result-install-count', entry), { Object.assign($('.search-result-install-count', entry), {
textContent: result.total_install_count.toLocaleString() textContent: result.total_install_count.toLocaleString()
}); });
renderActionButtons(entry);
$('#search-results-list').appendChild(entry); renderActionButtons(entry);
return entry; return entry;
} }
@ -363,13 +435,13 @@ window.addEventListener('showStyles:done', function _() {
installButton.onclick = onInstallClicked; installButton.onclick = onInstallClicked;
const result = entry._result; const result = entry._result;
if (result.style_settings.length > 0) { if ((result.style_settings || []).length > 0) {
// Style has customizations // Style has customizations
installButton.classList.add('customize'); installButton.classList.add('customize');
uninstallButton.classList.add('customize'); uninstallButton.classList.add('customize');
const customizeButton = $('.search-result-customize', entry); const customizeButton = $('.search-result-customize', entry);
customizeButton.dataset.href = searchAPI.BASE_URL + result.url; customizeButton.dataset.href = BASE_URL + result.url;
customizeButton.dataset.sendMessage = JSON.stringify({method: 'openSettings'}); customizeButton.dataset.sendMessage = JSON.stringify({method: 'openSettings'});
customizeButton.classList.remove('hidden'); customizeButton.classList.remove('hidden');
customizeButton.onclick = function (event) { customizeButton.onclick = function (event) {
@ -404,91 +476,80 @@ window.addEventListener('showStyles:done', function _() {
showSpinner(entry); showSpinner(entry);
installButton.disabled = true; installButton.disabled = true;
// Fetch .JSON style // Fetch settings to see if we should display "configure" button
searchAPI.fetchStyleJson(result) Promise.all([
.then(userstyleJson => { fetchStyleJson(result),
userstyleJson.reason = 'install'; fetchStyleSettings(result),
if (result.style_settings.length) { ])
// this will make the popup show a config-on-homepage icon .then(([style, settings]) => {
userstyleJson.updateUrl += '?'; // show a 'config-on-homepage' icon in the popup
} style.updateUrl += settings.length ? '?' : '';
// Install style // show a 'style installed' tooltip in the manager
saveStyleSafe(userstyleJson) style.reason = 'install';
return saveStyleSafe(style);
})
.then(savedStyle => { .then(savedStyle => {
// Success: Store installed styleId, mark as installed.
result.installed = true; result.installed = true;
result.installedStyleId = savedStyle.id; result.installedStyleId = savedStyle.id;
renderActionButtons(entry); renderActionButtons(entry);
$.remove('.lds-spinner', entry);
installButton.disabled = false;
});
}) })
.catch(reason => { .catch(reason => {
const usoId = result.id; const usoId = result.id;
console.log('install:saveStyleSafe(usoID:', usoId, ') => [ERROR]: ', reason); console.debug('install:saveStyleSafe(usoID:', usoId, ') => [ERROR]: ', reason);
alert('Error while downloading usoID:' + usoId + '\nReason: ' + reason); alert('Error while downloading usoID:' + usoId + '\nReason: ' + reason);
})
.then(() => {
$.remove('.lds-spinner', entry); $.remove('.lds-spinner', entry);
installButton.disabled = false; installButton.disabled = false;
}); });
return true;
}
} // End of searchResultsController function fetchStyleSettings(result) {
return result.style_settings ||
fetchStyle(result.id).then(style => {
result.style_settings = style.style_settings || [];
return result.style_settings;
}); });
}
/**
* 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) { //endregion
const category = getCategory(url); //region USO API wrapper
if (category === 'STYLUS') {
return BASE_URL + '/styles/browse/?search_terms=Stylus'; function getSearchPageURL() {
} else { const category = getCategory();
return BASE_URL + '/styles/browse/' + category; return BASE_URL +
} '/styles/browse/' +
(category in CATEGORY_FALLBACK ? '?search_terms=' : '') +
category;
} }
/** /**
* Resolves the Userstyles.org "category" for a given URL. * Resolves the Userstyles.org "category" for a given URL.
* @param {String} url The URL to a webpage.
* @returns {Promise<String>} The category for a URL, or the hostname if category is not found.
*/ */
function getCategory(url) { function getCategory({keepTLD} = {}) {
const u = tryCatch(() => new URL(url)); const u = tryCatch(() => new URL(tabURL));
if (!u) { if (!u) {
return ''; // Invalid URL // Invalid URL
return '';
} else if (u.protocol === 'file:') { } else if (u.protocol === 'file:') {
return 'file:'; // File page return 'file:';
} else if (u.protocol === location.protocol) { } else if (u.protocol === location.protocol) {
return 'STYLUS'; // Stylus page return 'Stylus';
} else { } else {
// Website address, strip TLD & subdomain // Website address, strip TLD & subdomain
let domain = u.hostname.replace(/^www\.|(\.com?)?\.\w+$/g, '').split('.').pop(); const [, category = u.hostname, tld = ''] = u.hostname.match(RX_CATEGORY) || [];
if (domain === 'userstyles') { const categoryWithTLD = category + '.' + tld;
domain = 'userstyles.org'; const fallback = CATEGORY_FALLBACK[categoryWithTLD];
return fallback === true && categoryWithTLD || fallback || category + (keepTLD ? tld : '');
} }
return domain;
} }
function sameCategory(result) {
return result.subcategory && (
category === result.subcategory ||
category === 'Stylus' && /^(chrome|moz)-extension$/.test(result.subcategory) ||
category.replace('.', '').toLowerCase() === result.subcategory.replace('.', '').toLowerCase()
);
} }
/** /**
@ -522,8 +583,7 @@ function searchUserstyles() {
* @returns {Promise<Object>} An object containing info about the style, e.g. name, author, etc. * @returns {Promise<Object>} An object containing info about the style, e.g. name, author, etc.
*/ */
function fetchStyle(userstylesId) { function fetchStyle(userstylesId) {
return readCache(userstylesId) return readCache(userstylesId).then(json => json ||
.then(json => json ||
download(BASE_URL + '/api/v1/styles/' + userstylesId, { download(BASE_URL + '/api/v1/styles/' + userstylesId, {
method: 'GET', method: 'GET',
headers: { headers: {
@ -532,27 +592,37 @@ function searchUserstyles() {
}, },
responseType: 'json', responseType: 'json',
body: null body: null
}).then(writeCache)); }).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. * 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. * @param {string} category The usrestyles.org "category" (subcategory) OR a any search string.
* @return {Object} Response object from userstyles.org * @return {Object} Response object from userstyles.org
*/ */
function search(category) { function search({category, restart}) {
if (totalPages !== undefined && currentPage > totalPages) { if (restart) {
searchCurrentPage = 1;
searchTotalPages = undefined;
}
if (searchTotalPages !== undefined && searchCurrentPage > searchTotalPages) {
return Promise.resolve({'data':[]}); return Promise.resolve({'data':[]});
} }
const searchURL = BASE_URL + const searchURL = BASE_URL +
'/api/v1/styles/subcategory' + '/api/v1/styles/subcategory' +
'?search=' + encodeURIComponent(category) + '?search=' + encodeURIComponent(category) +
'&page=' + currentPage + '&page=' + searchCurrentPage +
'&country=NA'; '&country=NA';
const cacheKey = category + '/' + currentPage; const cacheKey = category + '/' + searchCurrentPage;
return readCache(cacheKey) return readCache(cacheKey)
.then(json => json || .then(json => json ||
@ -570,55 +640,84 @@ function searchUserstyles() {
return json; return json;
})) }))
.then(json => { .then(json => {
currentPage = json.current_page + 1; searchCurrentPage = json.current_page + 1;
totalPages = json.total_pages; searchTotalPages = json.total_pages;
exhausted = (currentPage > totalPages); searchExhausted = (searchCurrentPage > searchTotalPages);
return json; return json;
}).catch(reason => { }).catch(reason => {
exhausted = true; searchExhausted = true;
return Promise.reject(reason); return Promise.reject(reason);
}); });
} }
//endregion
//region Cache
function readCache(id) { function readCache(id) {
return BG.chromeLocal.getLZValue(CACHE_PREFIX + id).then(data => { const key = CACHE_PREFIX + id;
if (!data || Date.now() - data.cacheWriteDate < CACHE_DURATION) { return BG.chromeLocal.getValue(key).then(item => {
return data; 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) { function writeCache(data, debounced) {
debounce(cleanupCache, 10e3); if (!debounced) {
data.cacheWriteDate = Date.now(); debounce(writeCache, 100, data, true);
return BG.chromeLocal.setLZValue(CACHE_PREFIX + data.id, data) return data;
.then(() => 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() { function cleanupCache() {
new Promise(resolve => if (!chrome.storage.local.getBytesInUse) {
chrome.storage.local.getBytesInUse && chrome.storage.local.get(null, cleanupCacheInternal);
chrome.storage.local.getBytesInUse(null, resolve) || } else {
1e9 chrome.storage.local.getBytesInUse(null, size => {
) if (size > CACHE_SIZE) {
.then(size => size > 1e6 || Promise.reject()) chrome.storage.local.get(null, cleanupCacheInternal);
.then(() => BG.chromeLocal.getValue(CACHE_PREFIX + 'Cleanup')) }
.then((lastCleanup = 0) => ignoreChromeError();
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 (expired.length) {
chrome.storage.local.remove(expired); 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);
} }
BG.chromeLocal.setValue(CACHE_PREFIX + 'Cleanup', Date.now()); ignoreChromeError();
})) }
.catch(ignoreChromeError);
//endregion
function objectPick(obj, keys) {
const result = {};
for (const k in obj) {
if (keys.includes(k)) {
result[k] = obj[k];
} }
} }
return result;
}
});