Based on a bookmarklet by @deliciousnights.
Installation requires a browser extension such as Violentmonkey / Tampermonkey / Greasemonkey.
Based on a bookmarklet by @deliciousnights.
Installation requires a browser extension such as Violentmonkey / Tampermonkey / Greasemonkey.
| // ==UserScript== | |
| // @name Stash Tagger Colorizer | |
| // @author peolic | |
| // @version 1.2 | |
| // @description Based on a bookmarklet by @deliciousnights | |
| // @namespace https://github.com/peolic | |
| // @match http://localhost:9999/* | |
| // @match https://localhost:9999/* | |
| // @grant none | |
| // @homepageURL https://gist.github.com/peolic/4237aaa60e08fcda8a6f8e68092cd5f2 | |
| // @downloadURL https://gist.github.com/peolic/4237aaa60e08fcda8a6f8e68092cd5f2/raw/stash-tagger-colorizer.user.js | |
| // @updateURL https://gist.github.com/peolic/4237aaa60e08fcda8a6f8e68092cd5f2/raw/stash-tagger-colorizer.user.js | |
| // ==/UserScript== | |
| // Change if you have Stash in a different language: | |
| const LABELS = { | |
| ScrapeAllButton: 'Scrape All', | |
| Studio: 'Studio', | |
| Performer: 'Performer', | |
| }; | |
| const COLORS = { | |
| true: '#0f9960', | |
| false: '#ff7373', | |
| }; | |
| async function main() { | |
| execute(); | |
| window.addEventListener(locationChanged, execute); | |
| } | |
| async function execute() { | |
| const [tagger, taggerHeader] = await Promise.race([ | |
| Promise.all([ | |
| elementReady('.tagger-container'), | |
| elementReady('.tagger-container > .tagger-container-header'), | |
| elementReady('.tagger-container > .tagger-container-header + div'), | |
| ]), | |
| wait(1000).then(() => []), | |
| ]); | |
| if (!tagger) | |
| return; | |
| const scrapeAllButton = await new Promise(async (resolve) => { | |
| let button; | |
| do { | |
| const buttons = Array.from(taggerHeader.querySelectorAll('button')); | |
| button = buttons.find((btn) => btn.innerText === LABELS.ScrapeAllButton); | |
| await wait(50); | |
| } while (!button); | |
| resolve(button); | |
| }); | |
| if (!scrapeAllButton) { | |
| console.error('[tagger colorizer] scrape all button not found'); | |
| return; | |
| } | |
| scrapeAllButton.addEventListener('click', () => { | |
| const mainButtonID = 'colorize-tagger-results'; | |
| if (taggerHeader.querySelector(`#${mainButtonID}`)) | |
| return; | |
| const colorizeButtonContainer = document.createElement('div'); | |
| colorizeButtonContainer.classList.add('ml-1', 'mr-1'); | |
| const colorizeButton = scrapeAllButton.cloneNode(true); | |
| colorizeButton.classList.replace('btn-primary', 'btn-success'); | |
| colorizeButton.innerText = 'Colorize Results'; | |
| colorizeButton.id = mainButtonID; | |
| colorizeButton.addEventListener('click', () => colorizeScrapeResults(tagger)); | |
| colorizeButtonContainer.appendChild(colorizeButton); | |
| scrapeAllButton.closest('.d-flex').prepend(colorizeButtonContainer); | |
| }); | |
| } | |
| function colorizeScrapeResults(tagger) { | |
| const searchItems = Array.from(tagger.querySelectorAll('.search-item')); | |
| if (searchItems.length === 0) | |
| return; | |
| searchItems.forEach((searchItem) => { | |
| const localname = searchItem.querySelector('.scene-link > div').textContent.toLowerCase(); | |
| const queryname = searchItem.querySelector('input').value.toLowerCase(); | |
| const check = (fn) => [localname, queryname].some(fn); | |
| const searchResults = Array.from(searchItem.querySelectorAll('.search-result')); | |
| if (searchResults.length === 0) | |
| return; | |
| searchResults.forEach((searchResult) => { | |
| const titleEl = searchResult.querySelector('.scene-metadata > h4'); | |
| const dateEl = searchResult.querySelector('.scene-metadata > h5'); | |
| const entityNames = Array.from(searchResult.querySelectorAll('.entity-name')) | |
| .map((en) => ({ | |
| type: en.firstChild.textContent, | |
| name: en.lastChild.textContent, | |
| nameEl: en.lastChild, | |
| el: en, | |
| })); | |
| const studio = entityNames.find(({ type }) => type === LABELS.Studio); | |
| const studioName = studio.name.toLowerCase().replaceAll(/[. -]/g, ''); // '21 .Naturals' -> '21naturals' | |
| studio.nameEl.style.color = COLORS[check((n) => n.replaceAll(/[. -]/g, '').includes(studioName))]; | |
| const performers = entityNames.filter(({ type }) => type === LABELS.Performer); | |
| performers.forEach(({ name, nameEl }) => { | |
| nameEl.style.color = COLORS[check((n) => n.includes(name.toLowerCase()))]; | |
| }); | |
| const title = titleEl.textContent.toLowerCase().replaceAll('!', ''); // 'Coming Home for Xmas!' -> 'coming home for xmas' | |
| titleEl.querySelector('a').style.color = COLORS[check((n) => n.includes(title))]; | |
| const date = dateEl.textContent; // 2022-01-31 | |
| const dateMatch = check( | |
| (n) => date === n.replace( | |
| /.*(\d{2}|\d{4})[\D](\d{2})[\D](\d{2}).*/g, (_, year, month, day) => | |
| [(year.length === 2 ? '20' : '') + year, month, day].join('-') | |
| ) | |
| ); | |
| dateEl.querySelector('button + div').style.color = COLORS[dateMatch]; | |
| }); | |
| }); | |
| }; | |
| const wait = (/** @type {number} */ ms) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| // MIT Licensed | |
| // Author: jwilson8767 | |
| // https://gist.github.com/jwilson8767/db379026efcbd932f64382db4b02853e | |
| /** | |
| * Waits for an element satisfying selector to exist, then resolves promise with the element. | |
| * Useful for resolving race conditions. | |
| * | |
| * @param {string} selector | |
| * @param {HTMLElement} [parentEl] | |
| * @returns {Promise<Element>} | |
| */ | |
| function elementReady(selector, parentEl) { | |
| return new Promise((resolve, reject) => { | |
| let el = (parentEl || document).querySelector(selector); | |
| if (el) {resolve(el);} | |
| new MutationObserver((mutationRecords, observer) => { | |
| // Query for elements matching the specified selector | |
| Array.from((parentEl || document).querySelectorAll(selector)).forEach((element) => { | |
| resolve(element); | |
| //Once we have resolved we don't need the observer anymore. | |
| observer.disconnect(); | |
| }); | |
| }) | |
| .observe(parentEl || document.documentElement, { | |
| childList: true, | |
| subtree: true | |
| }); | |
| }); | |
| } | |
| // Based on: https://dirask.com/posts/JavaScript-on-location-changed-event-on-url-changed-event-DKeyZj | |
| const locationChanged = (function() { | |
| const { pushState, replaceState } = history; | |
| // @ts-expect-error | |
| const prefix = GM.info.script.name | |
| .toLowerCase() | |
| .replace(/[^a-z0-9 -]/g, '') | |
| .trim() | |
| .replace(/\s+/g, '-'); | |
| const eventLocationChange = new Event(`${prefix}$locationchange`); | |
| history.pushState = function(...args) { | |
| pushState.apply(history, args); | |
| window.dispatchEvent(new Event(`${prefix}$pushstate`)); | |
| window.dispatchEvent(eventLocationChange); | |
| } | |
| history.replaceState = function(...args) { | |
| replaceState.apply(history, args); | |
| window.dispatchEvent(new Event(`${prefix}$replacestate`)); | |
| window.dispatchEvent(eventLocationChange); | |
| } | |
| window.addEventListener('popstate', function() { | |
| window.dispatchEvent(eventLocationChange); | |
| }); | |
| return eventLocationChange.type; | |
| })(); | |
| main(); |