// ==UserScript== // @name VNDB Highlighter // @namespace https://github.com/MarvNC // @homepageURL https://github.com/MarvNC/vndb-highlighter // @match https://vndb.org/s* // @match https://vndb.org/p* // @match https://vndb.org/v* // @match https://vndb.org/c* // @match https://vndb.org/u*/edit // @version 1.65 // @author Marv // @downloadURL https://raw.githubusercontent.com/MarvNC/vndb-highlighter/main/vndb-list-highlighter.user.js // @updateURL https://raw.githubusercontent.com/MarvNC/vndb-highlighter/main/vndb-list-highlighter.user.js // @description Highlights and provides tooltips for known entries on VNDB. // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_getResourceText // @require https://unpkg.com/@popperjs/core@2.9.2/dist/umd/popper.min.js // @require https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/pickr.min.js // @resource pickrCSS https://cdn.jsdelivr.net/npm/@simonwep/pickr/dist/themes/classic.min.css // @run-at document-idle // ==/UserScript== let delayMs = 300; const fetchListMs = 600000; const updatePageMs = 86400000; const listExportUrl = (id) => `https://vndb.org/${id}/list-export/xml`; const types = { VN: 'loli', Settings: 'cute', Staff: { vnSelector: 'tr > td.tc1 > a', insertBefore: '#maincontent > .boxtitle', box: (novels, count) => `

On List (${count})

${novels}
Title Released Role/Cast As Note
`, }, CompanyVNs: { vnSelector: '#maincontent > div.mainbox > ul > li > a', insertBefore: '#maincontent > div:nth-child(3)', box: (novels, count) => `

On List (${count})

`, }, Releases: { vnSelector: 'tbody > tr.vn > td > a', insertBefore: '#maincontent > div:nth-child(3)', }, CompanyOther: '🩀', }; const defaultColors = { PlayingColor: 'rgba(168.72, 5.77, 189.48, 0.28)', FinishedColor: 'rgba(42.62, 210, 33, 0.25)', StalledColor: 'rgba(177.26, 191.93, 9.01, 0.24)', DroppedColor: 'rgba(210, 33, 33, 0.2)', WishlistColor: 'rgba(33, 210, 196.99, 0.26)', SubTextColor: 'rgba(29, 115, 176, 1)', }; const statusTypes = { Playing: 1, Finished: 2, Stalled: 3, Dropped: 4, Wishlist: 5, }; const addCSS = (colors) => /* css */ ` .listinfo{color:${colors.SubTextColor}!important;padding-left:15px;} .tooltip{display:none;z-index:999;text-align:left;} .tooltip[data-show]{display:block;} .pickerdiv{position:absolute;} .pcr-app{display:none!important;} .pcr-app.visible{display:block!important;} .mainbox.tooltip>.prodvns{column-width:auto!important;}`; let userIDelem = document.querySelector('#menulist > div:nth-child(3) > div > a:nth-child(1)'); let userID = userIDelem ? userIDelem.href.match(/u\d+/)[0] : null; let colors = GM_getValue('colors', duplicate(defaultColors)); GM_addStyle(addCSS(colors)); GM_addStyle( Object.keys(statusTypes) .map((listType) => `.colorbg.${listType}{background:${colors[listType + 'Color']}!important}`) .join('') ); GM_addStyle(GM_getResourceText('pickrCSS')); let vns; if (!GM_getValue('pages', null)) GM_setValue('pages', {}); (async function () { await updateUserList(); let type = getType(document.URL, document); if ([types.CompanyVNs, types.Releases, types.Staff].includes(type)) { getPage(document.URL, document, (info) => { let table = createElementFromHTML(info.table); let before = document.querySelector(type.insertBefore); before.parentElement.insertBefore(table, before); }); } else if (type == types.Settings) { let fieldset = document.querySelector('#maincontent > form > fieldset'); addPickerStuff(fieldset); let subTextPicker = Pickr.create(PickrOptions('.color-picker-0', colors.SubTextColor)); let listPickers = Object.keys(statusTypes).map((key) => { let pickerColor = key + 'Color'; return { picker: Pickr.create( PickrOptions('.color-picker-' + statusTypes[key], colors[pickerColor]) ), color: pickerColor, listType: key, }; }); let allPickers = [...listPickers, { picker: subTextPicker, color: 'SubTextColor' }]; // updates the elements' css styles on the page let updateColorsStyle = () => { listPickers.forEach(({ color: listColor, listType }) => { [...document.querySelectorAll('.colorbg.' + listType)].forEach((elem) => { elem.style.cssText = `background:${colors[listColor]}!important`; }); }); [...document.querySelectorAll('.listinfo')].forEach((elem) => { elem.style.cssText = `color:${colors.SubTextColor}!important`; }); }; let setPickerColors = () => { allPickers.forEach(({ picker, color }) => picker.setColor(colors[color])); updateColorsStyle(); }; setPickerColors(); document.querySelector('.saveColors').onclick = () => { GM_setValue('colors', colors); setPickerColors(); }; document.querySelector('.resetColors').onclick = () => { colors = GM_getValue('colors', duplicate(defaultColors)); setPickerColors(); }; document.querySelector('.resetDefaultColors').onclick = () => { colors = duplicate(defaultColors); setPickerColors(); }; document.querySelector('.clearCache').onclick = () => GM_setValue('pages', {}); document.querySelector('.getVNs').onclick = () => updateUserList(true); ['change', 'swatchselect', 'save', 'clear', 'cancel', 'hide', 'show'].forEach((colorEvent) => allPickers.forEach(({ picker, color: pickerColor }) => { picker.on(colorEvent, () => { colors[pickerColor] = picker.getColor().toRGBA().toString(2); updateColorsStyle(); }); }) ); } // make popups for all staff or producer links in the page let pages = [...document.querySelectorAll('a[href]')].filter((elem) => elem.href.match(/vndb.org\/[sp]\d+$/) ); for (let entryElem of pages) { let visible = false, tooltipLoaded = false; let span = document.createElement('span'); entryElem.append(span); let tooltip = createElementFromHTML(`

