// ==UserScript==
// @name AO3: Fic's Style, Blacklist, Bookmarks
// @namespace https://codeberg.org/schegge
// @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
// @version 3.6.3
// @author Schegge
// @match *://archiveofourown.org/*
// @match *://www.archiveofourown.org/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM.getValue
// @grant GM.setValue
// ==/UserScript==
// gm4 polyfill https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
if (typeof GM == 'undefined') {
this.GM = {};
Object.entries({
'GM_getValue': 'getValue',
'GM_setValue': 'setValue'
}).forEach(([oldKey, newKey]) => {
let old = this[oldKey];
if (old && (typeof GM[newKey] == 'undefined')) {
GM[newKey] = function(...args) {
return new Promise((resolve, reject) => {
try { resolve(old.apply(this, args)); } catch (e) { reject(e); }
});
};
}
});
}
(async function() {
const SN = 'stblbm';
// check which page
const Check = {
// script version
version: async function() {
if (await getStorage('version', '1') !== 363) {
setStorage('version', 363);
return true;
}
return false;
},
// on search pages but not on personal user profile
black: function() {
let user = document.querySelector('#greeting .user a[href*="/users/"]') || false;
user = user && window.location.pathname.includes(user.href.split('/users/')[1]);
return document.querySelector('li.blurb.group:not(.collection):not(.tagset):not(.skins)') && !user;
},
// include /works/(numbers) and /works/(numbers)/chapters/(numbers)
// and exclude /works/(whatever)navigate
work: function() {
return /\/works\/\d+(\/chapters\/\d+)?(?!.*navigate)/.test(window.location.pathname);
},
// Full Screen
fullScreen: false
};
// new version check
if (await Check.version()) {
document.body.insertAdjacentHTML('beforeend',
`<div style="position: fixed; bottom: 3em; right: 3em; width: min(calc(100% - 8em), 44em); z-index: 999;
font-size: .9em; color: #000; background: #fff; padding: 1em; border: 1px solid #900;">
<b>AO3: Fic's Style, Blacklist, Bookmarks</b> UPDATES (v3.6.3)<br><br>
- you can now add tags and authors to your blacklist with <b>ALT + click</b> on the tag/author's name;<br>
- bug fixed: the text in the blacklist menu is now visible also on the 'Reversi' site skin;<br>
- minor changes for better compatibility on mobile.
<br><br><span id="${SN}-close" style="cursor: pointer; color: #900;">close</span>
</div>
`);
document.getElementById(`${SN}-close`).addEventListener('click', function() {
this.parentElement.style.display = 'none';
});
}
/** FEATURES **/
const Feature = {
style: true,
book: true,
black: true,
wpm: 250
};
Object.assign(Feature, await getStorage('feature', '{}'));
// Features' menu
addCSS(`${SN}-menus`, `/* Menu */
li[id|="${SN}"] a { cursor: pointer; }
li[id|="${SN}"] .dropdown-menu li a.${SN}-save {
color: #900 !important; font-weight: bold; text-align: center;
padding-bottom: 0.75em !important; }
li[id|="${SN}"] .dropdown-menu input[type="number"],
li[id |= "${SN}"] .dropdown-menu input[type="text"] {
width: 3.5em; padding: 0 0 0 .2em; margin: 0; }
li[id|="${SN}"] .dropdown-menu input[type="checkbox"] { margin: 0; }
li[id|="${SN}"] .dropdown-menu textarea {
font-size: .9em; line-height: 1.3em; min-height: 4em; padding: .3em;
margin: .1em .5em; width: calc(100% - 1em); box-sizing: border-box; resize: vertical; }
li[id|="${SN}"] .${SN}-opts {
display: flex !important; flex-wrap: nowrap; align-items: center; }
#${SN}-black .dropdown-menu { min-width: min(28em, 100vw); }
#${SN}-black .dropdown-menu .${SN}-opts { text-align: center !important; }
#${SN}-black .dropdown-menu .${SN}-opts > a,
#${SN}-black .dropdown-menu .${SN}-optsFull > a {
height: auto; font-size: .8em !important;
text-transform: uppercase; padding: .3em 0 !important; margin: 0 !important;
cursor: default; }
#${SN}-black .dropdown-menu .${SN}-opts > a { width: 25%; flex: auto; }
#${SN}-black .dropdown-menu input[type="text"] { width: 12em; }
#${SN}-black-tags { height: 6em; }
#${SN}-book .${SN}-opts a:first-child { flex-grow: 1; font-size: .9em; }
a.${SN}-book-delete { color: #900 !important; }
div[class*="${SN}-book"] a { margin: 1em .2em 0 0; font-size: .8em; cursor: pointer; }
.${SN}-book-left {
position: fixed; left: 0; bottom: 0; margin: 0 0 .8em .5em; z-index: 999; }
.${SN}-book-top { text-align: right;}
.${SN}-no-book { display: none !important; }
#${SN}-style {
position: fixed !important; bottom: 0; right: 0; margin: 0 1em 1em 0;
padding: 0; text-align: right; font-size: .8rem; z-index: 999 !important;
font-family: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Helvetica, sans-serif, 'GNU Unifont'; }
#${SN}-style:not(.${SN}-style-hide) { width: min(28em, calc(100% - 2em)); }
#${SN}-style:not(.${SN}-style-hide) > ul {
display: block !important; padding: .5em; margin: 0; border-radius: .3em;
background-color: #ddd; color: #111; box-shadow: inset 0 0 5px #999; }
#${SN}-style.${SN}-style-hide > ul { display: none; }
#${SN}-style li { padding: .4em 0 .2em; margin: 0; border-bottom: 1px solid #888; }
#${SN}-style input, #${SN}-style select { width: 60%; vertical-align: middle; }
#${SN}-style button { margin: .3em .2em; }
#${SN}-style-button { padding: 0 .3em; font-size: 1.2em !important; }
.${SN}-words {
font-size: .7em; color: inherit; font-family: consolas, monospace;
text-transform: uppercase; text-align: center; margin: 3em 0 .5em; }`
);
let featureMenu = document.createElement('li');
featureMenu.id = `${SN}-feature`;
featureMenu.className = 'dropdown';
featureMenu.setAttribute('aria-haspopup', 'true');
featureMenu.innerHTML = `<a class="dropdown-toggle" data-toggle="dropdown" data-target="#"
style="font-weight: bold;">Features</a>
<ul class="menu dropdown-menu">
<li><a><input id="${SN}-feature-style" type="checkbox" ${
Feature.style ? 'checked' : ''}> Styling</a></li>
<li><a><input id="${SN}-feature-book" type="checkbox" ${
Feature.book ? 'checked' : ''}> Bookmarks / Full Screen</a></li>
<li><a><input id="${SN}-feature-black" type="checkbox" ${
Feature.black ? 'checked' : ''}> Blacklist</a></li>
<li><a><input id="${SN}-feature-wpm" type="number" min="0" max="1000" step="10" value="${
Feature.wpm}"> Words per minute</a></li>
<li><a class="${SN}-save" id="${SN}-feature-save">SAVE</a></li>
</ul>`;
document.querySelector('#header ul.primary.navigation.actions').appendChild(featureMenu);
document.getElementById(`${SN}-feature-save`).addEventListener('click', function() {
Feature.style = document.getElementById(`${SN}-feature-style`).checked;
Feature.book = document.getElementById(`${SN}-feature-book`).checked;
Feature.black = document.getElementById(`${SN}-feature-black`).checked;
let wpm = document.getElementById(`${SN}-feature-wpm`).value.trim();
Feature.wpm = wpm ? Math.min(Math.max(parseInt(wpm), 0), 1000) : 0;
setStorage('feature', Feature);
this.textContent = 'SAVING...';
window.location.reload();
});
// add estimated reading time for every fic found
if (Feature.wpm) {
for (let work of document.querySelectorAll('dl.stats dd.words')) {
let numWords = work.textContent.replace(/,/g, '');
work.insertAdjacentHTML('afterend', `<dt>Time:</dt><dd>${countTime(numWords)}</dd>`);
}
}
/** BOOKMARKS **/
if (Feature.book) {
const Bookmarks = {
list: [],
getValues: async function() {
this.list = await getStorage('bookmarks', '[]');
},
setValues: function() {
setStorage('bookmarks', this.list);
},
fromBook: window.location.search === '?bookmark',
getUrl: window.location.pathname.split('/works/')[1],
getTitle: function() {
let title = document.querySelector('#workskin .preface.group h2.title.heading')
.textContent.trim().substring(0, 28);
// get the number of the chapter if chapter by chapter
if (this.getUrl.includes('/chapters/')) {
title += ` (${
document.querySelector('#chapters > .chapter > .chapter.preface.group > h3 > a')
.textContent.replace('Chapter ', 'ch')
})`;
}
return title;
},
getPosition: function() {
let position = getScroll();
// calculate % if chapter by chapter view or work completed (number/number is the same)
if (window.location.pathname.includes('/chapters/') ||
/(\d+)\/\1/.test(document.querySelector('dl.stats dd.chapters').textContent)) {
position = (position / getDocHeight()).toFixed(4) + '%';
}
return position;
},
checkIfExist: function(what, link) {
let url = link || this.getUrl;
let found = false;
for (let [index, bookmark] of this.list.entries()) {
// check if the same fic already exists
if (bookmark[0].split('/chapters/')[0] !== url.split('/chapters/')[0]) {
continue;
}
// i need the index to delete the old bookmark (for change or delete)
if (what === 'cancel') {
found = index;
break;
// check if the same chapter
} else if (bookmark[0] === url) {
// retrieve the bookmark position
if (what === 'book') {
found = bookmark[2];
// if the bookmark is in %
if (found.toString().includes('%')) {
found = parseFloat(found.replace('%', '')) * getDocHeight();
}
} else {
// just check if a bookmark exist
found = true;
}
break;
}
}
return found;
},
cancel: function(url) {
let found = this.checkIfExist('cancel', url);
// !== false because it can return 0 for the index
if (found !== false) this.list.splice(found, 1);
},
getNew: function() {
this.cancel();
this.list.push([this.getUrl, this.getTitle(), this.getPosition()]);
this.setValues();
},
html: function() {
let bookMenu = document.createElement('li');
bookMenu.id = `${SN}-book`;
bookMenu.className = 'dropdown';
bookMenu.setAttribute('aria-haspopup', 'true');
bookMenu.innerHTML = '<a class="dropdown-toggle" data-toggle="dropdown" data-target="#">Bookmarks</a>';
let bookMenuDrop = document.createElement('ul');
bookMenuDrop.className = 'menu dropdown-menu';
bookMenu.appendChild(bookMenuDrop);
document.querySelector('#header ul.primary.navigation.actions').appendChild(bookMenu);
if (this.list.length) {
let self = this;
let clickDelete = function() {
self.cancel(this.getAttribute('data-url'));
self.setValues();
this.style.display = 'none';
this.previousSibling.style.opacity = '.4';
};
for (let item of this.list) {
let bookMenuLi = document.createElement('li');
bookMenuLi.className = `${SN}-opts`;
bookMenuLi.innerHTML = `<a href="https://archiveofourown.org/works/${
item[0]}?bookmark">${item[1]}</a>`;
let bookMenuDelete = document.createElement('a');
bookMenuDelete.className = `${SN}-book-delete`;
bookMenuDelete.title = 'delete bookmark';
bookMenuDelete.setAttribute('data-url', item[0]);
bookMenuDelete.textContent = 'x';
bookMenuDelete.addEventListener('click', clickDelete);
bookMenuLi.appendChild(bookMenuDelete);
bookMenuDrop.appendChild(bookMenuLi);
}
} else {
bookMenuDrop.innerHTML = '<li><a>No bookmark yet.</a></li>';
}
}
};
await Bookmarks.getValues();
Bookmarks.html();
// Fullscreen
if (Check.work()) {
let workskin = document.getElementById('workskin');
let ficTop = document.createElement('div');
ficTop.className = `actions ${SN}-book-top`;
let toFullScreen = document.createElement('a');
toFullScreen.textContent = 'Full Screen';
ficTop.appendChild(toFullScreen);
workskin.insertAdjacentElement('afterbegin', ficTop);
// changes to create full screen
let fullScreen = () => {
if (Check.fullScreen) {
window.location.replace(window.location.pathname);
return;
}
setScroll(0);
Check.fullScreen = true;
window.history.replaceState(null, '', '?bookmark');
addCSS(`${SN}-fullscreen`, `/* Fullscreen */
#outer.wrapper, div#outer.wrapper > * { display: none !important; }
#workskin .preface { margin: 0; padding-bottom: 0; }
.preface.group .module { padding-bottom: 0; text-align: center; }
.preface.group .module h3.heading {
display: inline; cursor: pointer; text-align: center; opacity: .5;
font-style: italic; font-size: 100%; }
.preface h3 + p {
border: 3px solid rgba(0, 0, 0, .1); border-left: 0; border-right: 0;
padding: .6em; margin: 0; }
.preface.group .module .userstuff { background-color: #fff; }
.preface.group .module > :not(h3),
.preface.group .notes.module.hidden,
.actions:not(div[class*="${SN}-book"]) li > a:not([href*="chapters"]):not([href="#workskin"]) {
display: none; }
.preface.group .module > h3:hover ~ .userstuff, .preface .module > .userstuff:hover,
.afterword.preface.group .children.module > h3:hover + ul,
.afterword.preface.group .children.module > ul:hover {
display: block !important; position: absolute; width: 100%;
max-height: 6em; transform: translateY(-100%);
color: inherit; background-color: inherit; padding: 0.5em;
text-align: left; margin: 0; overflow: auto; z-index: 999; cursor: pointer; }
.actions:not(div[class*="${SN}-book"]) { margin-top: 2em; }`
);
// .preface.group .module .userstuff { background-color: #fff; } -> in case Styling is disabled
// to hide notes whith no author's words
workskin.querySelectorAll(".preface.group .notes.module").forEach(note => {
if (!note.querySelector('.userstuff')) note.classList.add('hidden');
});
document.body.appendChild(workskin);
toFullScreen.textContent = 'Exit';
let goToBook = document.createElement('a');
goToBook.textContent = 'Go to Bookmark';
goToBook.addEventListener('click', () => {
setScroll(Bookmarks.checkIfExist('book'));
});
let ficLeft = document.createElement('div');
ficLeft.className = `actions ${SN}-book-left`;
let deleteBook = document.createElement('a');
deleteBook.title = 'delete bookmark';
deleteBook.textContent = 'x';
deleteBook.addEventListener('click', () => {
Bookmarks.cancel();
Bookmarks.setValues();
goToBook.className = `${SN}-no-book`;
deleteBook.className = `${SN}-no-book`;
});
let newBook = document.createElement('a');
newBook.title = 'new bookmark';
newBook.textContent = '+';
newBook.addEventListener('click', function() {
Bookmarks.getNew();
goToBook.className = '';
deleteBook.className = '';
this.textContent = 'saved';
setTimeout(() => { this.textContent = '+'; }, 1000);
});
if (!Bookmarks.checkIfExist()) {
goToBook.className = `${SN}-no-book`;
deleteBook.className = `${SN}-no-book`;
}
ficTop.insertAdjacentElement('afterbegin', goToBook);
ficLeft.appendChild(newBook);
ficLeft.appendChild(deleteBook);
document.body.appendChild(ficLeft);
// change the url for prevoious and next chapter and top
let bottomNav = document.querySelector('#feedback .actions');
bottomNav.querySelectorAll('li > a').forEach(a => {
if (/chapter/i.test(a.textContent)) {
a.href = a.href.replace('#workskin', '?bookmark');
}
});
bottomNav.querySelector('a[href="#main"]').href = '#workskin';
// append the navigation to the workskin
workskin.appendChild(bottomNav);
};
if (Bookmarks.fromBook) fullScreen();
toFullScreen.addEventListener('click', fullScreen);
} // END Check.work()
} // END Feature.book
/** FIC'S STYLE + WPM **/
if (Check.work()) {
if (Feature.style) {
addCSS(`${SN}-generalstyle`, `/* General Styling */
#main div.wrapper { margin-bottom: 1em; }
#workskin { margin: 0; max-width: none !important; }
#workskin .notes, #workskin .summary,
blockquote { font-size: inherit; font-family: inherit; }
.preface a, #chapters a, .preface a:link, #chapters a:link,
.preface a:visited, #chapters a:visited, .preface a:visited:hover,
#chapters a:visited:hover, div.preface .byline a, #workskin #chapters a,
#chapters a:link, #chapters a:visited { color: inherit !important; }
#workskin .actions { font-size: 1rem;
font-family: 'Lucida Grande', 'Lucida Sans Unicode', Verdana, Helvetica, sans-serif; }
.chapter .preface { border-top: 0; margin-bottom: 0; margin-top: 0; }
.chapter .preface[role="complementary"] { border-width: 0; margin: 0; }
.preface.group, div.preface {
color: inherit; background-color: inherit; margin-left: 0; margin-right: 0;
padding: 0 2em; font-size: .8em; }
.preface.group .module { min-height: 0; }
#workskin #chapters .preface .userstuff p,
#workskin .preface .userstuff p,
.userstuff details { margin: .1em auto; line-height: 1.1em; }
div.preface .jump { margin-top: 1em; font-size: .9em; }
.preface blockquote { padding: .6em; margin: 0; border-inline-start-color: rgba(255, 255, 255, .2); }
.preface blockquote.userstuff {
box-shadow: 0 0 2px rgba(0, 0, 0, .8), inset 0 0 2px rgba(255, 255, 255, .5); }
.preface h3.title {
background: repeating-linear-gradient(46deg, rgba(0, 0, 0, .05),
rgba(0, 0, 0, .15) .49em, rgba(255, 255, 255, .2) .5em, rgba(255, 255, 255, .2) .51em);
box-shadow: 0 0 2px rgba(0, 0, 0, .8), inset 0 0 2px rgba(255, 255, 255, .5);
padding: .6em; margin: 0; }
.preface h3.heading, .userstuff h3 { font-size: inherit; border-width: 0; }
h3.title a { border: 0; font-style: italic; }
div.preface .associations,
.preface .notes h3+p { margin-bottom: 0; font-style: italic; font-size: .8em; }
#workskin #chapters,
#workskin #chapters .userstuff { width: 100% !important; box-sizing: border-box; }
#workskin #chapters .userstuff,
#workskin #chapters .userstuff p { font-family: inherit; }
#workskin #chapters .userstuff br { display: block; margin-top: .6em; content: " "; }
.userstuff hr {
width: 100%; height: 2px; border: 0; margin: 1.5em 0;
background-image: linear-gradient(90deg, transparent, rgba(0, 0, 0, .2), transparent),
linear-gradient(90deg, transparent, rgba(255, 255, 255, .3), transparent); }
#workskin #chapters .userstuff blockquote {
padding-top: 1px; padding-bottom: 1px; margin: 0 .5em; font-size: inherit; }
.userstuff img { max-width: 100%; height: auto; display: block; margin: auto; }`
);
// CSS changes depending on the user
const Styling = {
opts: {
fontName: 'Default',
colors: 'light',
textAlign: 'justify',
fontSize: '100',
margins: '7',
lineSpacing: '5'
},
fonts: {
'Default': 'inherit',
'Arial Black': 'Arial Black, Arial Bold, Gadget, sans-serif',
'Helvetica': 'Helvetica, Helvetica Neue, sans-serif',
'Verdana': 'Verdana, Tahoma, sans-serif',
'Segoe UI': 'Segoe UI, Trebuchet MS, sans-serif',
'Garamond': 'Garamond, Book Antiqua, Palatino, Baskerville, serif',
'Georgia': 'Georgia, serif',
'Times New Roman': 'Times New Roman, Times, serif',
'Consolas': 'Consolas, Lucida Console, monospace',
'Courier': 'Courier, Courier New, monospace'
},
colors: {
// background, font color
light: ['#ffffff', '#000000'],
grey: ['#e6e6e6', '#111111'],
sepia: ['#fbf0d9', '#54331b'],
dark: ['#333333', '#e1e1e1'],
darkblue: ['#282a36', '#f8f8e6'],
black: ['#000000', '#ffffff']
},
inputs: function() {
return {
fontName: {label: 'Font', options: Object.keys(this.fonts), default: 'Default'},
colors: {label: 'Background', options: Object.keys(this.colors), default: 'light'},
textAlign: {label: 'Alignment', options: ['default', 'justify', 'left', 'center', 'right'], default: 'justify'},
fontSize: {label: 'Text Size', range: [80, 300], default: 100},
margins: {label: 'Page Margins', range: [5, 40], default: 7},
lineSpacing: {label: 'Line Spacing', range: [3, 14], default: 5}
}
},
getValues: async function() {
Object.assign(this.opts, await getStorage('styling', '{}'));
},
setValues: function() {
setStorage('styling', this.opts);
addCSS(`${SN}-userstyle`, `/* User Styling */
#workskin{
font-family: ${this.fonts[this.opts.fontName]};
font-size: ${parseFloat((this.opts.fontSize/100).toFixed(2))}rem;
padding: 0 ${this.opts.margins}%;
color: ${this.colors[this.opts.colors][1]};
background-color: ${this.colors[this.opts.colors][0]};
${this.opts.textAlign === 'default' ? '' :`text-align: ${this.opts.textAlign};`}
}
#workskin #chapters .userstuff,
#workskin #chapters .userstuff p {
line-height: ${parseFloat((this.opts.lineSpacing * 0.4).toFixed(2))}rem;
${this.opts.textAlign === 'default' ? '' :`text-align: ${this.opts.textAlign};`}
}
#workskin #chapters .userstuff p {
margin: ${parseFloat((this.opts.lineSpacing * 0.5 - 1.4).toFixed(2))}rem auto;
}
#workskin .preface.group .heading {
color: ${this.colors[this.opts.colors][1]} !important;
}
.preface.group .module {
color: ${this.colors[this.opts.colors][1]};
background-color: ${this.colors[this.opts.colors][0]} !important;
}`
);
},
html: function() {
this.setValues();
let inputs = this.inputs();
// the options displayed on the page
let styleMenu = document.createElement('div');
styleMenu.id = `${SN}-style`;
styleMenu.className = `${SN}-style-hide`;
let h = '';
for (const [name, input] of Object.entries(inputs)) {
h += `<li><label>${input.label}</label>`;
if ('options' in input) {
h += `<select id="${name}">`;
for (const o of input.options) {
h += `<option value="${o}" ${o === this.opts[name] ? 'selected' : ''}>${o}</option>`;
}
h += '</select>';
} else {
h += `<input type="range" min="${input.range[0]}" max="${input.range[1]}"
id="${name}" value="${this.opts[name]}">`;
}
h += '</li>';
}
styleMenu.innerHTML = `<ul>${h}<button id="${SN}-style-save">save</button>
<button id="${SN}-style-reset">reset</button></ul>
<button id="${SN}-style-button">☰</button>`;
document.body.appendChild(styleMenu);
document.getElementById(`${SN}-style-save`).addEventListener('click', () => {
let pos = getScroll() / getDocHeight();
for (const name in inputs) {
this.opts[name] = styleMenu.querySelector(`#${name}`).value;
}
this.setValues();
setScroll(pos * getDocHeight());
});
document.getElementById(`${SN}-style-reset`).addEventListener('click', () => {
let pos = getScroll() / getDocHeight();
styleMenu.parentElement.removeChild(styleMenu);
for (const [name, input] of Object.entries(inputs)) {
this.opts[name] = input.default;
}
this.html();
setScroll(pos * getDocHeight());
});
document.getElementById(`${SN}-style-button`).addEventListener('click', () => {
styleMenu.classList.toggle(`${SN}-style-hide`);
});
}
};
await Styling.getValues();
Styling.html();
} // END Feature.style
// # words and time for every chapter, if the fic has chapters
if (Feature.wpm) {
for (let chapter of document.querySelectorAll('#chapters > .chapter > div.userstuff.module')) {
// -2 because of hidden <h3>Chapter Text</h3>
let numWords = chapter.textContent.replace(/['’‘-]/g, '').match(/\w+/g).length - 2;
chapter.parentElement.insertAdjacentHTML('afterbegin',
`<div class="${SN}-words">this chapter has ${numWords} words (time: ${
countTime(numWords)})</div>`);
}
}
// remove all the non-breaking white spaces
document.getElementById('chapters').innerHTML = document.getElementById('chapters')
.innerHTML.replace(/ /g, ' ');
} // END Check.work()
/** BLACKLIST **/
if (Feature.black && Check.black()) {
addCSS(`${SN}-blacklisting`,
`[data-${SN}-visibility="remove"],
[data-${SN}-visibility="hide"] > :not(.header),
[data-${SN}-visibility="hide"] > .header > :not(h4) { display: none !important; }
[data-${SN}-visibility="hide"] > .header,
[data-${SN}-visibility="hide"] > .header > h4 {
margin: 0 !important; min-height: auto; font-size: .9em; font-style: italic; }
[data-${SN}-visibility="hide"] { opacity: .6; }
[data-${SN}-visibility="hide"]::before {
content: "\\2573 " attr(data-${SN}-reasons); font-size: .8em; }`
);
const Blacklist = {
lists : {
Tag: [],
Text: [],
Author: []
},
opts: {
show: true,
pause: false,
maxTags: 0,
maxRelations: 0,
minIncomplete: 0,
minFandoms: 0,
maxFandoms: 0,
minChapters: 0,
maxChapters: 0,
minWords: 0,
maxWords: 0,
langs: ''
},
blurb: 'li.blurb.group',
getValues: async function() {
this.lists.Tag = await getStorage('blacklistTags', '[]');
this.lists.Text = await getStorage('blacklistText', '[]');
this.lists.Author = await getStorage('blacklistAuthors', '[]');
Object.assign(this.opts, await getStorage('blacklistOpts', '{}'));
},
findTags: function(work) {
return this.opts.maxTags &&
work.querySelectorAll('.tag').length > this.opts.maxTags;
},
findRelations: function(work) {
return this.opts.maxRelations &&
work.querySelectorAll('.tags .relationships .tag').length > this.opts.maxRelations;
},
findLangs: function(work) {
return this.opts.langs && work.querySelector('dd.language') &&
!this.opts.langs.toLowerCase().includes(work.querySelector('dd.language').textContent.toLowerCase().trim());
},
getFandoms: function(work) {
if ((this.opts.minFandoms || this.opts.maxFandoms) && work.querySelector('.header .fandoms .tag')) {
let numFandoms = work.querySelectorAll('.header .fandoms .tag').length;
if (this.opts.minFandoms && numFandoms < this.opts.minFandoms ||
this.opts.maxFandoms && numFandoms > this.opts.maxFandoms) {
return `Fandoms: ${numFandoms}`;
}
}
return [];
},
getChapters: function(work) {
if ((this.opts.minChapters || this.opts.maxChapters) &&
work.querySelector('dd.chapters')) {
let numCh = Number(work.querySelector('dd.chapters').textContent.split('/')[0]);
if (this.opts.minChapters && numCh < this.opts.minChapters ||
this.opts.maxChapters && numCh > this.opts.maxChapters) {
return `Chapters: ${numCh}`;
}
}
return [];
},
getWords: function(work) {
if ((this.opts.minWords || this.opts.maxWords) && work.querySelector('dd.words')) {
let numWords = Number(work.querySelector('dd.words').textContent.replace(/,/g, '')) / 1000;
if (this.opts.minWords && numWords < this.opts.minWords ||
this.opts.maxWords && numWords > this.opts.maxWords) {
return `Words: ${Math.round(numWords * 1000)}`;
}
}
return [];
},
getIncomplete: function(work) {
if (this.opts.minIncomplete && work.querySelector('.required-tags .complete-no')) {
// millisecs in an average month = 30.4days*24hrs*60mins*60secs*1000
let updated = (
Date.now() - new Date(work.querySelector('.datetime').textContent).getTime()
) / (30.4*24*60*60*1000);
if (updated > this.opts.minIncomplete) {
return `Updated: ${Math.floor(updated)}mnth ago`;
}
}
return [];
},
ifMatch: function(elem, list, flag) {
let found = false;
for (let value of this.lists[list]) {
let pattern = value.trim().replace(/[.+?^${}()|[\]\\]/g, '\\$&');
if (!pattern) break;
// wildcard
pattern = pattern.replace(/\*/g, '.*');
// match 2 words in any order
pattern = pattern.replace(/(.+)&&(.+)/, '(?=.*$1)(?=.*$2).*');
// only otp
if (elem.parent === 'relationships') {
// to delete fandom's name in the tag
elem.text = elem.text.replace(/\(.+\)$/, '');
pattern = pattern.replace(/(.+)&!(.+)/, '(?=.*\\/)((?=.*$1)(?!.*$2)|(?=.*$2)(?!.*$1)).*');
}
let regex;
if (flag === 'free') regex = new RegExp(pattern, 'i'); // for text
else regex = new RegExp(`^${pattern}$`, 'i');
if (regex.test(elem.text)) {
if (flag === 'free') found = `${list}: ${value}`; // show the rule that matched (for text)
else if (elem.parent === 'heading') found = list; // show only list name (for author)
else found = `${list}: ${elem.text}`; // show the entire matched tag
break;
}
}
return found;
},
getReasons: function(work, list, where, flag = '') {
if (!this.lists[list].length) return [];
let filtered = [];
for (let elem of work.querySelectorAll(where)) {
let found = this.ifMatch({
text: elem.textContent.trim(),
parent: elem.parentElement.className
}, list, flag);
if (found) filtered.push(found);
}
return filtered;
},
setVisibility: function() {
for (let el of document.querySelectorAll(`${this.blurb}[data-${SN}-visibility]`)) {
el.removeAttribute(`data-${SN}-visibility`);
el.removeAttribute(`data-${SN}-reasons`);
}
if (this.opts.pause) return;
for (let work of document.querySelectorAll(this.blurb)) {
let reasons = []
.concat(this.getReasons(work, 'Author', 'h4.heading a[rel="author"]'))
.concat(this.getIncomplete(work))
.concat(this.getWords(work))
.concat(this.getChapters(work))
.concat(this.getFandoms(work))
.concat(this.getReasons(work, 'Text', 'h4.heading a:first-child, .summary', 'free'))
.concat(this.getReasons(work, 'Tag',
'.tags .tag, .required-tags span:not(.warnings) span.text, .header .fandoms .tag'));
if (this.findRelations(work)) reasons.unshift('Relations');
if (this.findTags(work)) reasons.unshift('Tags');
if (this.findLangs(work)) reasons.unshift('Language');
if (!reasons.length) continue;
if (this.opts.show) {
work.setAttribute(`data-${SN}-visibility`, 'hide');
work.setAttribute(`data-${SN}-reasons`, reasons.join(' - '));
} else {
work.setAttribute(`data-${SN}-visibility`, 'remove');
}
}
},
getArray: function(string) {
return string.trim() ? string.split(',').map(s => s.trim()).filter(s => s.length) : [];
},
getInt: function(string, min = 0) {
let number = string.trim() ? Math.max(parseInt(string), 0) : 0;
if (number < min) number = 0;
return number;
},
setValues: function() {
// when changes are made manually on the menu
this.lists.Tag = this.getArray(document.getElementById(`${SN}-black-tags`).value);
this.lists.Text = this.getArray(document.getElementById(`${SN}-black-text`).value);
this.lists.Author = this.getArray(document.getElementById(`${SN}-black-authors`).value);
this.opts.maxTags = this.getInt(document.getElementById(`${SN}-black-maxTags`).value);
this.opts.maxRelations = this.getInt(document.getElementById(`${SN}-black-maxRelations`).value);
this.opts.minIncomplete = this.getInt(document.getElementById(`${SN}-black-minIncomplete`).value);
this.opts.minFandoms = this.getInt(document.getElementById(`${SN}-black-minFandoms`).value);
this.opts.maxFandoms = this.getInt(document.getElementById(`${SN}-black-maxFandoms`).value);
this.opts.minChapters = this.getInt(document.getElementById(`${SN}-black-minChapters`).value);
this.opts.maxChapters = this.getInt(document.getElementById(`${SN}-black-maxChapters`).value, this.opts.minChapters);
this.opts.minWords = this.getInt(document.getElementById(`${SN}-black-minWords`).value);
this.opts.maxWords = this.getInt(document.getElementById(`${SN}-black-maxWords`).value, this.opts.minWords);
this.opts.langs = document.getElementById(`${SN}-black-langs`).value;
this.opts.show = document.getElementById(`${SN}-black-show`).checked;
this.opts.pause = document.getElementById(`${SN}-black-pause`).checked;
setStorage('blacklistTags', this.lists.Tag);
setStorage('blacklistText', this.lists.Text);
setStorage('blacklistAuthors', this.lists.Author);
setStorage('blacklistOpts', this.opts);
this.setVisibility();
},
updateValues: function() {
// when new tags or authors are added by click
document.getElementById(`${SN}-black-tags`).value = this.lists.Tag.join(', ');
document.getElementById(`${SN}-black-authors`).value = this.lists.Author.join(', ');
setStorage('blacklistTags', this.lists.Tag);
setStorage('blacklistAuthors', this.lists.Author);
this.setVisibility();
},
html: function() {
let blackMenu = document.createElement('li');
blackMenu.id = `${SN}-black`;
blackMenu.className = 'dropdown';
blackMenu.setAttribute('aria-haspopup', 'true');
blackMenu.innerHTML = `<a class="dropdown-toggle" data-toggle="dropdown" data-target="#">Blacklist</a>
<ul class="menu dropdown-menu">
<li>
<a class="${SN}-save" id="${SN}-black-save">SAVE</a>
</li><li class="${SN}-opts">
<a>SHOW REASONS <input id="${SN}-black-show" type="checkbox" ${
this.opts.show ? 'checked' : ''}></a>
<a>PAUSE <input id="${SN}-black-pause" type="checkbox" ${
this.opts.pause ? 'checked' : ''}></a>
</li><li class="${SN}-opts">
<a title="for works in progress only">updated<br>max
<input id="${SN}-black-minIncomplete" type="number" min="0" step="1"
title="in months" value="${this.opts.minIncomplete}"></a>
<a>tags<br>max
<input id="${SN}-black-maxTags" type="number" min="0" step="1" value="${
this.opts.maxTags}"></a>
<a>relations<br>max
<input id="${SN}-black-maxRelations" type="number" min="0" step="1" value="${
this.opts.maxRelations}"></a>
</li><li class="${SN}-opts">
<a>chapters<br>min
<input id="${SN}-black-minChapters" type="number" min="0" step="1"
value="${this.opts.minChapters}">
max <input id="${SN}-black-maxChapters" type="number" min="0" step="1"
value="${this.opts.maxChapters}"></a>
<a>words<br>min
<input id="${SN}-black-minWords" type="number" min="0" step="1"
title="in thousands" value="${this.opts.minWords}">
max <input id="${SN}-black-maxWords" type="number" min="0" step="1"
title="in thousands" value="${this.opts.maxWords}"></a>
</li><li class="${SN}-opts">
<a>fandoms<br>min
<input id="${SN}-black-minFandoms" type="number" min="0" step="1"
value="${this.opts.minFandoms}">
max <input id="${SN}-black-maxFandoms" type="number" min="0" step="1"
value="${this.opts.maxFandoms}"></a>
<a title="show only specified">languages<br>
<input type="text" id="${SN}-black-langs" spellcheck="false"
title="separate languages by a space" value="${this.opts.langs}"></a>
</li><li class="${SN}-optsFull">
<a title="tags, fandoms, relations, characters, ratings, warnings, categories, status">tags</a>
<textarea id="${SN}-black-tags" spellcheck="false">${
this.lists.Tag.join(', ')}</textarea>
<a>titles, summaries</a>
<textarea id="${SN}-black-text" spellcheck="false">${
this.lists.Text.join(', ')}</textarea>
<a>authors</a>
<textarea id="${SN}-black-authors" spellcheck="false">${
this.lists.Author.join(', ')}</textarea>
</li><li class="${SN}-opts">
<a title="comma">separator: ,</a>
<a title="match zero or more of any character (letter, white space, symbol...)">wildcard: *</a>
<a title="match two pair of words in any order">pair: &&</a>
<a title="hide relationships that include only one person of your favourite ship (only for tags)">only otp: &!</a>
</li>
</ul>`;
document.querySelector('#header ul.primary.navigation.actions').appendChild(blackMenu);
document.getElementById(`${SN}-black-save`).addEventListener('click', function() {
Blacklist.setValues();
this.textContent = 'SAVED';
setTimeout(() => { this.textContent = 'SAVE'; }, 1000);
});
},
altClick: function(event) {
if (event.altKey) {
if (event.target.classList.contains('tag') &&
!this.lists.Tag.includes(event.target.textContent)) {
event.preventDefault();
this.lists.Tag.push(event.target.textContent);
this.updateValues();
} else if (event.target.getAttribute('rel') === 'author' &&
!this.lists.Author.includes(event.target.textContent)) {
event.preventDefault();
this.lists.Author.push(event.target.textContent);
this.updateValues();
}
}
}
};
await Blacklist.getValues();
Blacklist.setVisibility();
Blacklist.html();
document.querySelector('.index.group').addEventListener("click", Blacklist.altClick.bind(Blacklist));
} // END Feature.black AND Check.black()
/** GLOBAL FUNCTIONS **/
async function getStorage(key, def) {
// def must be a string
return JSON.parse(await GM.getValue(key, def));
}
function setStorage(key, value) {
// value can be any type
GM.setValue(key, value !== 'string' ? JSON.stringify(value) : value);
}
function addCSS(id, css) {
// unique id because of styling user changes
if (document.querySelector(`style#${id}`)) {
document.querySelector(`style#${id}`).textContent = css;
} else {
let style = document.createElement('style');
style.id = id;
style.textContent = css;
document.getElementsByTagName('head')[0].appendChild(style);
}
}
function countTime(num) {
// estimate reading time
if (!num) return '?';
num = Math.round(Number(num) / Feature.wpm);
let h = Math.floor(num / 60);
let m = num % 60;
return `${h > 0 ? `${h}hr ` : ''}${m > 0 ? `${m}min` : ''}` || '<1min';
}
function getScroll() {
return Math.max(document.documentElement.scrollTop, window.scrollY, 0);
}
function setScroll(s) {
window.scroll(0, s);
}
function getDocHeight() {
return Math.max(document.documentElement.scrollHeight, document.documentElement.offsetHeight,
document.body.scrollHeight, document.body.offsetHeight);
}
})();