Greasy Fork is available in English.

AO3: Fic's Style, Blacklist, Bookmarks

Change font, size, width and background of a work + blacklist: hide works that contain certains tags or text, have too many tags/fandoms/relations/chapters/words and other options + fullscreen reading mode + bookmarks: save the position you stopped reading a fic + number of words for each chapter and estimated reading time

  1. // ==UserScript==
  2. // @name AO3: Fic's Style, Blacklist, Bookmarks
  3. // @namespace https://codeberg.org/schegge
  4. // @description Change font, size, width and background of a work + blacklist: hide works that contain certains tags or text, have too many tags/fandoms/relations/chapters/words and other options + fullscreen reading mode + bookmarks: save the position you stopped reading a fic + number of words for each chapter and estimated reading time
  5. // @version 3.6.2
  6. // @author Schegge
  7. // @match *://archiveofourown.org/*
  8. // @match *://www.archiveofourown.org/*
  9. // @grant GM_getValue
  10. // @grant GM_setValue
  11. // @grant GM.getValue
  12. // @grant GM.setValue
  13. // ==/UserScript==
  14.  
  15. // gm4 polyfill https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
  16. if (typeof GM == 'undefined') {
  17. this.GM = {};
  18. Object.entries({
  19. 'GM_getValue': 'getValue',
  20. 'GM_setValue': 'setValue'
  21. }).forEach(([oldKey, newKey]) => {
  22. let old = this[oldKey];
  23. if (old && (typeof GM[newKey] == 'undefined')) {
  24. GM[newKey] = function(...args) {
  25. return new Promise((resolve, reject) => {
  26. try { resolve(old.apply(this, args)); } catch (e) { reject(e); }
  27. });
  28. };
  29. }
  30. });
  31. }
  32.  
  33. (async function() {
  34. const SN = 'stblbm';
  35.  
  36. // check which page
  37. const Check = {
  38. // script version
  39. version: async function() {
  40. if (await getStorage('version', '1') !== 362) {
  41. setStorage('version', 362);
  42. return true;
  43. }
  44. return false;
  45. },
  46. // on search pages but not on personal user profile
  47. black: function() {
  48. let user = document.querySelector('#greeting .user a[href*="/users/"]') || false;
  49. user = user && window.location.pathname.includes(user.href.split('/users/')[1]);
  50. return document.querySelector('li.blurb.group:not(.collection):not(.tagset)') && !user;
  51. },
  52. // include /works/(numbers) and /works/(numbers)/chapters/(numbers)
  53. // and exclude /works/(whatever)navigate
  54. work: function() {
  55. return /\/works\/\d+(\/chapters\/\d+)?(?!.*navigate)/.test(window.location.pathname);
  56. },
  57. // Full Screen
  58. fullScreen: false
  59. };
  60.  
  61. // new version check
  62. if (await Check.version()) {
  63. document.body.insertAdjacentHTML('beforeend',
  64. `<div style="position: fixed; bottom: 3em; right: 3em; width: 35%; z-index: 999;
  65. font-size: .9em; background: #fff; padding: 1em; border: 1px solid #900;">
  66. <b>AO3: Fic's Style, Blacklist, Bookmarks</b> UPDATES (v3.6.2)<br><br> -- in the blacklist, added option "min fandoms": it hides works that has less that amount of fandoms listed
  67. <br><br>
  68. <span id="${SN}-close" style="cursor: pointer; color: #900;">close</span>
  69. </div>
  70. `);
  71. document.getElementById(`${SN}-close`).addEventListener('click', function() {
  72. this.parentElement.style.display = 'none';
  73. });
  74. }
  75.  
  76.  
  77. /** FEATURES **/
  78. const Feature = {
  79. style: true,
  80. book: true,
  81. black: true,
  82. wpm: 250
  83. };
  84. Object.assign(Feature, await getStorage('feature', '{}'));
  85.  
  86.  
  87. // Features' menu
  88. addCSS(`${SN}-menus`,
  89. `li[id|="${SN}"] a { cursor: pointer; }
  90. li[id|="${SN}"] .dropdown-menu li a.${SN}-save {
  91. color: #900!important; font-weight: bold; text-align: center;
  92. padding-bottom: 0.75em!important; }
  93. li[id|="${SN}"] .dropdown-menu input[type="number"],
  94. li[id |= "${SN}"] .dropdown-menu input[type="text"] {
  95. width: 3.5em; padding: 0 0 0 .2em; margin: 0; background: #fff; }
  96. li[id|="${SN}"] .dropdown-menu input[type="checkbox"] { margin: 0; }
  97. li[id|="${SN}"] .dropdown-menu textarea {
  98. font-size: .9em; line-height: 1.2em; min-height: 4em; padding: .3em;
  99. margin: .1em .5em; width: calc(100% - 1em); box-sizing: border-box; resize: vertical; }
  100. li[id|="${SN}"] .${SN}-opts {
  101. display: flex!important; flex-wrap: nowrap; align-items: center; }
  102. li[id|="${SN}"] .${SN}-opts span {
  103. width: 25%; flex: auto; font-size: .75em; text-transform: uppercase; padding: .3em 0; }
  104.  
  105. #${SN}-black .dropdown-menu { width: 28em; }
  106. #${SN}-black .dropdown-menu .${SN}-opts { text-align: center!important; }
  107. #${SN}-black .dropdown-menu input[type="text"] { width: 12em; }
  108.  
  109. #${SN}-book .${SN}-opts a:first-child { flex-grow: 1; font-size: .9em; }
  110. a.${SN}-book-delete { color: #900!important; }
  111. div[class*="${SN}-book"] a { margin: 1em .2em 0 0; font-size: .8em; cursor: pointer; }
  112. .${SN}-book-left {
  113. position: fixed; left: 0; bottom: 0; margin: 0 0 .8em .5em; z-index: 999; }
  114. .${SN}-book-top { text-align: right;}
  115. .${SN}-no-book { display: none!important; }
  116.  
  117. #${SN}-style {
  118. position: fixed; bottom: 0; right: 0; margin: 0 .5em .8em 0; padding: 0;
  119. border-radius: .3em; background-color: transparent; text-align: right;
  120. font-size: .9em; z-index: 999; }
  121. #${SN}-style:not(.${SN}-style-hide) { width: 25em; }
  122. #${SN}-style.${SN}-style-hide > div { display: none; }
  123. #${SN}-style > div {
  124. color: #000; background-color: #ddd; padding: 0 .5em; box-shadow: 1px 1px 3px -1px #444;
  125. margin: 0; border-radius: .2em; }
  126. #${SN}-style label {
  127. display: block; border-bottom: 1px solid #888; padding: .2em 0; margin: 0; }
  128. #${SN}-style input, #${SN}-style select {
  129. width: 50%; padding: 0; margin: 0 0 0 1em; vertical-align: middle; }
  130. #${SN}-style button { margin: .3em .2em; }
  131. #${SN}-style-button { padding: 0 .3em; }
  132.  
  133. .${SN}-words {
  134. font-size: .7em; color: inherit; font-family: consolas, monospace;
  135. text-transform: uppercase; text-align: center; margin: 3em 0 .5em; }`
  136. );
  137.  
  138. let featureMenu = document.createElement('li');
  139. featureMenu.id = `${SN}-feature`;
  140. featureMenu.className = 'dropdown';
  141. featureMenu.innerHTML = `<a style="font-weight: bold;">Features</a>
  142. <ul class="menu dropdown-menu">
  143. <li><a><input id="${SN}-feature-style" type="checkbox" ${
  144. Feature.style ? 'checked' : ''}> Styling</a></li>
  145. <li><a><input id="${SN}-feature-book" type="checkbox" ${
  146. Feature.book ? 'checked' : ''}> Bookmarks / Full Screen</a></li>
  147. <li><a><input id="${SN}-feature-black" type="checkbox" ${
  148. Feature.black ? 'checked' : ''}> Blacklist</a></li>
  149. <li><a><input id="${SN}-feature-wpm" type="number" min="0" max="1000" step="10" value="${
  150. Feature.wpm}"> Words per minute</a></li>
  151. <li><a class="${SN}-save" id="${SN}-feature-save">SAVE</a></li>
  152. </ul>`;
  153. document.querySelector('#header ul.primary.navigation.actions').appendChild(featureMenu);
  154.  
  155. document.getElementById(`${SN}-feature-save`).addEventListener('click', function() {
  156. Feature.style = document.getElementById(`${SN}-feature-style`).checked;
  157. Feature.book = document.getElementById(`${SN}-feature-book`).checked;
  158. Feature.black = document.getElementById(`${SN}-feature-black`).checked;
  159. let wpm = document.getElementById(`${SN}-feature-wpm`).value.trim();
  160. Feature.wpm = wpm ? Math.min(Math.max(parseInt(wpm), 0), 1000) : 0;
  161. setStorage('feature', Feature);
  162. this.textContent = 'SAVING...';
  163. window.location.replace(window.location.href);
  164. });
  165.  
  166.  
  167. // add estimated reading time for every fic found
  168. if (Feature.wpm) {
  169. for (let work of document.querySelectorAll('dl.stats dd.words')) {
  170. let numWords = work.textContent.replace(/,/g, '');
  171. work.insertAdjacentHTML('afterend', `<dt>Time:</dt><dd>${countTime(numWords)}</dd>`);
  172. }
  173. }
  174.  
  175.  
  176. /** BOOKMARKS **/
  177. if (Feature.book) {
  178. const Bookmarks = {
  179. list: [],
  180. getValues: async function() {
  181. this.list = await getStorage('bookmarks', '[]');
  182. },
  183. setValues: function() {
  184. setStorage('bookmarks', this.list);
  185. },
  186. fromBook: window.location.search === '?bookmark',
  187. getUrl: window.location.pathname.split('/works/')[1],
  188. getTitle: function() {
  189. let title = document.querySelector('#workskin .preface.group h2.title.heading')
  190. .textContent.trim().substring(0, 28);
  191. // get the number of the chapter if chapter by chapter
  192. if (this.getUrl.includes('/chapters/')) {
  193. title += ` (${
  194. document.querySelector('#chapters > .chapter > .chapter.preface.group > h3 > a')
  195. .textContent.replace('Chapter ', 'ch')
  196. })`;
  197. }
  198. return title;
  199. },
  200. getPosition: function() {
  201. let position = getScroll();
  202. // calculate % if chapter by chapter view or work completed (number/number is the same)
  203. if (window.location.pathname.includes('/chapters/') ||
  204. /(\d+)\/\1/.test(document.querySelector('dl.stats dd.chapters').textContent)) {
  205. position = (position / getDocHeight()).toFixed(4) + '%';
  206. }
  207. return position;
  208. },
  209. checkIfExist: function(what, link) {
  210. let url = link || this.getUrl;
  211. let found = false;
  212. for (let [index, bookmark] of this.list.entries()) {
  213. // check if the same fic already exists
  214. if (bookmark[0].split('/chapters/')[0] !== url.split('/chapters/')[0]) {
  215. continue;
  216. }
  217.  
  218. // i need the index to delete the old bookmark (for change or delete)
  219. if (what === 'cancel') {
  220. found = index;
  221. break;
  222. // check if the same chapter
  223. } else if (bookmark[0] === url) {
  224. // retrieve the bookmark position
  225. if (what === 'book') {
  226. found = bookmark[2];
  227. // if the bookmark is in %
  228. if (found.toString().includes('%')) {
  229. found = parseFloat(found.replace('%', '')) * getDocHeight();
  230. }
  231. } else {
  232. // just check if a bookmark exist
  233. found = true;
  234. }
  235. break;
  236. }
  237. }
  238. return found;
  239. },
  240. cancel: function(url) {
  241. let found = this.checkIfExist('cancel', url);
  242. // !== false because it can return 0 for the index
  243. if (found !== false) this.list.splice(found, 1);
  244. },
  245. getNew: function() {
  246. this.cancel();
  247. this.list.push([this.getUrl, this.getTitle(), this.getPosition()]);
  248. this.setValues();
  249. },
  250. html: function() {
  251. let bookMenu = document.createElement('li');
  252. bookMenu.id = `${SN}-book`;
  253. bookMenu.className = 'dropdown';
  254. bookMenu.innerHTML = '<a>Bookmarks</a>';
  255. let bookMenuDrop = document.createElement('ul');
  256. bookMenuDrop.className = 'menu dropdown-menu';
  257. bookMenu.appendChild(bookMenuDrop);
  258. document.querySelector('#header ul.primary.navigation.actions').appendChild(bookMenu);
  259.  
  260. if (this.list.length) {
  261. let self = this;
  262. let clickDelete = function() {
  263. self.cancel(this.getAttribute('data-url'));
  264. self.setValues();
  265. this.style.display = 'none';
  266. this.previousSibling.style.opacity = '.4';
  267. };
  268.  
  269. for (let item of this.list) {
  270. let bookMenuLi = document.createElement('li');
  271. bookMenuLi.className = `${SN}-opts`;
  272. bookMenuLi.innerHTML = `<a href="https://archiveofourown.org/works/${
  273. item[0]}?bookmark">${item[1]}</a>`;
  274. let bookMenuDelete = document.createElement('a');
  275. bookMenuDelete.className = `${SN}-book-delete`;
  276. bookMenuDelete.title = 'delete bookmark';
  277. bookMenuDelete.setAttribute('data-url', item[0]);
  278. bookMenuDelete.textContent = 'x';
  279. bookMenuDelete.addEventListener('click', clickDelete);
  280. bookMenuLi.appendChild(bookMenuDelete);
  281. bookMenuDrop.appendChild(bookMenuLi);
  282. }
  283. } else {
  284. bookMenuDrop.innerHTML = '<li><a>No bookmark yet.</a></li>';
  285. }
  286. }
  287. };
  288. await Bookmarks.getValues();
  289. Bookmarks.html();
  290.  
  291. // Fullscreen
  292. if (Check.work()) {
  293. let workskin = document.getElementById('workskin');
  294.  
  295. let ficTop = document.createElement('div');
  296. ficTop.className = `actions ${SN}-book-top`;
  297. let toFullScreen = document.createElement('a');
  298. toFullScreen.textContent = 'Full Screen';
  299. ficTop.appendChild(toFullScreen);
  300. workskin.insertAdjacentElement('afterbegin', ficTop);
  301.  
  302. // changes to create full screen
  303. let fullScreen = () => {
  304. if (Check.fullScreen) {
  305. window.location.replace(window.location.pathname);
  306. return;
  307. }
  308.  
  309. setScroll(0);
  310. Check.fullScreen = true;
  311. window.history.replaceState(null, '', '?bookmark');
  312.  
  313. addCSS(`${SN}-fullscreen`,
  314. `#outer.wrapper, div#outer.wrapper > * { display: none!important; }
  315. #workskin .preface { margin: 0; padding-bottom: 0; }
  316. div.preface .notes, div.preface .summary,
  317. div.preface .series, div.preface .children { min-height: 0; }
  318. div.preface .module { padding-bottom: 0; text-align: center; }
  319. .preface .module h3.heading {
  320. display: inline; cursor: pointer; text-align: center; opacity: .5;
  321. font-style: italic; font-size: 100%; }
  322. .preface .module > :not(h3) { display: none; }
  323. .preface h3 + p {
  324. border: 3px solid rgba(0, 0, 0, .1); border-left: 0; border-right: 0;
  325. padding: .6em; margin: 0; }
  326. .preface .module > h3:hover ~ .userstuff, .preface .module > .userstuff:hover,
  327. .preface .module > h3:hover ~ ul, .preface .module > ul:hover,
  328. .preface .module > h3:hover + p, .preface .module > h3 + p:hover {
  329. display: block!important; position: absolute; width: 100%; max-height: 6em;
  330. font-size: .8em; transform: translateY(-100%); color: rgb(42, 42, 42);
  331. background-color: #fff; padding: 10px; box-shadow: 0 1px 2px 0 rgba(0, 0, 0, .4);
  332. margin: 0; overflow: auto; z-index: 999; cursor: pointer; }
  333. .actions:not(div[class*="${SN}-book"]) li > a:not([href*="chapters"]):not([href="#workskin"]) {
  334. display: none; }
  335. .actions:not(div[class*="${SN}-book"]) { margin-top: 2em; }`
  336. );
  337.  
  338. document.body.appendChild(workskin);
  339. toFullScreen.textContent = 'Exit';
  340.  
  341. let goToBook = document.createElement('a');
  342. goToBook.textContent = 'Go to Bookmark';
  343. goToBook.addEventListener('click', () => {
  344. setScroll(Bookmarks.checkIfExist('book'));
  345. });
  346.  
  347. let ficLeft = document.createElement('div');
  348. ficLeft.className = `actions ${SN}-book-left`;
  349.  
  350. let deleteBook = document.createElement('a');
  351. deleteBook.title = 'delete bookmark';
  352. deleteBook.textContent = 'x';
  353. deleteBook.addEventListener('click', () => {
  354. Bookmarks.cancel();
  355. Bookmarks.setValues();
  356. goToBook.className = `${SN}-no-book`;
  357. deleteBook.className = `${SN}-no-book`;
  358. });
  359.  
  360. let newBook = document.createElement('a');
  361. newBook.title = 'new bookmark';
  362. newBook.textContent = '+';
  363. newBook.addEventListener('click', function() {
  364. Bookmarks.getNew();
  365. goToBook.className = '';
  366. deleteBook.className = '';
  367. this.textContent = 'saved';
  368. setTimeout(() => { this.textContent = '+'; }, 1000);
  369. });
  370.  
  371. if (!Bookmarks.checkIfExist()) {
  372. goToBook.className = `${SN}-no-book`;
  373. deleteBook.className = `${SN}-no-book`;
  374. }
  375.  
  376. ficTop.insertAdjacentElement('afterbegin', goToBook);
  377. ficLeft.appendChild(newBook);
  378. ficLeft.appendChild(deleteBook);
  379. document.body.appendChild(ficLeft);
  380.  
  381. document.querySelector('#feedback .actions a[href="#main"]').href = '#workskin';
  382. workskin.appendChild(document.querySelector('#feedback .actions'));
  383. };
  384. if (Bookmarks.fromBook) fullScreen();
  385. toFullScreen.addEventListener('click', fullScreen);
  386. } // END Check.work()
  387. } // END Feature.book
  388.  
  389.  
  390. /** FIC'S STYLE + WPM **/
  391. if (Check.work()) {
  392. if (Feature.style) {
  393. addCSS(`${SN}-generalstyle`,
  394. `#main div.wrapper { margin-bottom: 1em; }
  395. #workskin { margin: 0; max-width: none!important; }
  396. #workskin .notes, #workskin .summary, blockquote {
  397. font-size: inherit; font-family: inherit; }
  398. .preface a, #chapters a, .preface a:link, #chapters a:link, .preface a:visited,
  399. #chapters a:visited, .preface a:visited:hover, #chapters a:visited:hover {
  400. color: inherit !important; }
  401. .actions {
  402. font-family: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Helvetica, sans-serif;
  403. font-size: 14px; }
  404. .chapter .preface { border-top: 0; margin-bottom: 0; padding: 0 2em; }
  405. .chapter .preface[role="complementary"] { border-width: 0; margin: 0; }
  406. .preface.group, div.preface {
  407. color: inherit; background-color: inherit; margin-left: 0; margin-right: 0;
  408. padding: 0 2em; }
  409. #workskin #chapters .preface .userstuff p, #workskin .preface .userstuff p {
  410. margin: .1em auto; line-height: 1.1em; }
  411. div.preface .byline a, #workskin #chapters a, #chapters a:link, #chapters a:visited {
  412. color: inherit; }
  413. div.preface .notes, div.preface .summary, div.preface .series, div.preface .children {
  414. min-height: 0; }
  415. div.preface .jump { margin-top: 1em; font-size: .9em; }
  416. .preface blockquote {
  417. box-shadow: 0 0 0 2px rgba(0, 0, 0, .1), 0 0 0 2px rgba(255, 255, 255, .2);
  418. padding: .6em; margin: 0; }
  419. .preface h3.title {
  420. background: repeating-linear-gradient(45deg, rgba(0, 0, 0, .05), rgba(0, 0, 0, .1) 2px,
  421. rgba(255, 255, 255, .2) 2px, rgba(255, 255, 255, .2) 4px);
  422. padding: .6em; margin: 0; }
  423. .preface h3.heading { font-size: inherit; border-width: 0; }
  424. h3.title a { border: 0; font-style: italic; }
  425. div.preface .associations, .preface .notes h3+p {
  426. margin-bottom: 0; font-style: italic; font-size: .8em; }
  427. #workskin #chapters, #workskin #chapters .userstuff {
  428. width: 100%!important; box-sizing: border-box; }
  429. #workskin #chapters .userstuff, #workskin #chapters .userstuff p {
  430. font-family: inherit; }
  431. #workskin #chapters .userstuff br { display: block; margin-top: .6em; content: " "; }
  432. .userstuff hr {
  433. width: 100%; height: 2px; border: 0; margin: 1.5em 0;
  434. background-image: linear-gradient(90deg, transparent, rgba(0, 0, 0, .2), transparent),
  435. linear-gradient(90deg, transparent, rgba(255, 255, 255, .3), transparent); }
  436. #workskin #chapters .userstuff blockquote {
  437. padding-top: 1px; padding-bottom: 1px; margin: 0 .5em; font-size: inherit; }
  438. .userstuff img {
  439. max-width: 100%; height: auto; display: block; margin: auto; }`
  440. );
  441.  
  442. // CSS changes depending on the user
  443. const Styling = {
  444. opts: {
  445. fontName: 'Default',
  446. colors: 'light',
  447. textAlign: 'justify',
  448. fontSize: '100',
  449. margins: '7',
  450. lineSpacing: '5'
  451. },
  452. inputs: [
  453. // 0:id, 1:name, 2+:options
  454. ['fontName', 'Font', 'Default', 'Arial Black', 'Helvetica', 'Verdana', 'Segoe UI',
  455. 'Garamond', 'Georgia', 'Times New Roman', 'Consolas', 'Courier'],
  456. ['colors', 'Background', 'light', 'grey', 'sepia', 'dark', 'darkblue', 'black'],
  457. ['textAlign', 'Alignment', 'default', 'justify', 'left', 'center', 'right'],
  458. ['fontSize', 'Text Size', 100, 50, 300],
  459. ['margins', 'Page Margins', 7, 5, 40],
  460. ['lineSpacing', 'Line Spacing', 5, 3, 10]
  461. ],
  462. fonts: {
  463. 'inherit': 'inherit', // old default
  464. 'Default': 'inherit',
  465. 'Arial Black': 'Arial Black, Arial Bold, Gadget, sans-serif',
  466. 'Helvetica': 'Helvetica, Helvetica Neue, sans-serif',
  467. 'Verdana': 'Verdana, Tahoma, sans-serif',
  468. 'Segoe UI': 'Segoe UI, Trebuchet MS, sans-serif',
  469. 'Garamond': 'Garamond, Book Antiqua, Palatino, Baskerville, serif',
  470. 'Georgia': 'Georgia, serif',
  471. 'Times New Roman': 'Times New Roman, Times, serif',
  472. 'Consolas': 'Consolas, Lucida Console, monospace',
  473. 'Courier': 'Courier, Courier New, monospace'
  474. },
  475. colors: {
  476. // background, font color
  477. light: ['#ffffff', '#000000'],
  478. grey: ['#e6e6e6', '#111111'],
  479. sepia: ['#fbf0d9', '#54331b'],
  480. dark: ['#333333', '#e1e1e1'],
  481. darkblue: ['#282a36', '#f8f8e6'],
  482. black: ['#000000', '#ffffff']
  483. },
  484. getValues: async function() {
  485. Object.assign(this.opts, await getStorage('styling', '{}'));
  486. },
  487. setValues: function() {
  488. setStorage('styling', this.opts);
  489. addCSS(`${SN}-userstyle`,
  490. `#workskin {
  491. font-family: ${this.fonts[this.opts.fontName]};
  492. font-size: ${this.opts.fontSize/100}em;
  493. padding: 0 ${this.opts.margins}%;
  494. color: ${this.colors[this.opts.colors][1]};
  495. background-color: ${this.colors[this.opts.colors][0]};
  496. ${this.opts.textAlign === 'default' ? '' :`text-align: ${this.opts.textAlign};`}
  497. }
  498. #workskin #chapters .userstuff {
  499. line-height: ${this.opts.lineSpacing * 0.3}em;
  500. ${this.opts.textAlign === 'default' ? '' :`text-align: ${this.opts.textAlign};`}
  501. }
  502. #workskin #chapters .userstuff p {
  503. line-height: ${this.opts.lineSpacing * 0.3}em;
  504. margin: ${this.opts.lineSpacing * 0.5 - 1.4}em auto;
  505. ${this.opts.textAlign === 'default' ? '' :`text-align: ${this.opts.textAlign};`}
  506. }`
  507. );
  508. },
  509. html: function() {
  510. this.setValues();
  511.  
  512. // the options displayed on the page
  513. let styleMenu = document.createElement('div');
  514. styleMenu.id = `${SN}-style`;
  515. styleMenu.className = `${SN}-style-hide`;
  516.  
  517. let h = '';
  518. for (let input of this.inputs) {
  519. h += `<label>${input[1]}`;
  520. if (typeof input[2] === 'string') {
  521. h += `<select id="${input[0]}">`;
  522. for (let i = 2; i < input.length; i++) {
  523. h += `<option value="${input[i]}" ${
  524. input[i] === this.opts[input[0]] ? 'selected' : ''
  525. }>${input[i]}</option>`;
  526. }
  527. h += '</select>';
  528. } else {
  529. h += `<input type="range" min="${input[3]}" max="${input[4]}" id="${
  530. input[0]}" value="${this.opts[input[0]]}">`;
  531. }
  532. h += '</label>';
  533. }
  534. styleMenu.innerHTML = `<div>${h}<button id="${SN}-style-save">save</button>
  535. <button id="${SN}-style-reset">reset</button></div>
  536. <button id="${SN}-style-button">&#9776;</button>`;
  537.  
  538. document.body.appendChild(styleMenu);
  539.  
  540. document.getElementById(`${SN}-style-save`).addEventListener('click', () => {
  541. let pos = getScroll() / getDocHeight();
  542. for (let input of this.inputs) {
  543. this.opts[input[0]] = styleMenu.querySelector(`#${input[0]}`).value;
  544. }
  545. this.setValues();
  546. setScroll(pos * getDocHeight());
  547. });
  548.  
  549. document.getElementById(`${SN}-style-reset`).addEventListener('click', () => {
  550. let pos = getScroll() / getDocHeight();
  551. styleMenu.parentElement.removeChild(styleMenu);
  552. for (let input of this.inputs) {
  553. this.opts[input[0]] = input[2];
  554. }
  555. this.html();
  556. setScroll(pos * getDocHeight());
  557. });
  558.  
  559. document.getElementById(`${SN}-style-button`).addEventListener('click', function() {
  560. this.parentElement.className = this.parentElement.className ? '' : `${SN}-style-hide`;
  561. });
  562. }
  563. };
  564. await Styling.getValues();
  565. Styling.html();
  566.  
  567. } // END Feature.style
  568.  
  569.  
  570. // # words and time for every chapter, if the fic has chapters
  571. if (Feature.wpm) {
  572. for (let chapter of document.querySelectorAll('#chapters > .chapter > div.userstuff.module')) {
  573. // -2 because of hidden <h3>Chapter Text</h3>
  574. let numWords = chapter.textContent.replace(/['’‘-]/g, '').match(/\w+/g).length - 2;
  575. chapter.parentElement.insertAdjacentHTML('afterbegin',
  576. `<div class="${SN}-words">this chapter has ${numWords} words (time: ${
  577. countTime(numWords)})</div>`);
  578. }
  579. }
  580.  
  581. // remove all the non-breaking white spaces
  582. document.getElementById('chapters').innerHTML = document.getElementById('chapters')
  583. .innerHTML.replace(/&nbsp;/g, ' ');
  584.  
  585. } // END Check.work()
  586.  
  587.  
  588. /** BLACKLIST **/
  589. if (Feature.black && Check.black()) {
  590. addCSS(`${SN}-blacklisting`,
  591. `[data-${SN}-visibility="remove"],
  592. [data-${SN}-visibility="hide"] > :not(.header),
  593. [data-${SN}-visibility="hide"] > .header > :not(h4) { display: none!important; }
  594.  
  595. [data-${SN}-visibility="hide"] > .header,
  596. [data-${SN}-visibility="hide"] > .header > h4 {
  597. margin: 0!important; min-height: auto; font-size: .9em; font-style: italic; }
  598.  
  599. [data-${SN}-visibility="hide"] { opacity: .6; }
  600. [data-${SN}-visibility="hide"]::before {
  601. content: "\\2573 " attr(data-${SN}-reasons); font-size: .8em; }`
  602. );
  603.  
  604. const Blacklist = {
  605. lists : {
  606. Tag: [],
  607. Text: [],
  608. Author: []
  609. },
  610. opts: {
  611. show: true,
  612. pause: false,
  613. maxTags: 0,
  614. maxRelations: 0,
  615. minIncomplete: 0,
  616. minFandoms: 0,
  617. maxFandoms: 0,
  618. minChapters: 0,
  619. maxChapters: 0,
  620. minWords: 0,
  621. maxWords: 0,
  622. langs: ''
  623. },
  624. blurb: 'li.blurb.group',
  625. getValues: async function() {
  626. this.lists.Tag = await getStorage('blacklistTags', '[]');
  627. this.lists.Text = await getStorage('blacklistText', '[]');
  628. this.lists.Author = await getStorage('blacklistAuthors', '[]');
  629. Object.assign(this.opts, await getStorage('blacklistOpts', '{}'));
  630. },
  631. findTags: function(work) {
  632. return this.opts.maxTags &&
  633. work.querySelectorAll('.tag').length > this.opts.maxTags;
  634. },
  635. findRelations: function(work) {
  636. return this.opts.maxRelations &&
  637. work.querySelectorAll('.tags .relationships .tag').length > this.opts.maxRelations;
  638. },
  639. findLangs: function(work) {
  640. return this.opts.langs && work.querySelector('dd.language') &&
  641. !this.opts.langs.toLowerCase().includes(work.querySelector('dd.language').textContent.toLowerCase().trim());
  642. },
  643. getFandoms: function(work) {
  644. if ((this.opts.minFandoms || this.opts.maxFandoms) && work.querySelector('.header .fandoms .tag')) {
  645. let numFandoms = work.querySelectorAll('.header .fandoms .tag').length;
  646. if (this.opts.minFandoms && numFandoms < this.opts.minFandoms ||
  647. this.opts.maxFandoms && numFandoms > this.opts.maxFandoms) {
  648. return `Fandoms: ${numFandoms}`;
  649. }
  650. }
  651. return [];
  652. },
  653. getChapters: function(work) {
  654. if ((this.opts.minChapters || this.opts.maxChapters) &&
  655. work.querySelector('dd.chapters')) {
  656. let numCh = Number(work.querySelector('dd.chapters').textContent.split('/')[0]);
  657. if (this.opts.minChapters && numCh < this.opts.minChapters ||
  658. this.opts.maxChapters && numCh > this.opts.maxChapters) {
  659. return `Chapters: ${numCh}`;
  660. }
  661. }
  662. return [];
  663. },
  664. getWords: function(work) {
  665. if ((this.opts.minWords || this.opts.maxWords) && work.querySelector('dd.words')) {
  666. let numWords = Number(work.querySelector('dd.words').textContent.replace(/,/g, '')) / 1000;
  667. if (this.opts.minWords && numWords < this.opts.minWords ||
  668. this.opts.maxWords && numWords > this.opts.maxWords) {
  669. return `Words: ${Math.round(numWords * 1000)}`;
  670. }
  671. }
  672. return [];
  673. },
  674. getIncomplete: function(work) {
  675. if (this.opts.minIncomplete && work.querySelector('.required-tags .complete-no')) {
  676. // millisecs in an average month = 30.4days*24hrs*60mins*60secs*1000
  677. let updated = (
  678. Date.now() - new Date(work.querySelector('.datetime').textContent).getTime()
  679. ) / (30.4*24*60*60*1000);
  680. if (updated > this.opts.minIncomplete) {
  681. return `Updated: ${Math.floor(updated)}mnth ago`;
  682. }
  683. }
  684. return [];
  685. },
  686. ifMatch: function(elem, list, flag) {
  687. let found = false;
  688. for (let value of this.lists[list]) {
  689. let pattern = value.trim().replace(/[.+?^${}()|[\]\\]/g, '\\$&');
  690. if (!pattern) break;
  691.  
  692. // wildcard
  693. pattern = pattern.replace(/\*/g, '.*');
  694. // match 2 words in any order
  695. pattern = pattern.replace(/(.+)&&(.+)/, '(?=.*$1)(?=.*$2).*');
  696. // only otp
  697. if (elem.parent === 'relationships') {
  698. // to delete fandom's name in the tag
  699. elem.text = elem.text.replace(/\(.+\)$/, '');
  700.  
  701. pattern = pattern.replace(/(.+)&!(.+)/, '(?=.*\\/)((?=.*$1)(?!.*$2)|(?=.*$2)(?!.*$1)).*');
  702. }
  703.  
  704. let regex;
  705. if (flag === 'free') regex = new RegExp(pattern, 'i'); // for text
  706. else regex = new RegExp(`^${pattern}$`, 'i');
  707.  
  708. if (regex.test(elem.text)) {
  709. if (flag === 'free') found = `${list}: ${value}`; // show the rule that matched (for text)
  710. else if (elem.parent === 'heading') found = list; // show only list name (for author)
  711. else found = `${list}: ${elem.text}`; // show the entire matched tag
  712. break;
  713. }
  714. }
  715. return found;
  716. },
  717. getReasons: function(work, list, where, flag = '') {
  718. if (!this.lists[list].length) return [];
  719.  
  720. let filtered = [];
  721. for (let elem of work.querySelectorAll(where)) {
  722. let found = this.ifMatch({
  723. text: elem.textContent.trim(),
  724. parent: elem.parentElement.className
  725. }, list, flag);
  726. if (found) filtered.push(found);
  727. }
  728. return filtered;
  729. },
  730. setVisibility: function() {
  731. if (this.opts.pause) return;
  732. for (let work of document.querySelectorAll(this.blurb)) {
  733. let reasons = []
  734. .concat(this.getReasons(work, 'Author', 'h4.heading a[rel="author"]'))
  735. .concat(this.getIncomplete(work))
  736. .concat(this.getWords(work))
  737. .concat(this.getChapters(work))
  738. .concat(this.getFandoms(work))
  739. .concat(this.getReasons(work, 'Text', 'h4.heading a:first-child, .summary', 'free'))
  740. .concat(this.getReasons(work, 'Tag',
  741. '.tags .tag, .required-tags span:not(.warnings) span.text, .header .fandoms .tag'));
  742. if (this.findRelations(work)) reasons.unshift('Relations');
  743. if (this.findTags(work)) reasons.unshift('Tags');
  744. if (this.findLangs(work)) reasons.unshift('Language');
  745. if (!reasons.length) continue;
  746.  
  747. if (this.opts.show) {
  748. work.setAttribute(`data-${SN}-visibility`, 'hide');
  749. work.setAttribute(`data-${SN}-reasons`, reasons.join(' - '));
  750. } else {
  751. work.setAttribute(`data-${SN}-visibility`, 'remove');
  752. }
  753. }
  754. },
  755. getArray: function(string) {
  756. return string.trim() ? string.split(',').filter(s => s.trim().length) : [];
  757. },
  758. getInt: function(string, min = 0) {
  759. let number = string.trim() ? Math.max(parseInt(string), 0) : 0;
  760. if (number < min) number = 0;
  761. return number;
  762. },
  763. setValues: function() {
  764. this.lists.Tag = this.getArray(document.getElementById(`${SN}-black-tags`).value);
  765. this.lists.Text = this.getArray(document.getElementById(`${SN}-black-text`).value);
  766. this.lists.Author = this.getArray(document.getElementById(`${SN}-black-authors`).value);
  767. this.opts.maxTags = this.getInt(document.getElementById(`${SN}-black-maxTags`).value);
  768. this.opts.maxRelations = this.getInt(document.getElementById(`${SN}-black-maxRelations`).value);
  769. this.opts.minIncomplete = this.getInt(document.getElementById(`${SN}-black-minIncomplete`).value);
  770. this.opts.minFandoms = this.getInt(document.getElementById(`${SN}-black-minFandoms`).value);
  771. this.opts.maxFandoms = this.getInt(document.getElementById(`${SN}-black-maxFandoms`).value);
  772. this.opts.minChapters = this.getInt(document.getElementById(`${SN}-black-minChapters`).value);
  773. this.opts.maxChapters = this.getInt(document.getElementById(`${SN}-black-maxChapters`).value, this.opts.minChapters);
  774. this.opts.minWords = this.getInt(document.getElementById(`${SN}-black-minWords`).value);
  775. this.opts.maxWords = this.getInt(document.getElementById(`${SN}-black-maxWords`).value, this.opts.minWords);
  776. this.opts.langs = document.getElementById(`${SN}-black-langs`).value;
  777. this.opts.show = document.getElementById(`${SN}-black-show`).checked;
  778. this.opts.pause = document.getElementById(`${SN}-black-pause`).checked;
  779. setStorage('blacklistTags', this.lists.Tag);
  780. setStorage('blacklistText', this.lists.Text);
  781. setStorage('blacklistAuthors', this.lists.Author);
  782. setStorage('blacklistOpts', this.opts);
  783.  
  784. for (let el of document.querySelectorAll(`${this.blurb}[data-${SN}-visibility]`)) {
  785. el.removeAttribute(`data-${SN}-visibility`);
  786. el.removeAttribute(`data-${SN}-reasons`);
  787. }
  788.  
  789. this.setVisibility();
  790. },
  791. html: function() {
  792. let blackMenu = document.createElement('li');
  793. blackMenu.id = `${SN}-black`;
  794. blackMenu.className = 'dropdown';
  795. blackMenu.innerHTML = `<a>Blacklist</a>
  796. <ul class="menu dropdown-menu">
  797. <li>
  798. <a class="${SN}-save" id="${SN}-black-save">SAVE</a>
  799. </li><li class="${SN}-opts">
  800. <span>SHOW REASONS <input id="${SN}-black-show" type="checkbox" ${
  801. this.opts.show ? 'checked' : ''}></span>
  802. <span>PAUSE <input id="${SN}-black-pause" type="checkbox" ${
  803. this.opts.pause ? 'checked' : ''}></span>
  804. </li><li class="${SN}-opts">
  805. <span title="for works in progress only">updated<br>max
  806. <input id="${SN}-black-minIncomplete" type="number" min="0" step="1"
  807. title="in months" value="${this.opts.minIncomplete}"></span>
  808. <span>tags<br>max
  809. <input id="${SN}-black-maxTags" type="number" min="0" step="1" value="${
  810. this.opts.maxTags}"></span>
  811. <span>relations<br>max
  812. <input id="${SN}-black-maxRelations" type="number" min="0" step="1" value="${
  813. this.opts.maxRelations}"></span>
  814. </li><li class="${SN}-opts">
  815. <span>chapters<br>min
  816. <input id="${SN}-black-minChapters" type="number" min="0" step="1"
  817. value="${this.opts.minChapters}">
  818. max <input id="${SN}-black-maxChapters" type="number" min="0" step="1"
  819. value="${this.opts.maxChapters}"></span>
  820. <span>words<br>min
  821. <input id="${SN}-black-minWords" type="number" min="0" step="1"
  822. title="in thousands" value="${this.opts.minWords}">
  823. max <input id="${SN}-black-maxWords" type="number" min="0" step="1"
  824. title="in thousands" value="${this.opts.maxWords}"></span>
  825. </li><li class="${SN}-opts">
  826. <span>fandoms<br>min
  827. <input id="${SN}-black-minFandoms" type="number" min="0" step="1"
  828. value="${this.opts.minFandoms}">
  829. max <input id="${SN}-black-maxFandoms" type="number" min="0" step="1"
  830. value="${this.opts.maxFandoms}"></span>
  831. <span title="show only specified">languages<br>
  832. <input type="text" id="${SN}-black-langs" spellcheck="false"
  833. title="separate languages by a space" value="${this.opts.langs}"></span>
  834. </li><li>
  835. <span title="tags, fandoms, relations, characters, ratings, warnings, categories, status">tags</span>
  836. <textarea id="${SN}-black-tags" spellcheck="false">${
  837. this.lists.Tag.join(',')}</textarea>
  838. <span>titles, summaries</span>
  839. <textarea id="${SN}-black-text" spellcheck="false">${
  840. this.lists.Text.join(',')}</textarea>
  841. <span>authors</span>
  842. <textarea id="${SN}-black-authors" spellcheck="false">${
  843. this.lists.Author.join(',')}</textarea>
  844. </li><li class="${SN}-opts">
  845. <span title="comma">separator: ,</span>
  846. <span title="match zero or more of any character (letter, white space, symbol...)">wildcard: *</span>
  847. <span title="match two pair of words in any order">pair: &&</span>
  848. <span title="hide relationships that include only one person of your favourite ship (only for tags)">only otp: &!</span>
  849. </li>
  850. </ul>`;
  851. document.querySelector('#header ul.primary.navigation.actions').appendChild(blackMenu);
  852.  
  853. document.getElementById(`${SN}-black-save`).addEventListener('click', function() {
  854. Blacklist.setValues();
  855. this.textContent = 'SAVED';
  856. setTimeout(() => { this.textContent = 'SAVE'; }, 1000);
  857. });
  858. }
  859. };
  860. await Blacklist.getValues();
  861. Blacklist.setVisibility();
  862. Blacklist.html();
  863.  
  864. } // END Feature.black AND Check.black()
  865.  
  866.  
  867. /** GLOBAL FUNCTIONS **/
  868.  
  869. async function getStorage(key, def) {
  870. // def must be a string
  871. return JSON.parse(await GM.getValue(key, def));
  872. }
  873. function setStorage(key, value) {
  874. // value can be any type
  875. GM.setValue(key, value !== 'string' ? JSON.stringify(value) : value);
  876. }
  877.  
  878. function addCSS(id, css) {
  879. // unique id because of styling user changes
  880. if (document.querySelector(`style#${id}`)) {
  881. document.querySelector(`style#${id}`).textContent = css;
  882. } else {
  883. let style = document.createElement('style');
  884. style.id = id;
  885. style.textContent = css;
  886. document.getElementsByTagName('head')[0].appendChild(style);
  887. }
  888. }
  889.  
  890. function countTime(num) {
  891. // estimate reading time
  892. if (!num) return '?';
  893. num = Math.round(Number(num) / Feature.wpm);
  894. let h = Math.floor(num / 60);
  895. let m = num % 60;
  896. return `${h > 0 ? `${h}hr ` : ''}${m > 0 ? `${m}min` : ''}` || '<1min';
  897. }
  898.  
  899. function getScroll() {
  900. return Math.max(document.documentElement.scrollTop, window.scrollY, 0);
  901. }
  902. function setScroll(s) {
  903. window.scroll(0, s);
  904. }
  905. function getDocHeight() {
  906. return Math.max(document.documentElement.scrollHeight, document.documentElement.offsetHeight,
  907. document.body.scrollHeight, document.body.offsetHeight);
  908. }
  909.  
  910. })();