Anakunda / Mobilism: New releases quick lookup, unpaginated compact listing & filtering

// ==UserScript==
// @name         Mobilism: New releases quick lookup, unpaginated compact listing & filtering
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.03.2
// @description  Applies filering and endless compact listing of previously unread articles in Releases section. Makes browsing through newly added releases since last visit much more quicker. Supports adding ignore rule for each listed release.
// @author       Anakunda
// @copyright    2021, Anakunda (https://greasyfork.org/users/321857-anakunda)
// @license      GPL-3.0-or-later
// @match        https://forum.mobilism.org/portal.php?mode=articles&block=aapp*
// @match        https://forum.mobilism.me/portal.php?mode=articles&block=aapp*
// @iconurl      https://forum.mobilism.me/styles/shared/images/favicon.ico
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_setClipboard
// @grant        GM_openInTab
// @grant        GM_notification
// @grant        GM_registerMenuCommand
// @require      https://openuserjs.org/src/libs/Anakunda/xhrLib.min.js
// ==/UserScript==

'use strict';

let lastId = GM_getValue('latest_read'),
		androidVer = GM_getValue('android_version'),
		categoryBlacklist = GM_getValue('category_blacklist', [ ]),
		appBlacklist = GM_getValue('app_blacklist', [ ]),
		appWhitelist = GM_getValue('app_whitelist', [ ]),
		filtered = false;

const contextId = 'context-9833836a-99db-4654-b9c3-d3dc195ba41c';
let menu = document.createElement('menu');
menu.type = 'context';
menu.id = contextId;
const contextUpdater = evt => { menu = evt.currentTarget };
menu.innerHTML = '<menuitem label="Ignore this category" /><menuitem label="-" />';
menu.children[0].onclick = function(evt) {
	let a = menu || evt.relatedTarget || document.activeElement;
	if (!(a instanceof HTMLAnchorElement)) return false;
	let category = a.textContent.trim();
	if (categoryBlacklist.find(cat => cat.toLowerCase() == category.toLowerCase()) != undefined) return false; // already ignored
	categoryBlacklist.push(category);
	GM_setValue('category_blacklist', categoryBlacklist);
	alert('Successfully added to ignored categories: ' + category);
};
document.body.append(menu);

function isIgnored(title) {
	function matchRule(expr) {
		const rx = /^\/(.+)\/([dgimsuy]*)$/.exec(expr);
		if (rx != null) try { return new RegExp(rx[1], rx[2]).test(title) } catch(e) { console.warn(e) }
		return expr.startsWith('\x15') ? title.includes(expr.slice(1)) : title.toLowerCase().includes(expr.toLowerCase());
	}
	return !appWhitelist.some(matchRule) && appBlacklist.some(matchRule);
}

