const stashListener = new EventTarget(); const { fetch: originalFetch } = window; window.fetch = async (...args) => { let [resource, config] = args; // request interceptor here const response = await originalFetch(resource, config); // response interceptor here const contentType = response.headers.get("content-type"); if (contentType && contentType.indexOf("application/json") !== -1 && resource.endsWith('/graphql')) { try { const data = await response.clone().json(); stashListener.dispatchEvent(new CustomEvent('response', { 'detail': data })); } catch (e) { } } return response; }; class Logger { constructor(enabled) { this.enabled = enabled; } debug() { if (!this.enabled) return; console.debug(...arguments); } } class Stash extends EventTarget { constructor({ pageUrlCheckInterval = 100, detectReRenders = false, // detects if .main element is re-rendered. eg: When you are in scenes page and clicking the scenes nav tab the url wont change but the elements are re-rendered, So with this you can listen and alter the elements inside the .main node logging = false } = {}) { super(); this.log = new Logger(logging); this._pageUrlCheckInterval = pageUrlCheckInterval; this._detectReRenders = detectReRenders; this.fireOnHashChangesToo = true; this._lastPathStr = ""; this._lastQueryStr = ""; this._lastHashStr = ""; this._lastHref = ""; this._lastStashPageEvent = ""; this.waitForElement(this._detectReRenders ? ".main > div" : "html").then(() => { this._pageURLCheckTimerId = setInterval(() => { // Loop every 100 ms if ( this._lastPathStr !== location.pathname || this._lastQueryStr !== location.search || (this.fireOnHashChangesToo && this._lastHashStr !== location.hash) || this._lastHref !== location.href || (!document.querySelector(".main > div[stashUserscriptLibrary]") && this._detectReRenders) ) { this._dispatchPageEvent("stash:page", false) this.gmMain({ lastPathStr: this._lastPathStr, lastQueryStr: this._lastQueryStr, lastHashStr: this._lastHashStr, lastHref: this._lastHref, lastStashPageEvent: this._lastStashPageEvent, }); this._lastPathStr = location.pathname this._lastQueryStr = location.search this._lastHashStr = location.hash this._lastHref = location.href if (this._detectReRenders) { this.waitForElement(".main > div", 10000).then((element) => { element.setAttribute("stashUserscriptLibrary", ""); }) } } }, this._pageUrlCheckInterval); }) stashListener.addEventListener('response', (evt) => { if (evt.detail.data?.plugins) { this.getPluginVersion(evt.detail); } this.processRemoteScenes(evt.detail); this.processScene(evt.detail); this.processScenes(evt.detail); this.processStudios(evt.detail); this.processPerformers(evt.detail); this.processApiKey(evt.detail); this.dispatchEvent(new CustomEvent('stash:response', { 'detail': evt.detail })); }); stashListener.addEventListener('pluginVersion', (evt) => { if (this.pluginVersion !== evt.detail) { this.pluginVersion = evt.detail; this.dispatchEvent(new CustomEvent('stash:pluginVersion', { 'detail': evt.detail })); } }); this.version = [0, 0, 0]; this.getVersion(); this.pluginVersion = null; this.getPlugins().then(plugins => this.getPluginVersion(plugins)); this.visiblePluginTasks = ['Userscript Functions']; this.settingsCallbacks = []; this.settingsId = 'userscript-settings'; this.remoteScenes = {}; this.scenes = {}; this.studios = {}; this.performers = {}; this.userscripts = []; this._pageListeners = {}; this.assignPageListeners() } async getVersion() { const reqData = { "operationName": "", "variables": {}, "query": `query version { version { version } }` }; const data = await this.callGQL(reqData); const versionString = data.data.version.version; this.version = versionString.substring(1).split('.').map(o => parseInt(o)); } compareVersion(minVersion) { let [currMajor, currMinor, currPatch = 0] = this.version; let [minMajor, minMinor, minPatch = 0] = minVersion.split('.').map(i => parseInt(i)); if (currMajor > minMajor) return 1; if (currMajor < minMajor) return -1; if (currMinor > minMinor) return 1; if (currMinor < minMinor) return -1; return 0; } comparePluginVersion(minPluginVersion) { if (!this.pluginVersion) return -1; let [currMajor, currMinor, currPatch = 0] = this.pluginVersion.split('.').map(i => parseInt(i)); let [minMajor, minMinor, minPatch = 0] = minPluginVersion.split('.').map(i => parseInt(i)); if (currMajor > minMajor) return 1; if (currMajor < minMajor) return -1; if (currMinor > minMinor) return 1; if (currMinor < minMinor) return -1; return 0; } async runPluginTask(pluginId, taskName, args = []) { const reqData = { "operationName": "RunPluginTask", "variables": { "plugin_id": pluginId, "task_name": taskName, "args": args }, "query": "mutation RunPluginTask($plugin_id: ID!, $task_name: String!, $args: [PluginArgInput!]) {\n runPluginTask(plugin_id: $plugin_id, task_name: $task_name, args: $args)\n}\n" }; return this.callGQL(reqData); } async callGQL(reqData) { const options = { method: 'POST', body: JSON.stringify(reqData), headers: { 'Content-Type': 'application/json' } } try { const res = await window.fetch('/graphql', options); this.log.debug(res); return res.json(); } catch (err) { console.error(err); } } async getFreeOnesStats(link) { try { const doc = await fetch(link) .then(function(response) { // When the page is loaded convert it to text return response.text() }) .then(function(html) { // Initialize the DOM parser var parser = new DOMParser(); // Parse the text var doc = parser.parseFromString(html, "text/html"); // You can now even select part of that html as you would in the regular DOM // Example: // var docArticle = doc.querySelector('article').innerHTML; console.log(doc); return doc }) .catch(function(err) { console.log('Failed to fetch page: ', err); }); var data = new Object(); data.rank = doc.querySelector('rank-chart-button'); console.log(data.rank); data.views = doc.querySelector('.d-none.d-m-flex.flex-column.align-items-center.global-header > div.font-weight-bold').textContent; data.votes = '0' return JSON.stringify(data); } catch (err) { console.error(err); } } async getPlugins() { const reqData = { "operationName": "Plugins", "variables": {}, "query": `query Plugins { plugins { id name description url version tasks { name description __typename } hooks { name description hooks } } } ` }; return this.callGQL(reqData); } async getPluginVersion(plugins) { let version = null; for (const plugin of plugins?.data?.plugins || []) { if (plugin.id === 'userscript_functions') { version = plugin.version; } } stashListener.dispatchEvent(new CustomEvent('pluginVersion', { 'detail': version })); } async getStashBoxes() { const reqData = { "operationName": "Configuration", "variables": {}, "query": `query Configuration { configuration { general { stashBoxes { endpoint api_key name } } } }` }; return this.callGQL(reqData); } async getApiKey() { const reqData = { "operationName": "Configuration", "variables": {}, "query": `query Configuration { configuration { general { apiKey } } }` }; return this.callGQL(reqData); } matchUrl(href, fragment) { const regexp = concatRegexp(new RegExp(window.location.origin), fragment); this.log.debug(regexp, location.href.match(regexp)); return href.match(regexp) != null; } createSettings() { waitForElementId('configuration-tabs-tabpane-system', async (elementId, el) => { let section; if (!document.getElementById(this.settingsId)) { section = document.createElement("div"); section.setAttribute('id', this.settingsId); section.classList.add('setting-section'); section.innerHTML = `

Userscript Settings

`; el.appendChild(section); const expectedApiKey = (await this.getApiKey())?.data?.configuration?.general?.apiKey || ''; const expectedUrl = window.location.origin; const serverUrlInput = await this.createSystemSettingTextbox(section, 'userscript-section-server-url', 'userscript-server-url', 'Stash Server URL', '', 'Server URL…', true); serverUrlInput.addEventListener('change', () => { const value = serverUrlInput.value || ''; if (value) { this.updateConfigValueTask('STASH', 'url', value); alert(`Userscripts plugin server URL set to ${value}`); } else { this.getConfigValueTask('STASH', 'url').then(value => { serverUrlInput.value = value; }); } }); serverUrlInput.disabled = true; serverUrlInput.value = expectedUrl; this.getConfigValueTask('STASH', 'url').then(value => { if (value !== expectedUrl) { return this.updateConfigValueTask('STASH', 'url', expectedUrl); } }); const apiKeyInput = await this.createSystemSettingTextbox(section, 'userscript-section-server-apikey', 'userscript-server-apikey', 'Stash API Key', '', 'API Key…', true); apiKeyInput.addEventListener('change', () => { const value = apiKeyInput.value || ''; this.updateConfigValueTask('STASH', 'api_key', value); if (value) { alert(`Userscripts plugin server api key set to ${value}`); } else { alert(`Userscripts plugin server api key value cleared`); } }); apiKeyInput.disabled = true; apiKeyInput.value = expectedApiKey; this.getConfigValueTask('STASH', 'api_key').then(value => { if (value !== expectedApiKey) { return this.updateConfigValueTask('STASH', 'api_key', expectedApiKey); } }); } else { section = document.getElementById(this.settingsId); } for (const callback of this.settingsCallbacks) { callback(this.settingsId, section); } if (this.pluginVersion) { this.dispatchEvent(new CustomEvent('stash:pluginVersion', { 'detail': this.pluginVersion })); } }); } addSystemSetting(callback) { const section = document.getElementById(this.settingsId); if (section) { callback(this.settingsId, section); } this.settingsCallbacks.push(callback); } async createSystemSettingCheckbox(containerEl, settingsId, inputId, settingsHeader, settingsSubheader) { const section = document.createElement("div"); section.setAttribute('id', settingsId); section.classList.add('card'); section.style.display = 'none'; section.innerHTML = `

${settingsHeader}

${settingsSubheader}
`; containerEl.appendChild(section); return document.getElementById(inputId); } async createSystemSettingTextbox(containerEl, settingsId, inputId, settingsHeader, settingsSubheader, placeholder, visible) { const section = document.createElement("div"); section.setAttribute('id', settingsId); section.classList.add('card'); section.style.display = visible ? 'flex' : 'none'; section.innerHTML = `

${settingsHeader}

${settingsSubheader}
`; containerEl.appendChild(section); return document.getElementById(inputId); } get serverUrl() { return window.location.origin; } async waitForElement(selector, timeout = null, location = document.body, disconnectOnPageChange = false) { return new Promise((resolve) => { if (document.querySelector(selector)) { return resolve(document.querySelector(selector)) } const observer = new MutationObserver(async () => { if (document.querySelector(selector)) { resolve(document.querySelector(selector)) observer.disconnect() } else { if (timeout) { async function timeOver() { return new Promise((resolve) => { setTimeout(() => { observer.disconnect() resolve(false) }, timeout) }) } resolve(await timeOver()) } } }) observer.observe(location, { childList: true, subtree: true, }) const stash = this if (disconnectOnPageChange) { function disconnect() { observer.disconnect() stash.removeEventListener("stash:page", disconnect) } stash.addEventListener("stash:page", disconnect) } }) } async waitForElementDeath(selector, location = document.body, disconnectOnPageChange = false) { return new Promise((resolve) => { const observer = new MutationObserver(async () => { if (!document.querySelector(selector)) { resolve(true) observer.disconnect() } }) observer.observe(location, { childList: true, subtree: true, }) const stash = this if (disconnectOnPageChange) { function disconnect() { observer.disconnect() stash.removeEventListener("stash:page", disconnect) } stash.addEventListener("stash:page", disconnect) } }) } async _listenForNonPageChanges({selector = "", location = document.body, listenType = "", event = "", recursive = false, reRunGmMain = false, condition = () => true, listenDefaultTab = true, callback = () => {}} = {}){ if (recursive) return if (listenType === "tabs") { const locationElement = await this.waitForElement(location, 10000, document.body, true) const stash = this let previousEvent = "" function listenForTabClicks(domEvent) { const clickedChild = domEvent.target ? domEvent.target : domEvent; if(!clickedChild.classList?.contains("nav-link")) return const tagName = clickedChild.getAttribute("data-rb-event-key") const parentEvent = tagName.split("-")[0] const childEvent = tagName.split("-").slice(1, -1).join("-") event = `stash:page:${parentEvent}:${childEvent}` if (previousEvent === event || !condition()) return previousEvent = event stash._dispatchPageEvent(`stash:page:any:${childEvent}`, false) stash._dispatchPageEvent(event) } if (listenDefaultTab) listenForTabClicks(locationElement.querySelector(".nav-link.active")) locationElement.addEventListener("click", listenForTabClicks); function removeEventListenerOnPageChange() { locationElement.removeEventListener("click", listenForTabClicks) stash.removeEventListener("stash:page", removeEventListenerOnPageChange) } stash.addEventListener("stash:page", removeEventListenerOnPageChange) } else if (await this.waitForElement(selector, null, location, true)) { this._dispatchPageEvent(event) if (await this.waitForElementDeath(selector, location, true)) { if (this._lastPathStr === window.location.pathname && !reRunGmMain) { await this._listenForNonPageChanges({selector: selector, event: event}) } else if (this._lastPathStr === window.location.pathname && reRunGmMain) { this.gmMain({ recursive: true, lastPathStr: this._lastPathStr, lastQueryStr: this._lastQueryStr, lastHashStr: this._lastHashStr, lastHref: this._lastHref, lastStashPageEvent: this._lastStashPageEvent, }); } } } callback() } _dispatchPageEvent(event, addToHistory = true) { this.dispatchEvent(new CustomEvent(event, { detail: { event: event, lastEventState: { lastPathStr: this._lastPathStr, lastQueryStr: this._lastQueryStr, lastHashStr: this._lastHashStr, lastHref: this._lastHref, lastStashPageEvent: this._lastStashPageEvent, } } })) if (addToHistory) { this.log.debug(`[Navigation] ${event}`); if (event.startsWith("stash:")) { this._lastStashPageEvent = event; } } } addPageListener(eventData) { const {event, regex, callBack = () => {}, manuallyHandleDispatchEvent = false} = eventData if (event && !event?.startsWith("stash:") && regex && this._pageListeners[event] === undefined){ this._pageListeners[event] = { regex: regex, callBack: callBack, manuallyHandleDispatchEvent: manuallyHandleDispatchEvent } return event } else { if (this._pageListeners[event] !== undefined) { console.error(`Can't add page listener: Event ${event} already exists`) } else if (event?.startsWith("stash:")) { console.error(`Can't add page listener: Event name can't start with "stash:"`) } else { console.error(`Can't add page listener: Missing required argument(s) "event", "regex"`) } return false } } removePageListener(event) { if(event && !event?.startsWith("stash:") && this._pageListeners[event]){ delete this._pageListeners[event] return event } else { if (this._pageListeners[event] === undefined && event) { console.error(`Can't remove page listener: Event ${event} doesn't exists`) } else if (event?.startsWith("stash:")) { console.error(`Can't remove page listener: Event ${event} is a built in event`) } else { console.error(`Can't remove page listener: Missing "event" argument`) } return false } } stopPageListener() { clearInterval(this._pageURLCheckTimerId) } assignPageListeners() { this._pageListeners = { // scenes tab "stash:page:scenes": { regex: /\/scenes\?/, handleDisplayView: true, callBack: () => this.processTagger() }, "stash:page:scene:new": { regex: /\/scenes\/new/ }, "stash:page:scene": { regex: /\/scenes\/\d+/, callBack: ({recursive = false}) => this._listenForNonPageChanges({ location: ".scene-tabs .nav-tabs", listenType: "tabs", recursive: recursive }) }, // images tab "stash:page:images": { regex: /\/images\?/, handleDisplayView: true, }, "stash:page:image": { regex: /\/images\/\d+/, callBack: ({recursive = false}) => this._listenForNonPageChanges({ location: ".image-tabs .nav-tabs", listenType: "tabs", recursive: recursive }) }, // movies tab "stash:page:movies": { regex: /\/movies\?/, }, "stash:page:movie": { regex: /\/movies\/\d+/, }, "stash:page:movie:scenes": { regex: /\/movies\/\d+\?/, callBack: () => this.processTagger() }, // markers tab "stash:page:markers": { regex: /\/scenes\/markers/ }, // galleries tab "stash:page:galleries": { regex: /\/galleries\?/, handleDisplayView: true, }, "stash:page:gallery:new": { regex: /\/galleries\/new/, }, "stash:page:gallery:images": { regex: /\/galleries\/\d+\?/, manuallyHandleDispatchEvent: true, handleDisplayView: "ignoreDisplayViewCondition", callBack: ({lastHref, recursive = false}, event) => { if(!this.matchUrl(lastHref, /\/galleries\/\d+/)){ this._dispatchPageEvent("stash:page:gallery"); this._listenForNonPageChanges({selector: ".gallery-tabs .nav-tabs .nav-link.active", event: "stash:page:gallery:details"}) } this._dispatchPageEvent(event); this._listenForNonPageChanges({ location: ".gallery-tabs .nav-tabs", listenType: "tabs", recursive: recursive, listenDefaultTab: false }) } }, "stash:page:gallery:add": { regex: /\/galleries\/\d+\/add/, manuallyHandleDispatchEvent: true, handleDisplayView: "ignoreDisplayViewCondition", callBack: ({lastHref, recursive = false}, event) => { if(!this.matchUrl(lastHref, /\/galleries\/\d+/)){ this._dispatchPageEvent("stash:page:gallery"); this._listenForNonPageChanges({selector: ".gallery-tabs .nav-tabs .nav-link.active", event: "stash:page:gallery:details"}) } this._dispatchPageEvent(event); this._listenForNonPageChanges({ location: ".gallery-tabs .nav-tabs", listenType: "tabs", recursive: recursive, listenDefaultTab: false }) } }, // performers tab "stash:page:performers": { regex: /\/performers\?/, manuallyHandleDispatchEvent: true, handleDisplayView: true, callBack: ({lastHref}, event) => !this.matchUrl(lastHref, /\/performers\?/) || this._detectReRenders ? this._dispatchPageEvent(event) : null }, "stash:page:performer:new": { regex: /\/performers\/new/ }, "stash:page:performer": { regex: /\/performers\/\d+/, manuallyHandleDispatchEvent: true, callBack: ({lastHref}, event) => { if(!this.matchUrl(lastHref, /\/performers\/\d+/)){ this._dispatchPageEvent(event); this.processTagger(); } this._listenForNonPageChanges({ selector: "#performer-edit", event: "stash:page:performer:edit", reRunGmMain: true, callback: () => this._detectReRenders ? this._dispatchPageEvent(event) : null }) } }, "stash:page:performer:scenes": { regex: /\/performers\/\d+\?/, handleDisplayView: true, }, "stash:page:performer:galleries": { regex: /\/performers\/\d+\/galleries/, handleDisplayView: true }, "stash:page:performer:images": { regex: /\/performers\/\d+\/images/, handleDisplayView: true }, "stash:page:performer:movies": { regex: /\/performers\/\d+\/movies/ }, "stash:page:performer:appearswith": { regex: /\/performers\/\d+\/appearswith/, handleDisplayView: true, callBack: () => this.processTagger() }, // studios tab "stash:page:studios": { regex: /\/studios\?/, handleDisplayView: true, }, "stash:page:studio:new": { regex: /\/studios\/new/ }, "stash:page:studio": { regex: /\/studios\/\d+/, manuallyHandleDispatchEvent: true, callBack: ({lastHref}, event) => { if(!this.matchUrl(lastHref, /\/studios\/\d+/)){ this._dispatchPageEvent(event); this.processTagger(); } this._listenForNonPageChanges({ selector: "#studio-edit", event: "stash:page:studio:edit", reRunGmMain: true, callback: () => this._detectReRenders ? this._dispatchPageEvent(event) : null }) } }, "stash:page:studio:scenes": { regex: /\/studios\/\d+\?/, handleDisplayView: true, }, "stash:page:studio:galleries": { regex: /\/studios\/\d+\/galleries/, handleDisplayView: true, }, "stash:page:studio:images": { regex: /\/studios\/\d+\/images/, handleDisplayView: true, }, "stash:page:studio:performers": { regex: /\/studios\/\d+\/performers/, handleDisplayView: true, }, "stash:page:studio:movies": { regex: /\/studios\/\d+\/movies/ }, "stash:page:studio:childstudios": { regex: /\/studios\/\d+\/childstudios/, handleDisplayView: true, }, // tags tab "stash:page:tags": { regex: /\/tags\?/, handleDisplayView: true, }, "stash:page:tag:new": { regex: /\/tags\/new/ }, "stash:page:tag": { regex: /\/tags\/\d+/, manuallyHandleDispatchEvent: true, callBack: ({lastHref}, event) => { if(!this.matchUrl(lastHref, /\/tags\/\d+/)){ this._dispatchPageEvent(event); this.processTagger(); } this._listenForNonPageChanges({ selector: "#tag-edit", event: "stash:page:tag:edit", reRunGmMain: true, callback: () => this._detectReRenders ? this._dispatchPageEvent(event) : null }) } }, "stash:page:tag:scenes": { regex: /\/tags\/\d+\?/, handleDisplayView: true, }, "stash:page:tag:galleries": { regex: /\/tags\/\d+\/galleries/, handleDisplayView: true, }, "stash:page:tag:images": { regex: /\/tags\/\d+\/images/, handleDisplayView: true, }, "stash:page:tag:markers": { regex: /\/tags\/\d+\/markers/ }, "stash:page:tag:performers": { regex: /\/tags\/\d+\/performers/, handleDisplayView: true, }, // settings page "stash:page:settings": { regex: /\/settings/, manuallyHandleDispatchEvent: true, callBack: ({lastHref}, event) => !this.matchUrl(lastHref, /\/settings/) ? this._dispatchPageEvent(event) : null }, "stash:page:settings:tasks": { regex: /\/settings\?tab=tasks/, callback: () => this.hidePluginTasks() }, "stash:page:settings:library": { regex: /\/settings\?tab=library/ }, "stash:page:settings:interface": { regex: /\/settings\?tab=interface/ }, "stash:page:settings:security": { regex: /\/settings\?tab=security/ }, "stash:page:settings:metadata-providers": { regex: /\/settings\?tab=metadata-providers/ }, "stash:page:settings:services": { regex: /\/settings\?tab=services/ }, "stash:page:settings:system": { regex: /\/settings\?tab=system/, callBack: () => this.createSettings() }, "stash:page:settings:plugins": { regex: /\/settings\?tab=plugins/ }, "stash:page:settings:logs": { regex: /\/settings\?tab=logs/ }, "stash:page:settings:tools": { regex: /\/settings\?tab=tools/ }, "stash:page:settings:changelog": { regex: /\/settings\?tab=changelog/ }, "stash:page:settings:about": { regex: /\/settings\?tab=about/ }, // stats page "stash:page:stats": { regex: /\/stats/ }, // home page "stash:page:home": { regex: /\/$/, callBack: () => this._listenForNonPageChanges({selector: ".recommendations-container-edit", event: "stash:page:home:edit", reRunGmMain: true}) }, } } gmMain(args) { const events = Object.keys(this._pageListeners) for (const event of events) { const {regex, callBack = async () => {}, manuallyHandleDispatchEvent = false, handleDisplayView = false} = this._pageListeners[event] let isDisplayViewPage = false let isListPage, isWallPage, isTaggerPage if (handleDisplayView) { isListPage = this.matchUrl(window.location.href, concatRegexp(regex, /.*disp=1/)) isWallPage = this.matchUrl(window.location.href, concatRegexp(regex, /.*disp=2/)) isTaggerPage = this.matchUrl(window.location.href, concatRegexp(regex, /.*disp=3/)) if (isListPage || isWallPage || isTaggerPage) isDisplayViewPage = true } const handleDisplayViewCondition = handleDisplayView !== true || (handleDisplayView && (!isDisplayViewPage || args.lastHref === "")) if (this.matchUrl(window.location.href, regex) && handleDisplayViewCondition) { if (!manuallyHandleDispatchEvent) this._dispatchPageEvent(event) callBack({...args, location: window.location}, event) } if (handleDisplayView) { if (isListPage) { this._dispatchPageEvent("stash:page:any:list", false); this._dispatchPageEvent(event + ":list"); } else if (isWallPage) { this._dispatchPageEvent("stash:page:any:wall", false); this._dispatchPageEvent(event + ":wall"); } else if (isTaggerPage) { this._dispatchPageEvent("stash:page:any:tagger", false); this._dispatchPageEvent(event + ":tagger"); } } } } addEventListeners(events, callback, ...options) { events.forEach((event) => { this.addEventListener(event, callback, ...options); }); } hidePluginTasks() { // hide userscript functions plugin tasks waitForElementByXpath("//div[@id='tasks-panel']//h3[text()='Userscript Functions']/ancestor::div[contains(@class, 'setting-group')]", (elementId, el) => { const tasks = el.querySelectorAll('.setting'); for (const task of tasks) { const taskName = task.querySelector('h3').innerText; task.classList.add(this.visiblePluginTasks.indexOf(taskName) === -1 ? 'd-none' : 'd-flex'); this.dispatchEvent(new CustomEvent('stash:plugin:task', { 'detail': { taskName, task } })); } }); } async updateConfigValueTask(sectionKey, propName, value) { return this.runPluginTask("userscript_functions", "Update Config Value", [{ "key": "section_key", "value": { "str": sectionKey } }, { "key": "prop_name", "value": { "str": propName } }, { "key": "value", "value": { "str": value } }]); } async getConfigValueTask(sectionKey, propName) { await this.runPluginTask("userscript_functions", "Get Config Value", [{ "key": "section_key", "value": { "str": sectionKey } }, { "key": "prop_name", "value": { "str": propName } }]); // poll logs until plugin task output appears const prefix = `[Plugin / Userscript Functions] get_config_value: [${sectionKey}][${propName}] =`; return this.pollLogsForMessage(prefix); } async pollLogsForMessage(prefix) { const reqTime = Date.now(); const reqData = { "variables": {}, "query": `query Logs { logs { time level message } }` }; await new Promise(r => setTimeout(r, 500)); let retries = 0; while (true) { const delay = 2 ** retries * 100; await new Promise(r => setTimeout(r, delay)); retries++; const logs = await this.callGQL(reqData); for (const log of logs.data.logs) { const logTime = Date.parse(log.time); if (logTime > reqTime && log.message.startsWith(prefix)) { return log.message.replace(prefix, '').trim(); } } if (retries >= 5) { throw `Poll logs failed for message: ${prefix}`; } } } processTagger() { waitForElementByXpath("//button[text()='Scrape All']", (xpath, el) => { this.dispatchEvent(new CustomEvent('tagger', { 'detail': el })); const searchItemContainer = document.querySelector('.tagger-container').lastChild; const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node?.classList?.contains('entity-name') && node.innerText.startsWith('Performer:')) { this.dispatchEvent(new CustomEvent('tagger:mutation:add:remoteperformer', { 'detail': { node, mutation } })); } else if (node?.classList?.contains('entity-name') && node.innerText.startsWith('Studio:')) { this.dispatchEvent(new CustomEvent('tagger:mutation:add:remotestudio', { 'detail': { node, mutation } })); } else if (node.tagName === 'SPAN' && node.innerText.startsWith('Matched:')) { this.dispatchEvent(new CustomEvent('tagger:mutation:add:local', { 'detail': { node, mutation } })); } else if (node.tagName === 'UL') { this.dispatchEvent(new CustomEvent('tagger:mutation:add:container', { 'detail': { node, mutation } })); } else if (node?.classList?.contains('col-lg-6')) { this.dispatchEvent(new CustomEvent('tagger:mutation:add:subcontainer', { 'detail': { node, mutation } })); } else if (node.tagName === 'H5') { // scene date this.dispatchEvent(new CustomEvent('tagger:mutation:add:date', { 'detail': { node, mutation } })); } else if (node.tagName === 'DIV' && node?.classList?.contains('d-flex') && node?.classList?.contains('flex-column')) { // scene stashid, url, details this.dispatchEvent(new CustomEvent('tagger:mutation:add:detailscontainer', { 'detail': { node, mutation } })); } else { this.dispatchEvent(new CustomEvent('tagger:mutation:add:other', { 'detail': { node, mutation } })); } }); }); this.dispatchEvent(new CustomEvent('tagger:mutations:searchitems', { 'detail': mutations })); }); observer.observe(searchItemContainer, { childList: true, subtree: true }); const taggerContainerHeader = document.querySelector('.tagger-container-header'); const taggerContainerHeaderObserver = new MutationObserver(mutations => { this.dispatchEvent(new CustomEvent('tagger:mutations:header', { 'detail': mutations })); }); taggerContainerHeaderObserver.observe(taggerContainerHeader, { childList: true, subtree: true }); for (const searchItem of document.querySelectorAll('.search-item')) { this.dispatchEvent(new CustomEvent('tagger:searchitem', { 'detail': searchItem })); } if (!document.getElementById('progress-bar')) { const progressBar = createElementFromHTML(`
`); progressBar.classList.add('progress'); progressBar.style.display = 'none'; taggerContainerHeader.appendChild(progressBar); } }); waitForElementByXpath("//div[@class='tagger-container-header']/div/div[@class='row']/h4[text()='Configuration']", (xpath, el) => { this.dispatchEvent(new CustomEvent('tagger:configuration', { 'detail': el })); }); } setProgress(value) { const progressBar = document.getElementById('progress-bar'); if (progressBar) { progressBar.firstChild.style.width = value + '%'; progressBar.style.display = (value <= 0 || value > 100) ? 'none' : 'flex'; } } processRemoteScenes(data) { if (data.data?.scrapeMultiScenes) { for (const matchResults of data.data.scrapeMultiScenes) { for (const scene of matchResults) { this.remoteScenes[scene.remote_site_id] = scene; } } } else if (data.data?.scrapeSingleScene) { for (const scene of data.data.scrapeSingleScene) { this.remoteScenes[scene.remote_site_id] = scene; } } } processScene(data) { if (data.data.findScene) { this.scenes[data.data.findScene.id] = data.data.findScene; } } processScenes(data) { if (data.data.findScenes?.scenes) { for (const scene of data.data.findScenes.scenes) { this.scenes[scene.id] = scene; } } } processStudios(data) { if (data.data.findStudios?.studios) { for (const studio of data.data.findStudios.studios) { this.studios[studio.id] = studio; } } } processPerformers(data) { if (data.data.findPerformers?.performers) { for (const performer of data.data.findPerformers.performers) { this.performers[performer.id] = performer; } } } processApiKey(data) { if (data.data.generateAPIKey != null && this.pluginVersion) { this.updateConfigValueTask('STASH', 'api_key', data.data.generateAPIKey); } } parseSearchItem(searchItem) { const urlNode = searchItem.querySelector('a.scene-link'); const url = new URL(urlNode.href); const id = url.pathname.replace('/scenes/', ''); const data = this.scenes[id]; const nameNode = searchItem.querySelector('a.scene-link > div.TruncatedText'); const name = nameNode.innerText; const queryInput = searchItem.querySelector('input.text-input'); const performerNodes = searchItem.querySelectorAll('.performer-tag-container'); return { urlNode, url, id, data, nameNode, name, queryInput, performerNodes } } parseSearchResultItem(searchResultItem) { const remoteUrlNode = searchResultItem.querySelector('.scene-details .optional-field .optional-field-content a'); const remoteId = remoteUrlNode?.href.split('/').pop(); const remoteUrl = remoteUrlNode?.href ? new URL(remoteUrlNode.href) : null; const remoteData = this.remoteScenes[remoteId]; const sceneDetailNodes = searchResultItem.querySelectorAll('.scene-details .optional-field .optional-field-content'); let urlNode = null; let detailsNode = null; for (const sceneDetailNode of sceneDetailNodes) { if (sceneDetailNode.innerText.startsWith('http') && (remoteUrlNode?.href !== sceneDetailNode.innerText)) { urlNode = sceneDetailNode; } else if (!sceneDetailNode.innerText.startsWith('http')) { detailsNode = sceneDetailNode; } } const imageNode = searchResultItem.querySelector('.scene-image-container .optional-field .optional-field-content'); const metadataNode = searchResultItem.querySelector('.scene-metadata'); const titleNode = metadataNode.querySelector('h4 .optional-field .optional-field-content'); const codeAndDateNodes = metadataNode.querySelectorAll('h5 .optional-field .optional-field-content'); let codeNode = null; let dateNode = null; for (const node of codeAndDateNodes) { if (node.textContent.includes('-')) { dateNode = node; } else { codeNode = node; } } const entityNodes = searchResultItem.querySelectorAll('.entity-name'); let studioNode = null; const performerNodes = []; for (const entityNode of entityNodes) { if (entityNode.innerText.startsWith('Studio:')) { studioNode = entityNode; } else if (entityNode.innerText.startsWith('Performer:')) { performerNodes.push(entityNode); } } const matchNodes = searchResultItem.querySelectorAll('div.col-lg-6 div.mt-2 div.row.no-gutters.my-2 span.ml-auto'); const matches = [] for (const matchNode of matchNodes) { let matchType = null; const entityNode = matchNode.parentElement.querySelector('.entity-name'); const matchName = matchNode.querySelector('.optional-field-content b').innerText; const remoteName = entityNode.querySelector('b').innerText; let data; if (entityNode.innerText.startsWith('Performer:')) { matchType = 'performer'; if (remoteData) { data = remoteData.performers.find(performer => performer.name === remoteName); } } else if (entityNode.innerText.startsWith('Studio:')) { matchType = 'studio'; if (remoteData) { data = remoteData.studio } } matches.push({ matchType, matchNode, entityNode, matchName, remoteName, data }); } return { remoteUrlNode, remoteId, remoteUrl, remoteData, urlNode, detailsNode, imageNode, titleNode, codeNode, dateNode, studioNode, performerNodes, matches } } } window.stash = new Stash(); function waitForElementQuerySelector(query, callBack, time) { time = (typeof time !== 'undefined') ? time : 100; window.setTimeout(() => { const element = document.querySelector(query); if (element) { callBack(query, element); } else { waitForElementQuerySelector(query, callBack, time); } }, time); } function waitForElementClass(elementId, callBack, time) { time = (typeof time !== 'undefined') ? time : 100; window.setTimeout(() => { const element = document.getElementsByClassName(elementId); if (element.length > 0) { callBack(elementId, element); } else { waitForElementClass(elementId, callBack, time); } }, time); } function waitForElementId(elementId, callBack, time) { time = (typeof time !== 'undefined') ? time : 100; window.setTimeout(() => { const element = document.getElementById(elementId); if (element != null) { callBack(elementId, element); } else { waitForElementId(elementId, callBack, time); } }, time); } function waitForElementByXpath(xpath, callBack, time) { time = (typeof time !== 'undefined') ? time : 100; window.setTimeout(() => { const element = getElementByXpath(xpath); if (element) { callBack(xpath, element); } else { waitForElementByXpath(xpath, callBack, time); } }, time); } function getElementByXpath(xpath, contextNode) { return document.evaluate(xpath, contextNode || document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; } function createElementFromHTML(htmlString) { const div = document.createElement('div'); div.innerHTML = htmlString.trim(); // Change this to div.childNodes to support multiple top-level nodes. return div.firstChild; } function getElementByXpath(xpath, contextNode) { return document.evaluate(xpath, contextNode || document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; } function getElementsByXpath(xpath, contextNode) { return document.evaluate(xpath, contextNode || document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); } function getClosestAncestor(el, selector, stopSelector) { let retval = null; while (el) { if (el.matches(selector)) { retval = el; break } else if (stopSelector && el.matches(stopSelector)) { break } el = el.parentElement; } return retval; } function setNativeValue(element, value) { const valueSetter = Object.getOwnPropertyDescriptor(element, 'value').set; const prototype = Object.getPrototypeOf(element); const prototypeValueSetter = Object.getOwnPropertyDescriptor(prototype, 'value').set; if (valueSetter && valueSetter !== prototypeValueSetter) { prototypeValueSetter.call(element, value); } else { valueSetter.call(element, value); } } function updateTextInput(element, value) { setNativeValue(element, value); element.dispatchEvent(new Event('input', { bubbles: true })); } function concatRegexp(reg, exp) { let flags = reg.flags + exp.flags; flags = Array.from(new Set(flags.split(''))).join(); return new RegExp(reg.source + exp.source, flags); } function sortElementChildren(node) { const items = node.childNodes; const itemsArr = []; for (const i in items) { if (items[i].nodeType == Node.ELEMENT_NODE) { // get rid of the whitespace text nodes itemsArr.push(items[i]); } } itemsArr.sort((a, b) => { return a.innerHTML == b.innerHTML ? 0 : (a.innerHTML > b.innerHTML ? 1 : -1); }); for (let i = 0; i < itemsArr.length; i++) { node.appendChild(itemsArr[i]); } } function xPathResultToArray(result) { let node = null; const nodes = []; while (node = result.iterateNext()) { nodes.push(node); } return nodes; } function createStatElement(container, title, heading) { const statEl = document.createElement('div'); statEl.classList.add('stats-element'); container.appendChild(statEl); const statTitle = document.createElement('p'); statTitle.classList.add('title'); statTitle.innerText = title; statEl.appendChild(statTitle); const statHeading = document.createElement('p'); statHeading.classList.add('heading'); statHeading.innerText = heading; statEl.appendChild(statHeading); } const reloadImg = url => fetch(url, { cache: 'reload', mode: 'no-cors' }) .then(() => document.body.querySelectorAll(`img[src='${url}']`) .forEach(img => img.src = url));