Fetching Data

`); tooltip.className += ' tooltip'; entryElem.prepend(tooltip); let makePopper = (parent, elem) => { let popperInstance = Popper.createPopper(parent, elem, { placement: 'top', }); // if moused over, prioritise getting info of that tooltip function show() { visible = true; if (!tooltipLoaded) { console.log('Requesting ' + entryElem.href); getPage(entryElem.href, null, (info) => {}, true); } elem.setAttribute('data-show', ''); popperInstance.update(); } function hide() { visible = false; elem.removeAttribute('data-show'); } const showEvents = ['mouseenter', 'focus']; const hideEvents = ['mouseleave', 'blur']; showEvents.forEach((event) => { parent.addEventListener(event, show); }); hideEvents.forEach((event) => { parent.addEventListener(event, hide); }); return show; }; makePopper(entryElem, tooltip); getPage(entryElem.href, null, (info) => { tooltipLoaded = true; let newTable; if (info.count > 0) { newTable = createElementFromHTML(info.table); span.innerText = ` (${info.count})`; } else { newTable = createElementFromHTML( `

No Novels on List (of ${info.total})

` ); } tooltip = entryElem.replaceChild(newTable, tooltip); tooltip = newTable; tooltip.className += ' tooltip'; let show = makePopper(entryElem, tooltip); if (visible) show(); }); } })(); let queue = []; let prioQueue = []; let resolvers = {}; let active = false; (async function () { while (true) { if (active || prioQueue.length > 0) { let currURL; if (prioQueue.length > 0) { currURL = prioQueue.shift(); queue = queue.filter((queueUrl) => queueUrl != currURL); console.log(`Priority: getting ${currURL}, waiting ${delayMs} ms`); } else if (queue.length > 0) { currURL = queue.shift(); console.log(`Getting ${currURL}: ${queue.length} pages remaining, waiting ${delayMs} ms`); } if (currURL) { let responseText = await getUrl(currURL); resolvers[currURL].forEach((resolver) => resolver(responseText)); } } await timer(delayMs); } })(); async function getUrl(url) { let response = await fetch(url); let waitMs = delayMs; while (!response.ok) { response = await fetch(url); waitMs *= 2; delayMs *= 1.2; delayMs = Math.round(delayMs); console.log('Failed response, new wait:' + waitMs); await timer(waitMs); } return await response.text(); } (async function () { while (true) { await timer(1000); let currPage = GM_getValue('currPage', null); console.log('current page: ', active, 'queue: ', queue.length); if (queue.length == 0) { console.log('finished fetching'); active = false; return; } if (currPage == null || currPage?.page == document.URL) { GM_setValue('currPage', { page: document.URL, lastUpdate: Date.now() }); active = true; } else { if (currPage != null && currPage?.page != document.URL) { if (currPage?.lastUpdate + 2000 < Date.now()) { GM_setValue('currPage', { page: document.URL, lastUpdate: Date.now() }); active = true; } else active = false; } } } })(); async function getPage(url, doc = null, updateInfo, priority = false) { let type, table, count = 0, total = 0; if (!doc) { if (url.match('vndb.org/p')) url = 'https://vndb.org/' + url.match(/p\d+/)[0] + '/vn'; if (GM_getValue('pages', null)[url]) { updateInfo(GM_getValue('pages', null)[url]); if (GM_getValue('pages', null)[url].lastUpdate + updatePageMs > Date.now()) return; } if (priority) { prioQueue.unshift(url); return; } doc = document.createElement('html'); let [promise, resolver] = createPromise(); if (resolvers[url]) resolvers[url].push(resolver); else { resolvers[url] = [resolver]; queue.push(url); } doc.innerHTML = await promise; } type = getType(url, doc); vns = GM_getValue('vns', null); // add highlights to vns on list let vnElems = [...doc.querySelectorAll(type.vnSelector)]; let novelelements = ''; vnElems.forEach((elem) => { let vnID = elem.href.split('/').pop(); if (vns[vnID] && vns[vnID].lists.length > 0) { let bgElem = type == types.Staff ? elem.parentElement.parentElement : elem.parentElement; bgElem.className += 'colorbg '; bgElem.className += vns[vnID].lists.join(' '); elem.innerHTML = `${elem.innerHTML} ${vns[vnID].lists.join(', ') + (vns[vnID].vote ? ' | Score: ' + vns[vnID].vote : '')} `; if (type == types.CompanyVNs) { novelelements += elem.parentElement.outerHTML; } else { novelelements += elem.parentElement.parentElement.outerHTML; } count++; } total++; }); if (type == types.Releases) { getPage(url, null, updateInfo, priority); return; } table = type.box(novelelements, count + '/' + total); updateInfo({ count, total, table }); let pages = GM_getValue('pages'); pages[url] = { count, total, lastUpdate: Date.now(), table }; GM_setValue('pages', pages); } function getType(url, doc) { if (url.match('vndb.org/s')) return types.Staff; else if (url.match('vndb.org/p')) { let text = doc.querySelectorAll('.tabselected')[1]; if (text?.innerText) { return text.innerText == 'Releases' ? types.Releases : types.CompanyVNs; } else return types.CompanyOther; } else if (url.match(/vndb.org\/[vc]/)) { return types.VN; } else if (url.match(/vndb.org\/u\d+\/edit/)) { return types.Settings; } } async function updateUserList(override = false) { console.log('Last List Fetch: ' + new Date(GM_getValue('lastFetch'))); if (GM_getValue('lastFetch', 0) + fetchListMs < Date.now() || override) { console.log('Fetching VN List'); GM_setValue('lastFetch', Date.now()); let response = await getUrl(listExportUrl(userID)); let parser = new DOMParser(); let xmlDoc = parser.parseFromString(response, 'text/xml'); let vnsList = [...xmlDoc.querySelectorAll('vndb-export > vns > vn')]; let vns = {}; vnsList.forEach((vn) => { vns[vn.id] = {}; vns[vn.id].title = vn.querySelector('title').innerHTML; vns[vn.id].lists = [...vn.querySelectorAll('label')].map( (label) => label.attributes.label.value ); let vote = vn.querySelector('vote'); vns[vn.id].vote = vote ? parseFloat(vote.innerHTML) : 0; }); GM_setValue('vns', vns); } } function createElementFromHTML(htmlString) { var div = document.createElement('div'); div.innerHTML = htmlString.trim(); return div.firstChild; } function timer(ms) { return new Promise((res) => setTimeout(res, ms)); } function PickrOptions(selector, defaultColor) { return { el: selector, theme: 'classic', default: defaultColor, inline: true, autoReposition: true, adjustableNumbers: true, swatches: [ 'rgba(244, 67, 54, 1)', 'rgba(233, 30, 99, 0.95)', 'rgba(156, 39, 176, 0.9)', 'rgba(103, 58, 183, 0.85)', 'rgba(63, 81, 181, 0.8)', 'rgba(33, 150, 243, 0.75)', 'rgba(3, 169, 244, 0.7)', 'rgba(0, 188, 212, 0.7)', 'rgba(0, 150, 136, 0.75)', 'rgba(76, 175, 80, 0.8)', 'rgba(139, 195, 74, 0.85)', 'rgba(205, 220, 57, 0.9)', 'rgba(255, 235, 59, 0.95)', 'rgba(255, 193, 7, 1)', ], components: { // Main components preview: true, opacity: true, hue: true, // Input / output Options interaction: { hex: true, rgba: true, hsla: true, hsva: true, cmyk: true, input: true, clear: false, save: false, }, }, }; } function addPickerStuff(fieldset) { fieldset.append( createElementFromHTML(`