function addFilter(title) {
	let modal = document.createElement('div');
	modal.style = 'position: fixed; left: 0; top: 0; width: 100%; height: 100%; background-color: #0008;' +
		'opacity: 0; transition: opacity 0.15s linear;';
	modal.innerHTML = `
<form id="add-rule-form" style="background-color: darkslategray; font-size-adjust: 0.75; position: absolute; top: 30%; right: 10%; border-radius: 0.5em; padding: 20px 30px;">
	<div style="color: white; margin-bottom: 3em; font-size-adjust: 1; font-weight: bold;">Add exclusion rule as</div>
	<label style="color: white; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none;">
		<input name="rule-type" type="radio" value="plaintext" checked="true" title="All releases containing expression in their names will be excluded from the listing" style="margin: 5px 5px 5px 0; cursor: pointer;" />
		Plain text
	</label>
	<label style="margin-left: 2em; color: white; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none;">
		<input name="rule-type" type="radio" value="regexp" title="Expression must be written in correct regexp syntax (without surrounding slashes). All releases positively tested by compiled regexp will be excluded from the listing" style="margin: 5px 5px 5px 0px; cursor: pointer;" />
		Regular expression
	</label>
	<br>
	<label style="color: white; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none;">
		Expression:
		<input name="expression" type="text" style="width: 35em; height: 1.6em; font-size-adjust: 0.75; margin-left: 5px; margin-top: 1em;" />
	</label>
	<br>
	<label style="color: white; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none;">
		<input name="ignore-case" type="checkbox" style="margin: 1em 5px 0 0; cursor: pointer;" />
		Ignore case
	</label>
	<br>
	<input id="btn-cancel" type="button" value="Cancel" style="margin-top: 3em; float: right; padding: 5px 10px; font-size-adjust: 0.65; background-color: black; border: none; color: white;" />
	<input id="btn-add" type="button" value="Add to list" style="margin-top: 3em; float: right; padding: 5px 10px; font-size-adjust: 0.65; background-color: black; border: none; color: white; margin-right: 1em;" />
</form>
`;
	document.body.append(modal);
	let form = document.getElementById('add-rule-form'),
			radioPlain = form.querySelector('input[type="radio"][value="plaintext"]'),
			radioRegExp = form.querySelector('input[type="radio"][value="regexp"]'),
			expression = form.querySelector('input[type="text"][name="expression"]'),
			chkCaseless = form.querySelector('input[type="checkbox"][name="ignore-case"]'),
			btnAdd = form.querySelector('input#btn-add'),
			btnCancel = form.querySelector('input#btn-cancel'),
			exprTouched = false;
	if ([form, btnAdd, btnCancel, radioPlain, radioRegExp, expression, chkCaseless].some(elem => elem == null)) {
		console.warn('Dialog creation error');
		return;
	}
	expression.value = title;
	form.onclick = evt => { evt.stopPropagation() };
	expression.oninput = evt => { exprTouched = true };
	radioPlain.oninput = evt => { if (!exprTouched) expression.value = title };
	radioRegExp.oninput = evt => {
		if (!exprTouched) expression.value = '^(?:' + title.replace(/([\\\.\+\*\?\(\)\[\]\{\}\^\$\!])/g, '\\$1') + ')\\b';
	};
	btnAdd.onclick = function(evt) {
		let type = document.querySelector('form#add-rule-form input[name="rule-type"]:checked');
		if (type == null) {
			console.warn('Selected rule not found');
			return false;
		}
		let value = expression.value.trim();
		switch (type.value) {
			case 'plaintext':
				if (!value) return;
				if (!chkCaseless.checked) value = '\x15' + value;;
				if (appBlacklist.includes(value)) break;
				appBlacklist.push(value);
				GM_setValue('app_blacklist', appBlacklist);
				break;
			case 'regexp':
				try { new RegExp(value, 'i') } catch(e) {
					alert('RegExp syntax error: ' + e);
					return false;
				}
				if (!value) break;
				value = '/' + value + '/';
				if (chkCaseless.checked) value += 'i';
				if (appBlacklist.includes(value)) break;
				appBlacklist.push(value);
				GM_setValue('app_blacklist', appBlacklist);
				break;
			default:
				console.warn('Invalid rule type value:', type);
				return false;
		}
		modal.remove();
	};
	modal.onclick = btnCancel.onclick = evt => { modal.remove() };
	Promise.resolve(modal).then(elem => { elem.style.opacity = 1 });
}

function addIgnoreButton(tr, title) {
	if (!(tr instanceof HTMLTableRowElement)) return;
	let th = document.createElement('th');
	th.width = '2em';
	th.align = 'right';
	let a = document.createElement('a');
	a.textContent = '[X]';
	a.title = 'Not interested for this application?\n' +
		'Create ignore rule for it to not appear in cumulative listings from now on.';
	a.href = '#';
	a.onclick = function(evt) {
		addFilter(title ? [
			/\s+v(\d+(?:\.\d+)*)\b.*$/,
			/(?:\s+(\([^\(\)]+\)|\[[^\[\]]+\]|\{[^\{\}]+\}))+\s*$/,
		].reduce((acc, rx) => acc.replace(rx, ''), title) : '');
		return false;
	};
	th.append(a);
	tr.append(th);
}

