stylus/popup/search.js
2022-10-12 20:04:45 +03:00

620 lines
20 KiB
JavaScript

/* global $ $$ $create $remove showSpinner toggleDataset */// dom.js
/* global $entry tabURL */// popup.js
/* global API */// msg.js
/* global Events */
/* global FIREFOX URLS debounce download isEmptyObj stringAsRegExp tryRegExp tryURL */// toolbox.js
/* global prefs */
/* global t */// localization.js
'use strict';
(() => {
const RESULT_ID_PREFIX = t.template.searchResult.className + '-';
const RESULT_SEL = '.' + t.template.searchResult.className;
const INDEX_URL = URLS.usoArchiveRaw[0] + 'search-index.json';
const USW_INDEX_URL = URLS.usw + 'api/index/uso-format';
const USW_ICON = $create('img', {
src: `${URLS.usw}favicon.ico`,
title: URLS.usw,
});
const STYLUS_CATEGORY = 'chrome-extension';
const PAGE_LENGTH = 100;
// 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, later replaced with string like `uso-123`
* @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
*
* @prop {number} _styleId - installed style id
* @prop {boolean} _styleVars - installed style has vars
* @prop {number} _year
*/
/** @type IndexEntry[] */
let results, resultsAllYears;
/** @type IndexEntry[] */
let index;
let category = '';
/** @type RegExp */
let rxCategory;
let searchGlobals = $('#search-globals').checked;
/** @type {RegExp[]} */
let query = [];
let order = prefs.get('popup.findSort');
let scrollToFirstResult = true;
let displayedPage = 1;
let totalPages = 1;
let ready;
/** @type {?Promise} */
let indexing;
let imgType = '.jpg';
// detect WebP support
$create('img', {
src: 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=',
onload: () => (imgType = '.webp'),
});
/** @returns {{result: IndexEntry, entry: HTMLElement}} */
const $resultEntry = el => {
const entry = el.closest(RESULT_SEL);
return {entry, result: entry && entry._result};
};
const rid2id = rid => rid.split('-')[1];
Events.searchInline = () => {
calcCategory();
ready = start();
};
Events.searchSite = event => {
// use a less specific category if the inline search wasn't used yet
if (!category) calcCategory({retry: 1});
const add = (prefix, str) => str ? prefix + str : '';
const where = event.detail;
const q = encodeURIComponent($('#search-query').value.trim());
const catQ = category + add('+', q);
const href =
where === 'uso' &&
`${URLS.uso}styles/browse${q ? `?search_terms=${catQ}` : `/${category}`}` ||
where === 'usoa' &&
`${URLS.usoArchive}browse/styles?search=%23${catQ}` ||
where === 'usw' &&
`${URLS.usw}search?q=${catQ}` ||
where === 'gf' &&
'https://greasyfork.org/' + ($.root.lang.split('-')[0] || 'en') +
`/scripts/by-site/${tryURL(tabURL).hostname.replace(/^www\./, '')}?language=css${add('&q=', q)}`;
Events.openURLandHide.call({href}, event);
};
$('#search-globals').onchange = function () {
searchGlobals = this.checked;
ready = ready.then(start);
};
$('#search-query').oninput = function () {
query = [];
const text = this.value.trim();
for (let re = /(")(.+?)"|((\/)?(\S+?)(\/\w*)?)(?=\s|$)/g, m; (m = re.exec(text));) {
const [
all,
q, qt,
t, rx1 = '', rx, rx2 = '',
] = m;
query.push(rx1 && rx2 && tryRegExp(rx, rx2.slice(1)) ||
stringAsRegExp(q ? qt : t, all === all.toLocaleLowerCase() ? 'i' : ''));
}
if (category === STYLUS_CATEGORY) {
query.push(/\bStylus\b/);
}
ready = ready.then(start);
};
$('#search-years').onchange = () => {
ready = ready.then(() => start({keepYears: true}));
};
$('#search-order').value = order;
$('#search-order').onchange = function () {
order = this.value;
prefs.set('popup.findSort', order);
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']) {
const nav = $(`.search-results-nav[data-type="${place}"]`);
nav.appendChild(t.template.searchNav.cloneNode(true));
dom.nav[place] = nav;
for (const child of $$('[data-type]', nav)) {
const type = child.dataset.type;
child.onclick = navOnClick[type];
nav['_' + type] = child;
}
}
if (FIREFOX) {
let lastShift;
window.on('resize', () => {
const scrollbarWidth = window.innerWidth - document.scrollingElement.clientWidth;
const shift = document.body.getBoundingClientRect().left;
if (!scrollbarWidth || shift === lastShift) return;
lastShift = shift;
document.body.style.setProperty('padding',
`0 ${scrollbarWidth + shift}px 0 ${-shift}px`, 'important');
}, {passive: true});
}
window.on('styleDeleted', ({detail: {style: {id}}}) => {
restoreScrollPosition();
const r = results.find(r => r._styleId === id);
if (r) {
if (r.f) API.uso.pingback(rid2id(r.i), false);
delete r._styleId;
renderActionButtons(r.i);
}
});
window.on('styleAdded', async ({detail: {style}}) => {
restoreScrollPosition();
const ri = await API.styles.getRemoteInfo(style.id);
const r = ri && results.find(r => ri[0] === r.i);
if (r) {
r._styleId = style.id;
r._styleVars = ri[1];
renderActionButtons(ri[0]);
}
});
function next() {
displayedPage = Math.min(totalPages, displayedPage + 1);
scrollToFirstResult = true;
render();
}
function prev() {
displayedPage = Math.max(1, displayedPage - 1);
scrollToFirstResult = true;
render();
}
function error(reason) {
dom.error.textContent = reason;
dom.error.hidden = false;
dom.list.hidden = true;
if (dom.error.getBoundingClientRect().bottom < 0) {
dom.error.scrollIntoView(true);
}
}
function errorIfNoneFound() {
if (!results.length && !$('#search-query').value) {
return indexing ? indexing.then(errorIfNoneFound)
: Promise.reject(t('searchResultNoneFound'));
}
}
async function start({keepYears} = {}) {
resetUI.timer = resetUI.timer || setTimeout(resetUI, 500);
try {
results = [];
for (let retry = 0; !results.length && retry <= 2; retry++) {
results = await search({retry});
}
if (results.length) {
const info = await API.styles.getRemoteInfo();
for (const r of results) {
[r._styleId, r._styleVars] = info[r.i] || [];
}
}
if (!keepYears) resultsAllYears = results;
renderYears();
render();
dom.list.hidden = !results.length;
await errorIfNoneFound();
resetUI();
} catch (reason) {
error(reason);
}
clearTimeout(resetUI.timer);
resetUI.timer = 0;
}
function resetUI() {
$.rootCL.add('search-results-shown');
dom.container.hidden = false;
dom.list.hidden = false;
dom.error.hidden = true;
}
function renderYears() {
const SCALE = 1000;
const BASE = new Date(0).getFullYear(); // 1970
const DAYS = 365.2425;
const DAY = 24 * 3600e3;
const YEAR = DAYS * DAY / SCALE;
const SAFETY = 1 / DAYS; // 1 day safety margin: recheck Jan 1 and Dec 31
const years = [];
for (const r of resultsAllYears) {
let y = r._year;
if (!y) {
y = r.u / YEAR + BASE;
r._year = y = Math.abs(y % 1 - 1) <= SAFETY
? new Date(r.u * SCALE).getFullYear()
: y | 0;
}
years[y] = (years[y] || 0) + 1;
}
const texts = years.reduceRight((res, num, y) => res.push(`${y} (${num})`) && res, []);
const selects = $$('#search-years select');
selects.forEach((sel, selNum) => {
if (texts.length !== sel.length || texts.some((v, i) => v !== sel[i].text)) {
const {value} = sel;
sel.textContent = '';
sel.append(...texts.map(t => $create('option', {value: t.split(' ')[0]}, t)));
sel.value = value in years ? value : (sel[`${selNum ? 'first' : 'last'}Child`] || {}).value;
}
});
const [y1, y2] = selects.map(el => Number(el.value)).sort();
results = y1 ? resultsAllYears.filter(r => (r = r._year) >= y1 && r <= y2) : resultsAllYears;
}
function render() {
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 < PAGE_LENGTH &&
slot && slot.id === RESULT_ID_PREFIX + (results[start] || {}).i
) {
slot = slot.nextElementSibling;
plantAt++;
start++;
}
// 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);
}
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) {
debounce(doScrollToFirstResult);
}
// navigation
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;
nav._num.textContent = results.length;
}
}
function doScrollToFirstResult() {
if (dom.container.scrollHeight > window.innerHeight * 2) {
scrollToFirstResult = false;
dom.container.scrollIntoView(true);
}
}
/**
* @param {IndexEntry} result
* @returns {Node}
*/
function createSearchResultNode(result) {
const entry = t.template.searchResult.cloneNode(true);
const {
i: rid,
n: name,
r: rating,
u: updateTime,
w: weeklyInstalls,
t: totalInstalls,
ai: authorId,
an: author,
sa: shotArchived,
sn: shot,
f: fmt,
} = entry._result = result;
const id = rid2id(rid);
entry.id = RESULT_ID_PREFIX + rid;
// title
Object.assign($('.search-result-title', entry), {
onclick: Events.openURLandHide,
href: `${fmt ? URLS.usoArchive : URLS.usw}style/${id}`,
});
if (!fmt) $('.search-result-title', entry).prepend(USW_ICON.cloneNode(true));
$('.search-result-title span', entry).textContent =
t.breakWord(name.length < 300 ? name : name.slice(0, 300) + '...');
// screenshot
const elShot = $('.search-result-screenshot', entry);
let shotSrc;
if (!fmt) {
shotSrc = /^https?:/i.test(shot) && shot.replace(/\.jpg$/, imgType);
} else {
elShot._src = URLS.uso + `auto_style_screenshots/${id}${USO_AUTO_PIC_SUFFIX}`;
shotSrc = shot && !shot.endsWith(USO_AUTO_PIC_SUFFIX)
? `${shotArchived ? URLS.usoArchiveRaw[0] : URLS.uso + 'style_'}screenshots/${shot}`
: elShot._src;
}
if (shotSrc) {
elShot._entry = entry;
elShot.src = shotSrc;
elShot.onerror = fixScreenshot;
} else {
elShot.src = BLANK_PIXEL;
entry.dataset.noImage = '';
}
// author
Object.assign($('[data-type="author"] a', entry), {
textContent: author,
title: author,
href: !fmt ? `${URLS.usw}user/${encodeURIComponent(author)}` :
`${URLS.usoArchive}browse/styles?search=%40${authorId}`,
onclick: Events.openURLandHide,
});
// 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: updateTime * 1000,
textContent: t.formatDate(updateTime * 1000),
});
// totals
$('[data-type="weekly"] dd', entry).textContent = formatNumber(weeklyInstalls);
$('[data-type="total"] dd', entry).textContent = formatNumber(totalInstalls);
renderActionButtons(entry);
return entry;
}
function formatNumber(num) {
return (
num > 1e9 ? (num / 1e9).toFixed(1) + 'B' :
num > 10e6 ? (num / 1e6).toFixed(0) + 'M' :
num > 1e6 ? (num / 1e6).toFixed(1) + 'M' :
num > 10e3 ? (num / 1e3).toFixed(0) + 'k' :
num > 1e3 ? (num / 1e3).toFixed(1) + 'k' :
num
);
}
function fixScreenshot() {
const {_src} = this;
if (_src && _src !== this.src) {
this.src = _src;
delete this._src;
} else {
this.onerror = null;
this.src = BLANK_PIXEL;
this._entry.dataset.noImage = '';
renderActionButtons(this._entry);
}
}
function renderActionButtons(entry) {
if (typeof entry !== 'object') {
entry = $('#' + RESULT_ID_PREFIX + entry);
}
if (!entry) return;
const result = entry._result;
const installedId = result._styleId;
const isInstalled = installedId > 0; // must be boolean for comparisons below
const status = $('.search-result-status', entry).textContent =
isInstalled ? t('clickToUninstall') :
entry.dataset.noImage != null ? t('installButton') :
'';
const notMatching = isInstalled && !$entry(installedId);
if (notMatching !== entry.classList.contains('not-matching')) {
entry.classList.toggle('not-matching');
if (notMatching) {
entry.prepend(t.template.searchResultNotMatching.cloneNode(true));
} else {
entry.firstElementChild.remove();
}
}
Object.assign($('.search-result-screenshot', entry), {
onclick: isInstalled ? uninstall : install,
title: status ? '' : t('installButton'),
});
$('.search-result-uninstall', entry).onclick = uninstall;
$('.search-result-install', entry).onclick = install;
Object.assign($('.search-result-customize', entry), {
onclick: configure,
disabled: notMatching,
});
toggleDataset(entry, 'installed', isInstalled);
toggleDataset(entry, 'customizable', result._styleVars);
}
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,
});
vars = !isEmptyObj(vars);
entry._result._styleVars = vars;
toggleDataset(entry, 'customizable', vars);
}
function configure() {
const styleEntry = $entry($resultEntry(this).result._styleId);
Events.configure.call(this, {target: styleEntry});
}
async function install() {
const {entry, result} = $resultEntry(this);
const {f: fmt} = result;
const id = rid2id(result.i);
const installButton = $('.search-result-install', entry);
showSpinner(entry);
saveScrollPosition(entry);
installButton.disabled = true;
entry.style.setProperty('pointer-events', 'none', 'important');
delete entry.dataset.error;
if (fmt) API.uso.pingback(id, PINGBACK_DELAY);
const updateUrl = fmt ? URLS.makeUsoArchiveCodeUrl(id) : URLS.makeUswCodeUrl(id);
try {
const sourceCode = await download(updateUrl);
const style = await API.usercss.install({sourceCode, updateUrl});
renderFullInfo(entry, style);
} catch (reason) {
entry.dataset.error = `${t('genericError')}: ${reason}`;
entry.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
$remove('.lds-spinner', entry);
installButton.disabled = false;
entry.style.pointerEvents = '';
}
function uninstall() {
const {entry, result} = $resultEntry(this);
saveScrollPosition(entry);
API.styles.delete(result._styleId);
}
function saveScrollPosition(entry) {
dom.scrollPos = entry.getBoundingClientRect().top;
dom.scrollPosElement = entry;
}
function restoreScrollPosition() {
window.scrollBy(0, dom.scrollPosElement.getBoundingClientRect().top - dom.scrollPos);
}
/**
* Resolves the Userstyles.org "category" for a given URL.
* @returns {boolean} true if the category has actually changed
*/
function calcCategory({retry} = {}) {
const u = tryURL(tabURL);
const old = category;
if (!u.href) {
// Invalid URL
category = '';
} else if (u.protocol === 'file:') {
category = 'file:';
} else if (u.protocol === location.protocol) {
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 !== 1 && !(
tld === 'com' ||
tld === 'org' && main !== 'userstyles'
);
const keepThird = !retry && (
fourth ||
third && third !== 'www' && third !== 'm'
);
category = (keepThird && `${third}.` || '') + main + (keepTld || keepThird ? `.${tld}` : '');
}
rxCategory = new RegExp(`\\b${stringAsRegExp(category, '', true)}\\b`, 'i');
return category !== old;
}
async function fetchIndex() {
const timer = setTimeout(showSpinner, BUSY_DELAY, dom.list);
const jobs = [
[INDEX_URL, 'uso', json => json.filter(v => v.f === 'uso')],
[USW_INDEX_URL, 'usw', json => json.data],
].map(async ([url, prefix, transform]) => {
const res = transform(await download(url, {responseType: 'json'}));
for (const v of res) v.i = `${prefix}-${v.i}`;
index = index ? index.concat(res) : res;
if (index !== res) ready = ready.then(start);
});
// TODO: use Promise.allSettled when "minimum_chrome_version" >= 76 and "strict_min_version" >= 71
indexing = Promise.all(jobs.map(j => j.catch(e => e))).then(() => {
indexing = null;
});
await Promise.race(jobs);
clearTimeout(timer);
$remove(':scope > .lds-spinner', dom.list);
return index;
}
async function search({retry} = {}) {
return retry && !query.length && !calcCategory({retry})
? []
: (index || await fetchIndex()).filter(isResultMatching).sort(comparator);
}
function isResultMatching(res) {
const {c} = res;
return (
c === category ||
(category === STYLUS_CATEGORY
? c === 'stylus' // USW
: c === 'global' && searchGlobals &&
(query.length || rxCategory.test(res.n))
)
) && query.every(isInHaystack, res);
}
/**
* @this {IndexEntry} haystack
* @param {RegExp} q
*/
function isInHaystack(q) {
return q.test(this.n);
}
/**
* @param {IndexEntry} a
* @param {IndexEntry} b
*/
function comparator(a, b) {
return (
order === 'n'
? a.n < b.n ? -1 : a.n > b.n
: b[order] - a[order]
) || b.t - a.t;
}
})();