search.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. /* global $ $$ $create $remove showSpinner toggleDataset */// dom.js
  2. /* global $entry tabURL */// popup.js
  3. /* global API */// msg.js
  4. /* global Events */
  5. /* global FIREFOX URLS debounce download tryCatch */// toolbox.js
  6. /* global prefs */
  7. /* global t */// localization.js
  8. 'use strict';
  9. (() => {
  10. require(['/popup/search.css']);
  11. const RESULT_ID_PREFIX = t.template.searchResult.className + '-';
  12. const RESULT_SEL = '.' + t.template.searchResult.className;
  13. const INDEX_URL = URLS.usoArchiveRaw[0] + 'search-index.json';
  14. const USW_INDEX_URL = URLS.usw + 'api/index/uso-format';
  15. const USW_ICON = $create('img', {
  16. src: `${URLS.usw}favicon.ico`,
  17. title: URLS.usw,
  18. });
  19. const STYLUS_CATEGORY = 'chrome-extension';
  20. const PAGE_LENGTH = 10;
  21. // update USO style install counter if the style isn't uninstalled immediately
  22. const PINGBACK_DELAY = 5e3;
  23. const BUSY_DELAY = .5e3;
  24. const USO_AUTO_PIC_SUFFIX = '-after.png';
  25. const BLANK_PIXEL = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
  26. const dom = {};
  27. /**
  28. * @typedef IndexEntry
  29. * @prop {'uso' | 'uso-android'} f - format
  30. * @prop {Number} i - id
  31. * @prop {string} n - name
  32. * @prop {string} c - category
  33. * @prop {Number} u - updatedTime
  34. * @prop {Number} t - totalInstalls
  35. * @prop {Number} w - weeklyInstalls
  36. * @prop {Number} r - rating
  37. * @prop {Number} ai - authorId
  38. * @prop {string} an - authorName
  39. * @prop {string} sn - screenshotName
  40. * @prop {boolean} sa - screenshotArchived
  41. * --------------------- Stylus' internally added extras
  42. * @prop {boolean} isUsw
  43. * @prop {boolean} installed
  44. * @prop {number} installedStyleId
  45. * @prop {number} pingbackTimer
  46. */
  47. /** @type IndexEntry[] */
  48. let results;
  49. /** @type IndexEntry[] */
  50. let index;
  51. let category = '';
  52. let searchGlobals = $('#search-globals').checked;
  53. /** @type string[] */
  54. let query = [];
  55. let order = prefs.get('popup.findSort');
  56. let scrollToFirstResult = true;
  57. let displayedPage = 1;
  58. let totalPages = 1;
  59. let ready;
  60. let imgType = '.jpg';
  61. // detect WebP support
  62. $create('img', {
  63. src: 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=',
  64. onload: () => (imgType = '.webp'),
  65. });
  66. /** @returns {{result: IndexEntry, entry: HTMLElement}} */
  67. const $resultEntry = el => {
  68. const entry = el.closest(RESULT_SEL);
  69. return {entry, result: entry && entry._result};
  70. };
  71. const $classList = sel => (sel instanceof Node ? sel : $(sel)).classList;
  72. const show = sel => $classList(sel).remove('hidden');
  73. const hide = sel => $classList(sel).add('hidden');
  74. Object.assign(Events, {
  75. /**
  76. * @param {HTMLAnchorElement} a
  77. * @param {Event} event
  78. */
  79. searchOnClick(a, event) {
  80. if (!prefs.get('popup.findStylesInline') || dom.container) {
  81. // use a less specific category if the inline search wasn't used yet
  82. if (!category) calcCategory({retry: 1});
  83. const search = [
  84. category ? '#' + category : '',
  85. $('#search-query').value,
  86. ].filter(Boolean).join(' ');
  87. a.search = search ? 'search=' + encodeURIComponent(search) : '';
  88. Events.openURLandHide.call(a, event);
  89. return;
  90. }
  91. a.textContent = a.title;
  92. a.title = '';
  93. init();
  94. calcCategory();
  95. ready = start();
  96. },
  97. });
  98. function init() {
  99. setTimeout(() => document.body.classList.add('search-results-shown'));
  100. hide('#find-styles-inline-group');
  101. $('#search-globals').onchange = function () {
  102. searchGlobals = this.checked;
  103. ready = ready.then(start);
  104. };
  105. $('#search-query').oninput = function () {
  106. query = [];
  107. const text = this.value.trim().toLocaleLowerCase();
  108. const thisYear = new Date().getFullYear();
  109. for (let re = /"(.+?)"|(\S+)/g, m; (m = re.exec(text));) {
  110. const n = Number(m[2]);
  111. query.push(n >= 2000 && n <= thisYear ? n : m[1] || m[2]);
  112. }
  113. if (category === STYLUS_CATEGORY && !query.includes('stylus')) {
  114. query.push('stylus');
  115. }
  116. ready = ready.then(start);
  117. };
  118. $('#search-order').value = order;
  119. $('#search-order').onchange = function () {
  120. order = this.value;
  121. prefs.set('popup.findSort', order);
  122. results.sort(comparator);
  123. render();
  124. };
  125. dom.list = $('#search-results-list');
  126. dom.container = $('#search-results');
  127. dom.container.dataset.empty = '';
  128. dom.error = $('#search-results-error');
  129. dom.nav = {};
  130. const navOnClick = {prev, next};
  131. for (const place of ['top', 'bottom']) {
  132. const nav = $(`.search-results-nav[data-type="${place}"]`);
  133. nav.appendChild(t.template.searchNav.cloneNode(true));
  134. dom.nav[place] = nav;
  135. for (const child of $$('[data-type]', nav)) {
  136. const type = child.dataset.type;
  137. child.onclick = navOnClick[type];
  138. nav['_' + type] = child;
  139. }
  140. }
  141. if (FIREFOX) {
  142. let lastShift;
  143. window.on('resize', () => {
  144. const scrollbarWidth = window.innerWidth - document.scrollingElement.clientWidth;
  145. const shift = document.body.getBoundingClientRect().left;
  146. if (!scrollbarWidth || shift === lastShift) return;
  147. lastShift = shift;
  148. document.body.style.setProperty('padding',
  149. `0 ${scrollbarWidth + shift}px 0 ${-shift}px`, 'important');
  150. }, {passive: true});
  151. }
  152. window.on('styleDeleted', ({detail: {style: {id}}}) => {
  153. restoreScrollPosition();
  154. const result = results.find(r => r.installedStyleId === id);
  155. if (result) {
  156. clearTimeout(result.pingbackTimer);
  157. renderActionButtons(result.i, -1);
  158. }
  159. });
  160. window.on('styleAdded', async ({detail: {style}}) => {
  161. restoreScrollPosition();
  162. const id = calcId(style) || calcId(await API.styles.get(style.id));
  163. if (id && results.find(r => r.i === id)) {
  164. renderActionButtons(id, style.id);
  165. }
  166. });
  167. }
  168. function next() {
  169. displayedPage = Math.min(totalPages, displayedPage + 1);
  170. scrollToFirstResult = true;
  171. render();
  172. }
  173. function prev() {
  174. displayedPage = Math.max(1, displayedPage - 1);
  175. scrollToFirstResult = true;
  176. render();
  177. }
  178. function error(reason) {
  179. dom.error.textContent = reason;
  180. show(dom.error);
  181. hide(dom.list);
  182. if (dom.error.getBoundingClientRect().bottom < 0) {
  183. dom.error.scrollIntoView({behavior: 'smooth', block: 'start'});
  184. }
  185. }
  186. async function start() {
  187. show(dom.container);
  188. show(dom.list);
  189. hide(dom.error);
  190. try {
  191. results = [];
  192. for (let retry = 0; !results.length && retry <= 2; retry++) {
  193. results = await search({retry});
  194. }
  195. if (results.length) {
  196. const installedStyles = await API.styles.getAll();
  197. const allSupportedIds = new Set(installedStyles.map(calcId));
  198. results = results.filter(r => !allSupportedIds.has(r.i));
  199. }
  200. render();
  201. (results.length ? show : hide)(dom.list);
  202. if (!results.length && !$('#search-query').value) {
  203. error(t('searchResultNoneFound'));
  204. }
  205. } catch (reason) {
  206. error(reason);
  207. }
  208. }
  209. function render() {
  210. totalPages = Math.ceil(results.length / PAGE_LENGTH);
  211. displayedPage = Math.min(displayedPage, totalPages) || 1;
  212. let start = (displayedPage - 1) * PAGE_LENGTH;
  213. const end = displayedPage * PAGE_LENGTH;
  214. let plantAt = 0;
  215. let slot = dom.list.children[0];
  216. // keep rendered elements with ids in the range of interest
  217. while (
  218. plantAt < PAGE_LENGTH &&
  219. slot && slot.id === RESULT_ID_PREFIX + (results[start] || {}).i
  220. ) {
  221. slot = slot.nextElementSibling;
  222. plantAt++;
  223. start++;
  224. }
  225. // add new elements
  226. while (start < Math.min(end, results.length)) {
  227. const entry = createSearchResultNode(results[start++]);
  228. if (slot) {
  229. dom.list.replaceChild(entry, slot);
  230. slot = entry.nextElementSibling;
  231. } else {
  232. dom.list.appendChild(entry);
  233. }
  234. plantAt++;
  235. }
  236. // remove extraneous elements
  237. const pageLen = end > results.length &&
  238. results.length % PAGE_LENGTH ||
  239. Math.min(results.length, PAGE_LENGTH);
  240. while (dom.list.children.length > pageLen) {
  241. dom.list.lastElementChild.remove();
  242. }
  243. if (results.length && 'empty' in dom.container.dataset) {
  244. delete dom.container.dataset.empty;
  245. }
  246. if (scrollToFirstResult && (!FIREFOX || FIREFOX >= 55)) {
  247. debounce(doScrollToFirstResult);
  248. }
  249. // navigation
  250. for (const place in dom.nav) {
  251. const nav = dom.nav[place];
  252. nav._prev.disabled = displayedPage <= 1;
  253. nav._next.disabled = displayedPage >= totalPages;
  254. nav._page.textContent = displayedPage;
  255. nav._total.textContent = totalPages;
  256. }
  257. }
  258. function doScrollToFirstResult() {
  259. if (dom.container.scrollHeight > window.innerHeight * 2) {
  260. scrollToFirstResult = false;
  261. dom.container.scrollIntoView({behavior: 'smooth', block: 'start'});
  262. }
  263. }
  264. /**
  265. * @param {IndexEntry} result
  266. * @returns {Node}
  267. */
  268. function createSearchResultNode(result) {
  269. const entry = t.template.searchResult.cloneNode(true);
  270. const {
  271. i: id,
  272. n: name,
  273. r: rating,
  274. u: updateTime,
  275. w: weeklyInstalls,
  276. t: totalInstalls,
  277. ai: authorId,
  278. an: author,
  279. sa: shotArchived,
  280. sn: shot,
  281. isUsw,
  282. } = entry._result = result;
  283. entry.id = RESULT_ID_PREFIX + id;
  284. // title
  285. Object.assign($('.search-result-title', entry), {
  286. onclick: Events.openURLandHide,
  287. href: `${isUsw ? URLS.usw : URLS.usoArchive}style/${id}`,
  288. });
  289. if (isUsw) $('.search-result-title', entry).prepend(USW_ICON.cloneNode(true));
  290. $('.search-result-title span', entry).textContent =
  291. t.breakWord(name.length < 300 ? name : name.slice(0, 300) + '...');
  292. // screenshot
  293. const elShot = $('.search-result-screenshot', entry);
  294. let shotSrc;
  295. if (isUsw) {
  296. shotSrc = /^https?:/i.test(shot) && shot.replace(/\.jpg$/, imgType);
  297. } else {
  298. elShot._src = URLS.uso + `auto_style_screenshots/${id}${USO_AUTO_PIC_SUFFIX}`;
  299. shotSrc = shot && !shot.endsWith(USO_AUTO_PIC_SUFFIX)
  300. ? `${shotArchived ? URLS.usoArchiveRaw[0] : URLS.uso + 'style_'}screenshots/${shot}`
  301. : elShot._src;
  302. }
  303. if (shotSrc) {
  304. elShot._entry = entry;
  305. elShot.src = shotSrc;
  306. elShot.onerror = fixScreenshot;
  307. } else {
  308. elShot.src = BLANK_PIXEL;
  309. entry.dataset.noImage = '';
  310. }
  311. // author
  312. Object.assign($('[data-type="author"] a', entry), {
  313. textContent: author,
  314. title: author,
  315. href: isUsw ? `${URLS.usw}user/${encodeURIComponent(author)}` :
  316. `${URLS.usoArchive}browse/styles?search=%40${authorId}`,
  317. onclick: Events.openURLandHide,
  318. });
  319. // rating
  320. $('[data-type="rating"]', entry).dataset.class =
  321. !rating ? 'none' :
  322. rating >= 2.5 ? 'good' :
  323. rating >= 1.5 ? 'okay' :
  324. 'bad';
  325. $('[data-type="rating"] dd', entry).textContent = rating && rating.toFixed(1) || '';
  326. // time
  327. Object.assign($('[data-type="updated"] time', entry), {
  328. dateTime: updateTime * 1000,
  329. textContent: t.formatDate(updateTime * 1000),
  330. });
  331. // totals
  332. $('[data-type="weekly"] dd', entry).textContent = formatNumber(weeklyInstalls);
  333. $('[data-type="total"] dd', entry).textContent = formatNumber(totalInstalls);
  334. renderActionButtons(entry);
  335. return entry;
  336. }
  337. function formatNumber(num) {
  338. return (
  339. num > 1e9 ? (num / 1e9).toFixed(1) + 'B' :
  340. num > 10e6 ? (num / 1e6).toFixed(0) + 'M' :
  341. num > 1e6 ? (num / 1e6).toFixed(1) + 'M' :
  342. num > 10e3 ? (num / 1e3).toFixed(0) + 'k' :
  343. num > 1e3 ? (num / 1e3).toFixed(1) + 'k' :
  344. num
  345. );
  346. }
  347. function fixScreenshot() {
  348. const {_src} = this;
  349. if (_src && _src !== this.src) {
  350. this.src = _src;
  351. delete this._src;
  352. } else {
  353. this.onerror = null;
  354. this.src = BLANK_PIXEL;
  355. this._entry.dataset.noImage = '';
  356. renderActionButtons(this._entry);
  357. }
  358. }
  359. function renderActionButtons(entry, installedId) {
  360. if (Number(entry)) {
  361. entry = $('#' + RESULT_ID_PREFIX + entry);
  362. }
  363. if (!entry) return;
  364. const result = entry._result;
  365. if (typeof installedId === 'number') {
  366. result.installed = installedId > 0;
  367. result.installedStyleId = installedId;
  368. }
  369. const isInstalled = result.installed;
  370. const status = $('.search-result-status', entry).textContent =
  371. isInstalled ? t('clickToUninstall') :
  372. entry.dataset.noImage != null ? t('installButton') :
  373. '';
  374. const notMatching = installedId > 0 && !$entry(installedId);
  375. if (notMatching !== entry.classList.contains('not-matching')) {
  376. entry.classList.toggle('not-matching');
  377. if (notMatching) {
  378. entry.prepend(t.template.searchResultNotMatching.cloneNode(true));
  379. } else {
  380. entry.firstElementChild.remove();
  381. }
  382. }
  383. Object.assign($('.search-result-screenshot', entry), {
  384. onclick: isInstalled ? uninstall : install,
  385. title: status ? '' : t('installButton'),
  386. });
  387. $('.search-result-uninstall', entry).onclick = uninstall;
  388. $('.search-result-install', entry).onclick = install;
  389. Object.assign($('.search-result-customize', entry), {
  390. onclick: configure,
  391. disabled: notMatching,
  392. });
  393. toggleDataset(entry, 'installed', isInstalled);
  394. }
  395. function renderFullInfo(entry, style) {
  396. let {description, vars} = style.usercssData;
  397. // description
  398. description = (description || '')
  399. .replace(/<[^>]*>/g, ' ')
  400. .replace(/([^.][.。?!]|[\s,].{50,70})\s+/g, '$1\n')
  401. .replace(/([\r\n]\s*){3,}/g, '\n\n');
  402. Object.assign($('.search-result-description', entry), {
  403. textContent: description,
  404. title: description,
  405. });
  406. toggleDataset(entry, 'customizable', vars);
  407. }
  408. function configure() {
  409. const styleEntry = $entry($resultEntry(this).result.installedStyleId);
  410. Events.configure.call(this, {target: styleEntry});
  411. }
  412. async function install() {
  413. const {entry, result} = $resultEntry(this);
  414. const {i: id, isUsw} = result;
  415. const installButton = $('.search-result-install', entry);
  416. showSpinner(entry);
  417. saveScrollPosition(entry);
  418. installButton.disabled = true;
  419. entry.style.setProperty('pointer-events', 'none', 'important');
  420. delete entry.dataset.error;
  421. if (!isUsw) {
  422. // FIXME: move this to background page and create an API like installUSOStyle
  423. result.pingbackTimer = setTimeout(download, PINGBACK_DELAY,
  424. `${URLS.uso}styles/install/${id}?source=stylish-ch`);
  425. }
  426. const updateUrl = isUsw ? URLS.makeUswCodeUrl(id) : URLS.makeUsoArchiveCodeUrl(id);
  427. try {
  428. const sourceCode = await download(updateUrl);
  429. const style = await API.usercss.install({sourceCode, updateUrl});
  430. renderFullInfo(entry, style);
  431. } catch (reason) {
  432. entry.dataset.error = `${t('genericError')}: ${reason}`;
  433. entry.scrollIntoView({behavior: 'smooth', block: 'nearest'});
  434. }
  435. $remove('.lds-spinner', entry);
  436. installButton.disabled = false;
  437. entry.style.pointerEvents = '';
  438. }
  439. function uninstall() {
  440. const {entry, result} = $resultEntry(this);
  441. saveScrollPosition(entry);
  442. API.styles.delete(result.installedStyleId);
  443. }
  444. function saveScrollPosition(entry) {
  445. dom.scrollPos = entry.getBoundingClientRect().top;
  446. dom.scrollPosElement = entry;
  447. }
  448. function restoreScrollPosition() {
  449. window.scrollBy(0, dom.scrollPosElement.getBoundingClientRect().top - dom.scrollPos);
  450. }
  451. /**
  452. * Resolves the Userstyles.org "category" for a given URL.
  453. * @returns {boolean} true if the category has actually changed
  454. */
  455. function calcCategory({retry} = {}) {
  456. const u = tryCatch(() => new URL(tabURL));
  457. const old = category;
  458. if (!u) {
  459. // Invalid URL
  460. category = '';
  461. } else if (u.protocol === 'file:') {
  462. category = 'file:';
  463. } else if (u.protocol === location.protocol) {
  464. category = STYLUS_CATEGORY;
  465. } else {
  466. const parts = u.hostname.replace(/\.(?:com?|org)(\.\w{2,3})$/, '$1').split('.');
  467. const [tld, main = u.hostname, third, fourth] = parts.reverse();
  468. const keepTld = retry !== 1 && !(
  469. tld === 'com' ||
  470. tld === 'org' && main !== 'userstyles'
  471. );
  472. const keepThird = !retry && (
  473. fourth ||
  474. third && third !== 'www' && third !== 'm'
  475. );
  476. category = (keepThird && `${third}.` || '') + main + (keepTld || keepThird ? `.${tld}` : '');
  477. }
  478. return category !== old;
  479. }
  480. async function fetchIndex() {
  481. const timer = setTimeout(showSpinner, BUSY_DELAY, dom.list);
  482. index = [];
  483. await Promise.all([
  484. download(INDEX_URL, {responseType: 'json'}).then(res => {
  485. index = index.concat(res.filter(res => res.f === 'uso'));
  486. }).catch(() => {}),
  487. download(USW_INDEX_URL, {responseType: 'json'}).then(res => {
  488. for (const style of res.data) {
  489. style.isUsw = true;
  490. index.push(style);
  491. }
  492. }).catch(() => {}),
  493. ]);
  494. clearTimeout(timer);
  495. $remove(':scope > .lds-spinner', dom.list);
  496. return index;
  497. }
  498. async function search({retry} = {}) {
  499. return retry && !calcCategory({retry})
  500. ? []
  501. : (index || await fetchIndex()).filter(isResultMatching).sort(comparator);
  502. }
  503. function isResultMatching(res) {
  504. // We're trying to call calcHaystack only when needed, not on all 100K items
  505. const {c} = res;
  506. return (
  507. c === category ||
  508. (category === STYLUS_CATEGORY
  509. ? c === 'stylus' // USW
  510. : c === 'global' && searchGlobals &&
  511. (query.length || calcHaystack(res)._nLC.includes(category))
  512. )
  513. ) && (
  514. !query.length || // to skip calling calcHaystack
  515. query.every(isInHaystack, calcHaystack(res))
  516. );
  517. }
  518. /** @this {IndexEntry} haystack */
  519. function isInHaystack(needle) {
  520. return this._year === needle && this.c !== 'global' ||
  521. this._nLC.includes(needle);
  522. }
  523. /**
  524. * @param {IndexEntry} a
  525. * @param {IndexEntry} b
  526. */
  527. function comparator(a, b) {
  528. return (
  529. order === 'n'
  530. ? a.n < b.n ? -1 : a.n > b.n
  531. : b[order] - a[order]
  532. ) || b.t - a.t;
  533. }
  534. function calcUsoId({md5Url: m, updateUrl}) {
  535. return Number(m && m.match(/\d+|$/)[0]) ||
  536. URLS.extractUsoArchiveId(updateUrl);
  537. }
  538. function calcUswId({updateUrl}) {
  539. return URLS.extractUSwId(updateUrl) || 0;
  540. }
  541. function calcId(style) {
  542. return calcUsoId(style) || calcUswId(style);
  543. }
  544. function calcHaystack(res) {
  545. if (!res._nLC) res._nLC = res.n.toLocaleLowerCase();
  546. if (!res._year) res._year = new Date(res.u * 1000).getFullYear();
  547. return res;
  548. }
  549. })();