function loadArticles(elem = null) {
	return lastId > 0 ? new Promise(function(resolve, reject) {
		if (elem instanceof HTMLElement) {
			elem.style.padding = '3px 9px';
			elem.style.color = 'white';
			elem.style.backgroundColor = 'red';
			elem.textContent = 'Scanning...';
		}
		let articles = [ ];
		function ignoreCategory(evt) {
			let a = menu || evt.relatedTarget || document.activeElement;
			if (!(a instanceof HTMLAnchorElement)) return false;
			let category = a.textContent.trim();
			if (categoryBlacklist.find(cat => cat.toLowerCase() == category.toLowerCase()) != undefined) return false; // already ignored
			categoryBlacklist.push(category);
			GM_setValue('category_blacklist', categoryBlacklist);
			alert('Successfully added to ignored categories: ' + category);
		}

		function loadPage(page) {
			let url = document.location.origin + '/portal.php?mode=articles&block=aapp';
			if (page > 0) url += '&start=' + (page - 1) * 8;
			if (elem instanceof HTMLElement) elem.textContent = 'Scanning...page ' + (page || 1);
			localXHR(url).then(function(document) {
				function finished() {
					if (elem instanceof HTMLElement) {
						elem.style.backgroundColor = 'green';
						elem.textContent = articles.length > 0 ? 'Showing ' + articles.length.toString() + ' unread articles'
							: 'No new articles found';
					}
					resolve(articles);
				}

				const articleIds = Array.from(document.body.querySelectorAll('div#wrapcentre > table > tbody > tr > td:last-of-type > table')).map(function(table) {
					for (var a of table.querySelectorAll('tr > td.postbody > a')) {
						let articleId = parseInt(new URLSearchParams(a.search).get('t'));
						if (articleId > 0) return articleId;
					}
				}).filter(articleId => articleId > 0);
				for (let table of document.body.querySelectorAll('div#wrapcentre > table > tbody > tr > td:last-of-type > table')) {
					let a, articleId;
					for (a of table.querySelectorAll('tr > td.postbody > a'))
						if ((articleId = parseInt(new URLSearchParams(a.search).get('t'))) > 0) break;
					console.assert(articleId > 0, 'articleId > 0', table, a, articleId);
					if (!(articleId > 0)) continue; else if (articleId <= lastId) {
						if (articleId < lastId) {
							console.log('Old article bumbed up:', articleId, '(', lastId, '), page', page || 1);
							continue;
						}
						return finished();
					}
					let td = table.querySelector('tr > td.postbody:first-of-type'), minAndroid;
					if (td != null && /\b(?:Requirements):\s+(?:(?:Android|A)\b\s*)?(\d+(?:\.\d+)?)\b\+?/i.test(td.textContent))
						minAndroid = parseFloat(RegExp.$1);
					for (var tr of table.querySelectorAll('tbody > tr:not(:first-of-type)')) tr.remove();
					function cleanElement(elem) {
						if (elem instanceof Node) for (let child of elem.childNodes)
							if (child.nodeType == Node.TEXT_NODE && !child.textContent.trim()) elem.removeChild(child);
					}
					let category, title;
					if ((a = table.querySelector('th[align="center"] > a')) != null) {
						category = a.textContent.trim();
						if (Array.isArray(categoryBlacklist)
								&& categoryBlacklist.find(cat => cat.toLowerCase() == category.toLowerCase()) != undefined) continue;
						a.oncontextmenu = contextUpdater;
						a.setAttribute('contextmenu', contextId);
					}
					tr = table.querySelector('tbody > tr:first-of-type');
					if ((th = table.querySelector('th[align="left"]')) != null) {
						if (isIgnored(title = th.textContent.trim())) continue;
						a = document.createElement('a');
						a.setAttribute('articleId', articleId);
						a.href = './viewtopic.php?t=' + articleId;
						a.target = '_blank';
						a.textContent = title;
						a.style = 'color: white !important; cursor: pointer;';
						while (th.firstChild != null) th.removeChild(th.firstChild);
						th.append(a);
					}
					if ((th = table.querySelector('th[align="center"] > a')) != null)
						th.style ='color: silver !important;';
					if ((th = table.querySelector('th[align="right"] > strong')) != null) {
						th.style = 'color: burlywood !important;';
						th.parentNode.style = 'color: silver !important;';
					}
					addIgnoreButton(tr, title);
					for (var th of table.querySelectorAll('tbody > tr > th')) {
						th.style.backgroundImage = 'none';
						th.style.backgroundColor = androidVer > 0 && minAndroid <= androidVer ? '#030'
							: androidVer > 0 && minAndroid > androidVer ? '#400' : 'darkslategray';
						cleanElement(th);
					}
					cleanElement(table);
					articles.push(table);
				}
				if (articleIds.length > 0 && articleIds.every(articleId => articleId <= lastId)) return finished();
				loadPage((page || 1) + 1);
			}).catch(function(reason) {
				if (elem instanceof HTMLElement) elem.textContent = 'Failed: ' + reason;
				return reject(reason);
			});
		}

		return loadPage();
	}) : Promise.reject('There\'s no last read mark');
}

function listUnread(elem = null) {
	if (!(lastId > 0)) {
		alert('You need to have previously marked all articles read to have checkpoint to stop scanning');
		return false;
	}
	let td = document.body.querySelector('div#wrapcentre > table > tbody > tr > td:last-of-type'), table;
	if (td == null) throw 'Invalid page structure';
	while (td.firstChild != null) td.removeChild(td.firstChild);
	while ((table = document.body.querySelector('div#wrapcentre > table[width="100%"]:nth-of-type(2)')) != null
		&& table.querySelector('p.breadcrumbs, p.datetime') == null) table.remove();
	loadArticles(elem).then(function(articles) {
		if (Array.isArray(articles)) for (let article of articles) td.append(article);
	});
}