List Highlighter

List
Pick Colors
List Text Color Playing Color Finished Color Stalled Color Dropped Color Wishlist Color
`) ); fieldset.append( createElementFromHTML(`

Test List

Title Released Role/Cast As Note
Ama Koi Syrups ~Hajirau Koigokoro de Shitaku Naru Amagami...2015-02-27KokoroHaruka Sora
Ama Koi Syrups ~Hajirau Koigokoro de Shitaku Naru Amagami...2015-02-27Manjuu-samaHaruka Sora
Sanoba Witch Finished, Voted | Score: 10 2015-02-27Inaba MeguruHaruka Sora
Hanikami Clover2016-01-29Suou EmiruHaruka Sora
Mamiya-kunchi no Itsutsugo Jijou2016-02-26Shijouin RirikaHaruka Sora
Nora to Oujo to Noraneko Heart Wishlist, Wishlist-Medium 2016-02-26Kuroki MichiHaruka Sora
Toki o Tsumugu Yakusoku2016-03-25Sawamura YuiHaruka Sora
Maji de Watashi ni Koishinasai! A-52016-04-26Sheila ColomboHaruka Sora
D.S. -Dal Segno- Dropped 2016-04-28Kouzuki IoHaruka Sora
Crank In2017-08-31Murakumo NozomuSendai Eri
Nora to Oujo to Noraneko Heart 22017-10-27Kuroki MichiHaruka Sora
Kin'iro Loveriche Playing 2017-12-22Kisaki ReinaHaruka Sora
Shuffle! Episode 2 ~Kami ni mo Akuma ni mo Nerawareteiru ...2020-05-29LimestoneHaruka Sora
Waga Himegimi ni Eikan o2021-03-26YmirHaruka Sora
Yuukyuu no CampanellaStalled2021-07-30Charlotte vie AtrustiaHaruka Sora
`) ); } function duplicate(obj) { return JSON.parse(JSON.stringify(obj)); } function createPromise() { let resolver; return [ new Promise((resolve, reject) => { resolver = resolve; }), resolver, ]; }