Greasy Fork is available in English.

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.

Från och med 2021-04-01. Se den senaste versionen.

  1. // ==UserScript==
  2. // @name Mobilism: New releases quick lookup, unpaginated compact listing & filtering
  3. // @namespace https://greasyfork.org/users/321857-anakunda
  4. // @version 1.01.1
  5. // @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.
  6. // @author Anakunda
  7. // @copyright 2021, Anakunda (https://greasyfork.org/users/321857-anakunda)
  8. // @license GPL-3.0-or-later
  9. // @match https://forum.mobilism.org/portal.php?mode=articles&block=aapp*
  10. // @match https://forum.mobilism.me/portal.php?mode=articles&block=aapp*
  11. // @iconurl https://forum.mobilism.me/styles/shared/images/favicon.ico
  12. // @grant GM_getValue
  13. // @grant GM_setValue
  14. // @grant GM_deleteValue
  15. // @grant GM_setClipboard
  16. // @grant GM_openInTab
  17. // @grant GM_notification
  18. // @grant GM_registerMenuCommand
  19. // @require https://openuserjs.org/src/libs/Anakunda/xhrLib.js
  20. // ==/UserScript==
  21.  
  22. 'use strict';
  23.  
  24. let lastId = GM_getValue('latest_read'),
  25. ignoreRules = GM_getValue('app_ignore_rules', [ ]),
  26. androidVer = GM_getValue('android_version'),
  27. ignoredCategories = GM_getValue('ignored_categories', [ ]),
  28. filtered = false;
  29.  
  30. const contextId = 'context-9833836a-99db-4654-b9c3-d3dc195ba41c';
  31. let menu = document.createElement('menu');
  32. menu.type = 'context';
  33. menu.id = contextId;
  34. const contextUpdater = evt => { menu = evt.currentTarget };
  35. menu.innerHTML = '<menuitem label="Ignore this category" /><menuitem label="-" />';
  36. menu.children[0].onclick = function(evt) {
  37. let a = menu || evt.relatedTarget || document.activeElement;
  38. if (!(a instanceof HTMLAnchorElement)) return false;
  39. let category = a.textContent.trim();
  40. if (ignoredCategories.find(cat => cat.toLowerCase() == category.toLowerCase()) != undefined) return false; // already ignored
  41. ignoredCategories.push(category);
  42. GM_setValue('ignored_categories', ignoredCategories);
  43. alert('Successfully added to ignored categories: ' + category);
  44. };
  45. document.body.append(menu);
  46.  
  47. function isIgnored(title) {
  48. for (let expr of ignoreRules) {
  49. let rx = /^\/(.+)\/([dgimsuy]*)$/.exec(expr);
  50. if (rx != null) try {
  51. if (new RegExp(rx[1], rx[2]).test(title)) return true;
  52. } catch(e) {
  53. console.warn(e);
  54. continue;
  55. } else if (expr.startsWith('\x15') ? title.includes(expr.slice(1))
  56. : title.toLowerCase().includes(expr.toLowerCase())) return true;
  57. }
  58. return false;
  59. }
  60.  
  61. function addFilter(title) {
  62. let modal = document.createElement('div');
  63. modal.style = 'position: fixed; left: 0; top: 0; width: 100%; height: 100%; background-color: #0008;' +
  64. 'opacity: 0; transition: opacity 0.15s linear;';
  65. modal.innerHTML = `
  66. <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;">
  67. <div style="color: white; margin-bottom: 3em; font-size-adjust: 1; font-weight: bold;">Add exclusion rule as</div>
  68. <label style="color: white; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none;">
  69. <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;" />
  70. Plain text
  71. </label>
  72. <label style="margin-left: 2em; color: white; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none;">
  73. <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;" />
  74. Regular expression
  75. </label>
  76. <br>
  77. <label style="color: white; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none;">
  78. Expression:
  79. <input name="expression" type="text" style="width: 35em; height: 1.6em; font-size-adjust: 0.75; margin-left: 5px; margin-top: 1em;" />
  80. </label>
  81. <br>
  82. <label style="color: white; cursor: pointer; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; user-select: none;">
  83. <input name="ignore-case" type="checkbox" checked="true" style="margin: 1em 5px 0 0; cursor: pointer;" />
  84. Ignore case
  85. </label>
  86. <br>
  87. <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;" />
  88. <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;" />
  89. </form>
  90. `;
  91. document.body.append(modal);
  92. let form = document.getElementById('add-rule-form'),
  93. radioPlain = form.querySelector('input[type="radio"][value="plaintext"]'),
  94. radioRegExp = form.querySelector('input[type="radio"][value="regexp"]'),
  95. expression = form.querySelector('input[type="text"][name="expression"]'),
  96. chkCaseless = form.querySelector('input[type="checkbox"][name="ignore-case"]'),
  97. btnAdd = form.querySelector('input#btn-add'),
  98. btnCancel = form.querySelector('input#btn-cancel'),
  99. exprTouched = false;
  100. if ([form, btnAdd, btnCancel, radioPlain, radioRegExp, expression, chkCaseless].some(elem => elem == null)) {
  101. console.warn('Dialog creation error');
  102. return;
  103. }
  104. expression.value = title;
  105. form.onclick = evt => { evt.stopPropagation() };
  106. expression.oninput = evt => { exprTouched = true };
  107. radioPlain.oninput = evt => { if (!exprTouched) expression.value = title };
  108. radioRegExp.oninput = evt => {
  109. if (!exprTouched) expression.value = '\\b(?:' + title.replace(/([\\\.\+\*\?\(\)\[\]\{\}\^\$\!])/g, '\\$1') + ')\\b';
  110. };
  111. btnAdd.onclick = function(evt) {
  112. let type = document.querySelector('form#add-rule-form input[name="rule-type"]:checked');
  113. if (type == null) {
  114. console.warn('Selected rule not found');
  115. return false;
  116. }
  117. let value = expression.value.trim();
  118. switch (type.value) {
  119. case 'plaintext':
  120. if (!value) return;
  121. if (!chkCaseless.checked) value = '\x15' + value;;
  122. if (ignoreRules.includes(value)) break;
  123. ignoreRules.push(value);
  124. GM_setValue('app_ignore_rules', ignoreRules);
  125. break;
  126. case 'regexp':
  127. try { new RegExp(value, 'i') } catch(e) {
  128. alert('RegExp syntax error: ' + e);
  129. return false;
  130. }
  131. if (!value) break;
  132. value = '/' + value + '/';
  133. if (chkCaseless.checked) value += 'i';
  134. if (ignoreRules.includes(value)) break;
  135. ignoreRules.push(value);
  136. GM_setValue('app_ignore_rules', ignoreRules);
  137. break;
  138. default:
  139. console.warn('Invalid rule type value:', type);
  140. return false;
  141. }
  142. modal.remove();
  143. };
  144. modal.onclick = btnCancel.onclick = evt => { modal.remove() };
  145. Promise.resolve(modal).then(elem => { elem.style.opacity = 1 });
  146. }
  147.  
  148. function addIgnoreButton(tr, title) {
  149. if (!(tr instanceof HTMLTableRowElement)) return;
  150. let th = document.createElement('th');
  151. th.width = '2em';
  152. th.align = 'right';
  153. let a = document.createElement('a');
  154. a.textContent = '[X]';
  155. a.title = 'Create ignore rule for this release';
  156. a.href = '#';
  157. a.onclick = function(evt) {
  158. addFilter(title ? [
  159. /\s+v(\d+(?:\.\d+)*)\b.*$/,
  160. /(?:\s+(\([^\(\)]+\)|\[[^\[\]]+\]|\{[^\{\}]+\}))+\s*$/,
  161. ].reduce((acc, rx) => acc.replace(rx, ''), title) : '');
  162. return false;
  163. };
  164. th.append(a);
  165. tr.append(th);
  166. }
  167.  
  168. function loadArticles(elem = null) {
  169. return lastId > 0 ? new Promise(function(resolve, reject) {
  170. if (elem instanceof HTMLElement) {
  171. elem.style.padding = '3px 9px';
  172. elem.style.color = 'white';
  173. elem.style.backgroundColor = 'red';
  174. elem.textContent = 'Scanning...';
  175. }
  176. let articles = [ ];
  177. function ignoreCategory(evt) {
  178. let a = menu || evt.relatedTarget || document.activeElement;
  179. if (!(a instanceof HTMLAnchorElement)) return false;
  180. let category = a.textContent.trim();
  181. if (ignoredCategories.find(cat => cat.toLowerCase() == category.toLowerCase()) != undefined) return false; // already ignored
  182. ignoredCategories.push(category);
  183. GM_setValue('ignored_categories', ignoredCategories);
  184. alert('Successfully added to ignored categories: ' + category);
  185. }
  186.  
  187. function loadPage(page) {
  188. let url = document.location.origin + '/portal.php?mode=articles&block=aapp';
  189. if (page > 0) url += '&start=' + (page - 1) * 8;
  190. if (elem instanceof HTMLElement) elem.textContent = 'Scanning...page ' + (page || 1);
  191. localXHR(url).then(function(document) {
  192. for (let table of document.body.querySelectorAll('div#wrapcentre > table > tbody > tr > td:last-of-type > table')) {
  193. let articleId = table.querySelector('tr > td.postbody > a');
  194. articleId = articleId != null ? parseInt(new URLSearchParams(articleId.search).get('t')) : undefined;
  195. console.assert(articleId > 0, 'articleId > 0', table);
  196. if (!articleId) return; else if (articleId <= lastId) {
  197. if (elem instanceof HTMLElement) {
  198. elem.style.backgroundColor = 'green';
  199. elem.textContent = articles.length > 0 ? 'Showing ' + articles.length.toString() + ' unread articles'
  200. : 'No new articles found';
  201. }
  202. resolve(articles);
  203. return;
  204. }
  205. let td = table.querySelector('tr > td.postbody:first-of-type'), minAndroid;
  206. if (td != null && /\b(?:Requirements):\s+(?:(?:Android|A)\b\s*)?(\d+(?:\.\d+)?)\b\+?/i.test(td.textContent))
  207. minAndroid = parseFloat(RegExp.$1);
  208. for (var tr of table.querySelectorAll('tbody > tr:not(:first-of-type)')) tr.remove();
  209. function cleanElement(elem) {
  210. if (elem instanceof Node) for (let child of elem.childNodes)
  211. if (child.nodeType == Node.TEXT_NODE && !child.textContent.trim()) elem.removeChild(child);
  212. }
  213. let a, category, title;
  214. if ((a = table.querySelector('th[align="center"] > a')) != null) {
  215. category = a.textContent.trim();
  216. if (Array.isArray(ignoredCategories)
  217. && ignoredCategories.find(cat => cat.toLowerCase() == category.toLowerCase()) != undefined) continue;
  218. a.oncontextmenu = contextUpdater;
  219. a.setAttribute('contextmenu', contextId);
  220. }
  221. tr = table.querySelector('tbody > tr:first-of-type');
  222. if ((th = table.querySelector('th[align="left"]')) != null) {
  223. if (isIgnored(title = th.textContent.trim())) continue;
  224. a = document.createElement('a');
  225. a.setAttribute('articleId', articleId);
  226. a.href = './viewtopic.php?t=' + articleId;
  227. a.target = '_blank';
  228. a.textContent = title;
  229. a.style = 'color: white !important; cursor: pointer;';
  230. while (th.firstChild != null) th.removeChild(th.firstChild);
  231. th.append(a);
  232. }
  233. if ((th = table.querySelector('th[align="center"] > a')) != null)
  234. th.style ='color: silver !important;';
  235. if ((th = table.querySelector('th[align="right"] > strong')) != null) {
  236. th.style = 'color: burlywood !important;';
  237. th.parentNode.style = 'color: silver !important;';
  238. }
  239. addIgnoreButton(tr, title);
  240. for (var th of table.querySelectorAll('tbody > tr > th')) {
  241. th.style.backgroundImage = 'none';
  242. th.style.backgroundColor = androidVer > 0 && minAndroid <= androidVer ? '#030'
  243. : androidVer > 0 && minAndroid > androidVer ? '#400' : 'darkslategray';
  244. cleanElement(th);
  245. }
  246. cleanElement(table);
  247. articles.push(table);
  248. }
  249. loadPage((page || 1) + 1);
  250. }).catch(reject);
  251. }
  252.  
  253. return loadPage();
  254. }) : Promise.reject('There\'s no last read mark');
  255. }
  256.  
  257. function listUnread(elem = null) {
  258. if (!(lastId > 0)) {
  259. alert('You need to have previously marked all articles read to have checkpoint to stop scanning');
  260. return false;
  261. }
  262. let td = document.body.querySelector('div#wrapcentre > table > tbody > tr > td:last-of-type'), table;
  263. if (td == null) throw 'Invalid page structure';
  264. while (td.firstChild != null) td.removeChild(td.firstChild);
  265. while ((table = document.body.querySelector('div#wrapcentre > table[width="100%"]:nth-of-type(2)')) != null
  266. && table.querySelector('p.breadcrumbs, p.datetime') == null) table.remove();
  267. loadArticles(elem).then(articles => { articles.forEach(article => td.append(article)) });
  268. }
  269.  
  270. function markAllRead(elem = null) {
  271. function scanPage(document) {
  272. console.assert(document instanceof HTMLDocument);
  273. GM_setValue('latest_read', Math.max(...Array.from(document.body.querySelectorAll('div#wrapcentre > table > tbody > tr > td:last-of-type > table')).map(function(table) {
  274. let a = table.querySelector('td.postbody > a') || table.querySelector('th[align="left"] > a[articleId]');
  275. return a != null ? parseInt(new URLSearchParams(a.search).get('t')) : undefined;
  276. }).filter(id => id > 0)));
  277. if (elem != null) {
  278. elem.style.padding = '3px 9px';
  279. elem.style.color = 'white';
  280. elem.style.backgroundColor = 'green';
  281. elem.textContent = 'All releases marked as read, reloading page...';
  282. }
  283. window.document.location.assign(window.document.location.origin + '/portal.php?mode=articles&block=aapp');
  284. }
  285.  
  286. // (!onfirm('Are yuo sure to mark everything read?')) return;
  287. if (filtered) scanPage(document);
  288. else localXHR(document.location.origin + '/portal.php?mode=articles&block=aapp').then(scanPage);
  289. }
  290.  
  291. GM_registerMenuCommand('Show unread posts in compact view', listUnread, 'S');
  292. GM_registerMenuCommand('Mark everything read', markAllRead, 'r');
  293. for (let elem of document.querySelectorAll('div#wrapcentre > table:first-of-type > tbody > tr > td:first-of-type > div > iframe'))
  294. elem.parentNode.parentNode.removeChild(elem.parentNode);
  295. let td = document.body.querySelector('div#menubar > table > tbody > tr:first-of-type > td[class^="row"]');
  296. if (td != null) {
  297. let p = document.createElement('p');
  298. p.className = 'breadcrumbs';
  299. p.style = 'margin-right: 3em; float: right;';
  300. let a = document.createElement('a');
  301. a.textContent = 'Mark all releases read';
  302. a.href = '#';
  303. a.id = 'mark-all-read';
  304. a.onclick = function(evt) {
  305. markAllRead(evt.currentTarget);
  306. return false;
  307. };
  308. p.append(a);
  309. td.append(p);
  310. p = document.createElement('p');
  311. p.className = 'breadcrumbs';
  312. p.style = 'margin-right: 3em; float: right;';
  313. a = document.createElement('a');
  314. a.textContent = 'List only new releases';
  315. a.href = '#';
  316. a.id = 'list-only-new';
  317. a.onclick = function(evt) {
  318. listUnread(evt.currentTarget);
  319. return false;
  320. };
  321. p.append(a);
  322. td.append(p);
  323. }
  324.  
  325. for (let tr of document.querySelectorAll('div#wrapcentre > table > tbody > tr > td > table > tbody > tr[class^="row"]')) {
  326. let id = tr.querySelector('td.postbody > a');
  327. if (id != null) id = parseInt(new URLSearchParams(id.search).get('t')); else return;
  328. if (id <= lastId) tr.style.backgroundColor = '#dcd5c1';
  329. let title = tr.parentNode.querySelector('th[align="left"]');
  330. if (title == null) continue;
  331. if (isIgnored(title = title.textContent.trim())) tr.parentNode.parentNode.style.opacity = 0.4;
  332. else addIgnoreButton(tr.previousElementSibling, title);
  333. let a = tr.parentNode.querySelector('th[align="center"] > a');
  334. if (a != null) {
  335. a.oncontextmenu = contextUpdater;
  336. a.setAttribute('contextmenu', contextId);
  337. let category = a.textContent.trim();
  338. if (Array.isArray(ignoredCategories)
  339. && ignoredCategories.find(cat => cat.toLowerCase() == category.toLowerCase()) != undefined)
  340. tr.parentNode.parentNode.style.opacity = 0.4;
  341. }
  342. }