function markAllRead(elem = null) {
	function scanPage(document) {
		console.assert(document instanceof HTMLDocument);
		GM_setValue('latest_read', Math.max(...Array.from(document.body.querySelectorAll('div#wrapcentre > table > tbody > tr > td:last-of-type > table')).map(function(table) {
			let articleId;
			for (let a of table.querySelectorAll('tr > td.postbody > a'))
				if ((articleId = parseInt(new URLSearchParams(a.search).get('t'))) > 0) break;
			if (!(articleId > 0)) {
				articleId = table.querySelector('th[align="left"] > a[articleId]');
				articleId = articleId != null ? parseInt(new URLSearchParams(articleId.search).get('t')) : undefined;
			}
			console.assert(articleId > 0, 'articleId > 0', articleId);
			return articleId;
		}).filter(id => id > 0)));
		if (elem != null) {
			elem.style.padding = '3px 9px';
			elem.style.color = 'white';
			elem.style.backgroundColor = 'green';
			elem.textContent = 'All releases marked as read, reloading page...';
		}
		window.document.location.assign(window.document.location.origin + '/portal.php?mode=articles&block=aapp');
	}

	// (!onfirm('Are yuo sure to mark everything read?')) return;
	if (filtered) scanPage(document);
		else localXHR(document.location.origin + '/portal.php?mode=articles&block=aapp').then(scanPage);
}

//GM_registerMenuCommand('Show unread posts in compact view', listUnread, 'S');
//GM_registerMenuCommand('Mark everything read', markAllRead, 'r');
for (let elem of document.querySelectorAll('div#wrapcentre > table:first-of-type > tbody > tr > td:first-of-type > div > iframe'))
	elem.parentNode.parentNode.removeChild(elem.parentNode);
let td = document.body.querySelector('div#menubar > table > tbody > tr:first-of-type > td[class^="row"]');
if (td != null) {
	let p = document.createElement('p');
	p.className = 'breadcrumbs';
	p.style = 'margin-right: 3em; float: right;';
	let a = document.createElement('a');
	a.textContent = 'Mark all releases read';
	a.href = '#';
	a.id = 'mark-all-read';
	a.onclick = function(evt) {
		markAllRead(evt.currentTarget);
		return false;
	};
	p.append(a);
	td.append(p);
	if (lastId > 0) {
		p = document.createElement('p');
		p.className = 'breadcrumbs';
		p.style = 'margin-right: 3em; float: right;';
		a = document.createElement('a');
		a.textContent = 'List only new releases';
		a.href = '#';
		a.id = 'list-only-new';
		a.onclick = function(evt) {
			listUnread(evt.currentTarget);
			return false;
		};
		p.append(a);
		td.append(p);
	} else markAllRead();
}

for (let tr of document.querySelectorAll('div#wrapcentre > table > tbody > tr > td > table > tbody > tr[class^="row"]')) {
	let a, articleId;
	for (a of tr.querySelectorAll('tr > td.postbody > a'))
		if ((articleId = parseInt(new URLSearchParams(a.search).get('t'))) > 0) break;
	console.assert(articleId > 0, 'articleId > 0', a, tr, articleId);
	if (!(articleId > 0)) continue;
	if (articleId <= lastId) tr.style.backgroundColor = '#dcd5c1';
	let title = tr.parentNode.querySelector('th[align="left"]');
	if (title == null) continue;
	if (isIgnored(title = title.textContent.trim())) {
		tr.parentNode.parentNode.style.opacity = 0.4;
		let th = document.createElement('th');
		th.width = '2em';
		th.align = 'right';
		a = document.createElement('a');
		a.textContent = '[+]';
		a.title = 'Not wanting to ignore this app furthermore, or ignored by mistake?';
		a.href = '#';
		a.onclick = function(evt) {
			let removed = [ ];
			for (let index = 0; index < appBlacklist.length; ++index) {
				let rx = /^\/(.+)\/([dgimsuy]*)$/.exec(appBlacklist[index]);
				if (rx != null) try { if (!new RegExp(rx[1], rx[2]).test(title)) continue } catch(e) {
					console.warn(e);
					continue;
				} else if (appBlacklist[index].startsWith('\x15') ? !title.includes(appBlacklist[index].slice(1))
						: !title.toLowerCase().includes(appBlacklist[index].toLowerCase())) continue;
				Array.prototype.push.apply(removed, appBlacklist.splice(index, 1));
			}
			if (removed.length > 0) {
				GM_setValue('app_blacklist', appBlacklist);
				alert('Rules removed:\n\n' + removed.join('\n'));
				document.location.reload();
			}
			return false;
		};
		th.append(a);
		tr.previousElementSibling.append(th);
	} else addIgnoreButton(tr.previousElementSibling, title);
	a = tr.parentNode.querySelector('th[align="center"] > a');
	if (a != null) {
		a.oncontextmenu = contextUpdater;
		a.setAttribute('contextmenu', contextId);
		let category = a.textContent.trim();
		if (Array.isArray(categoryBlacklist)
				&& categoryBlacklist.find(cat => cat.toLowerCase() == category.toLowerCase()) != undefined)
			tr.parentNode.parentNode.style.opacity = 0.4;
	}
}