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

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.

Από την 01/04/2021. Δείτε την τελευταία έκδοση.

// ==UserScript==
// @name         Mobilism: New releases quick lookup, unpaginated compact listing & filtering
// @namespace    https://greasyfork.org/users/321857-anakunda
// @version      1.01.1
// @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.js
// ==/UserScript==

'use strict';

let lastId = GM_getValue('latest_read'),
		ignoreRules = GM_getValue('app_ignore_rules', [ ]),
		androidVer = GM_getValue('android_version'),
		ignoredCategories = GM_getValue('ignored_categories', [ ]),
		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 (ignoredCategories.find(cat => cat.toLowerCase() == category.toLowerCase()) != undefined) return false; // already ignored
	ignoredCategories.push(category);
	GM_setValue('ignored_categories', ignoredCategories);
	alert('Successfully added to ignored categories: ' + category);
};
document.body.append(menu);

function isIgnored(title) {
	for (let expr of ignoreRules) {
		let rx = /^\/(.+)\/([dgimsuy]*)$/.exec(expr);
		if (rx != null) try {
			if (new RegExp(rx[1], rx[2]).test(title)) return true;
		} catch(e) {
			console.warn(e);
			continue;
		} else if (expr.startsWith('\x15') ? title.includes(expr.slice(1))
				: title.toLowerCase().includes(expr.toLowerCase())) return true;
	}
	return false;
}

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" checked="true" 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 = '\\b(?:' + 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 (ignoreRules.includes(value)) break;
				ignoreRules.push(value);
				GM_setValue('app_ignore_rules', ignoreRules);
				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 (ignoreRules.includes(value)) break;
				ignoreRules.push(value);
				GM_setValue('app_ignore_rules', ignoreRules);
				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 = 'Create ignore rule for this release';
	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 (ignoredCategories.find(cat => cat.toLowerCase() == category.toLowerCase()) != undefined) return false; // already ignored
			ignoredCategories.push(category);
			GM_setValue('ignored_categories', ignoredCategories);
			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) {
				for (let table of document.body.querySelectorAll('div#wrapcentre > table > tbody > tr > td:last-of-type > table')) {
					let articleId = table.querySelector('tr > td.postbody > a');
					articleId = articleId != null ? parseInt(new URLSearchParams(articleId.search).get('t')) : undefined;
					console.assert(articleId > 0, 'articleId > 0', table);
					if (!articleId) return; else if (articleId <= lastId) {
						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);
						return;
					}
					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 a, category, title;
					if ((a = table.querySelector('th[align="center"] > a')) != null) {
						category = a.textContent.trim();
						if (Array.isArray(ignoredCategories)
								&& ignoredCategories.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);
				}
				loadPage((page || 1) + 1);
			}).catch(reject);
		}

		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(articles => { articles.forEach(article => 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 a = table.querySelector('td.postbody > a') || table.querySelector('th[align="left"] > a[articleId]');
			return a != null ? parseInt(new URLSearchParams(a.search).get('t')) : undefined;
		}).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);
	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);
}

for (let tr of document.querySelectorAll('div#wrapcentre > table > tbody > tr > td > table > tbody > tr[class^="row"]')) {
	let id = tr.querySelector('td.postbody > a');
	if (id != null) id = parseInt(new URLSearchParams(id.search).get('t')); else return;
	if (id <= 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;
		else addIgnoreButton(tr.previousElementSibling, title);
	let 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(ignoredCategories)
				&& ignoredCategories.find(cat => cat.toLowerCase() == category.toLowerCase()) != undefined)
			tr.parentNode.parentNode.style.opacity = 0.4;
	}
}