// ==UserScript==
// @name Fanfiction.net: Filter and Sorter
// @namespace https://greasyfork.org/en/users/163551-vannius
// @version 1.89
// @license MIT
// @description Add filters and additional sorters and "Load all pages" button to Fanfiction.net.
// @author Vannius
// @match https://www.fanfiction.net/*
// @exclude /^https://www\.fanfiction\.net/s//
// @exclude /^https://www\.fanfiction\.net/r//
// @grant GM_addStyle
// @grant GM_getResourceText
// @resource JSON https://raw.githubusercontent.com/Nellius/FanFiction-FandomData/master/json/exceptional-fandom.json
// ==/UserScript==
(function () {
'use strict';
// Author Biography Setting
const HIDE_BIO_AUTOMATICALLY = true;
// Filter Setting
// Options for 'gt', 'ge', 'le', 'dateRange' mode.
// Options for chapters filters.
// Format: [\d+(K)?] in ascending order
const chapterOptions = ['1', '5', '10', '20', '30', '50'];
// Options for word_count_gt and word_count_le filters.
// Format: [\d+(K)?] in ascending order
const wordCountOptions = ['1K', '5K', '10K', '20K', '40K', '60K', '80K', '100K', '200K', '300K'];
// Options for reviews, favs and follows filters.
// Format: [\d+(K)?] in ascending order
const kudoCountOptions = ['10', '50', '100', '200', '400', '600', '800', '1K', '2K', '3K'];
// Options for updated and published filters.
// Format: [\d+ (hour|day|week|month|year)(s)?] in ascending order
const dateRangeOptions = ['24 hours', '1 week', '1 month', '6 months', '1 year', '3 years', '5 years'];
// dataId: property key of storyData defined in makeStoryData()
// text: text for filter select dom
// title: title for filter select dom
// mode: used to determine how to compare selectValue and storyValue in throughFilter()
// options: required when mode is 'gt', 'ge', 'le', 'dateRange'
// reverse: reverse result of throughFilter()
// condition: display filter only if filter[filterKey] has defined value
const filterDic = {
fandom_a: { dataId: 'fandom', text: 'Fandom A', title: "Fandom filter a", mode: 'contain' },
crossover: { dataId: 'crossover', text: '?', title: "Crossover filter", mode: 'equal' },
// Display only if there are crossover fanfictions
fandom_b: { dataId: 'fandom', text: 'Fandom B', title: "Fandom filter b", mode: 'contain', condition: { filterKey: 'crossover', value: 'X' } },
rating: { dataId: 'rating', text: 'Rating', title: "Rating filter", mode: 'equal' },
language: { dataId: 'language', text: 'Language', title: "Language filter", mode: 'equal' },
genre: { dataId: 'genre', text: 'Genre', title: "Genre filter", mode: 'contain' },
not_genre: { dataId: 'genre', text: 'Not Genre', title: "Genre reverse filter", mode: 'contain', reverse: true },
chapters_gt: { dataId: 'chapters', text: '< Chapters', title: "Chapter number greater than filter", mode: 'gt', options: chapterOptions },
chapters_le: { dataId: 'chapters', text: 'Chapters ≤', title: "Chapter number less or equal filter", mode: 'le', options: chapterOptions },
word_count_gt: { dataId: 'word_count', text: '< Words', title: "Word count greater than filter", mode: 'gt', options: wordCountOptions },
word_count_le: { dataId: 'word_count', text: 'Words ≤', title: "Word count less or equal filter", mode: 'le', options: wordCountOptions },
reviews: { dataId: 'reviews', text: 'Reviews', title: "Review count greater than or equal filter", mode: 'ge', options: kudoCountOptions },
favs: { dataId: 'favs', text: 'Favs', title: "Fav count greater than or equal filter", mode: 'ge', options: kudoCountOptions },
follows: { dataId: 'follows', text: 'Follows', title: "Follow count greater than or equal filter", mode: 'ge', options: kudoCountOptions },
updated: { dataId: 'updated', text: 'Updated', title: "Updated date range filter", mode: 'dateRange', options: dateRangeOptions },
published: { dataId: 'published', text: 'Published', title: "Published date range filter", mode: 'dateRange', options: dateRangeOptions },
character_a: { dataId: 'character', text: 'Character A', title: "Character filter a", mode: 'contain' },
character_b: { dataId: 'character', text: 'Character B', title: "Character filter b", mode: 'contain' },
not_character: { dataId: 'character', text: 'Not Character', title: "Character reverse filter", mode: 'contain', reverse: true },
relationship: { dataId: 'relationship', text: 'Relationship', title: "Relationship filter", mode: 'contain' },
status: { dataId: 'status', text: 'Status', title: "Status filer", mode: 'equal' }
};
// Whether or not to sort characters of relationship in ascending order.
// true: [foo, bar] => [bar, foo]
// false: [foo, bar] => [foo, bar]
const SORT_CHARACTERS_OF_RELATIONSHIP = true;
// Sorter Setting
// dataId: property key of storyData defined in makeStoryData()
// text: displayed sorter name
// order: 'asc' or 'dsc'
const sorterDicList = [
{ dataId: 'fandom', text: 'Category', order: 'asc' },
{ dataId: 'updated', text: 'Updated', order: 'dsc' },
{ dataId: 'published', text: 'Published', order: 'dsc' },
{ dataId: 'title', text: 'Title', order: 'asc' },
{ dataId: 'word_count', text: 'Words', order: 'dsc' },
{ dataId: 'chapters', text: 'Chapters', order: 'dsc' },
{ dataId: 'reviews', text: 'Reviews', order: 'dsc' },
{ dataId: 'favs', text: 'Favs', order: 'dsc' },
{ dataId: 'follows', text: 'Follows', order: 'dsc' },
{ dataId: 'status', text: 'Status', order: 'asc' }
];
// Specify symbols to represent 'asc' and 'dsc'.
const orderSymbol = { asc: '▲', dsc: '▼' };
// Css Setting
// ColorScheme definitions
// [[backgroundColor, color]]
const red = [
// ['#ff1111', '#f96540', '#f4a26d', '#efcc99', 'white'].map(color => [color, getReadableColor(color, '#555')]) =>
['#ff1111', "#000033"], ["#f96540", "#000099"], ["#f4a26d", "#000000"], ["#efcc99", "#000000"], ["white", "#000000"]
];
// const blue = makeGradualColorScheme('#11f', '#fff', 'rgb', 5, '#555');
// const purple = makeGradualColorScheme('#cd47fd', '#e8eaf6', 'hsl', 5, '#555');
// const gold = makeGradualColorScheme('gold', 'darkgrey', 'rgb', 5);
// Select colorScheme
const colorScheme = red;
// Generate list of className for colorScheme automatically.
const menuItemGroupClasses = ((length) => {
let indexes = [...Array(length).keys()].map(x => x.toString());
if (length.toString().length > 1) {
indexes = indexes.map(x => x.padStart(length.toString().length, '0'));
}
return indexes.map(index => 'fas-filter-menu-item_group-' + index);
})(colorScheme.length);
// Generate str of colorScheme css automatically.
const menuItemGroupCss = menuItemGroupClasses.map((groupClass, i) => {
return '.' + groupClass +
" { background-color: " + colorScheme[i][0] +
"; color: " + colorScheme[i][1] + "; }";
});
// eslint-disable-next-line no-undef
GM_addStyle([
".fas-badge { color: #555; padding-top: 8px; padding-bottom: 8px; }",
".fas-badge-number { color: #fff; background-color: #999; padding-right: 9px; padding-left: 9px; border-radius: 9px }",
".fas-badge-number:hover { background-color: #555;}",
".fas-progress { width: 1%; height: 10px; background-color: #4caf50; }",
".fas-progress-bar { width: 100%; background-color: #ccc;}",
".fas-loaded-page { text-decoration: line-through !important; }",
".fas-sorter-div { color: gray; font-size: .9em; }",
".fas-sorter { color: gray; }",
".fas-sorter:after { content: attr(data-order); }",
".fas-filter-menus { color: gray; font-size: .9em; }",
".fas-filter-menu { font-size: 1em; padding: 1px 1px; height: 23px; margin: .1em auto; }",
".fas-filter-exclude-menu { border-color: #777; }",
".fas-filter-menu_locked { background-color: #ccc; }",
".fas-filter-menu:disabled { border-color: #999; background-color: #999; }",
".fas-filter-menu-item { color: #555; }",
".fas-filter-menu-item_locked { font-style: oblique; }",
...menuItemGroupCss,
".fas-filter-menu-item_story-zero { background-color: #999; }"
].join(''));
// Css functions
// Color convert Functions
function strColorToHex (strColor) {
const ctx = document.createElement('canvas').getContext('2d');
ctx.fillStyle = strColor;
return ctx.fillStyle;
};
function hexColorToRgb (hexColor) {
const hexColor6Digit = hexColor.length - 1 === 3
? hexColor[1] + hexColor[1] + hexColor[2] + hexColor[2] + hexColor[3] + hexColor[3]
: hexColor.slice(1);
return [0, 2, 4]
.map(x => hexColor6Digit.slice(x, x + 2))
.map(x => parseInt(x, 16));
};
function standardizeToRgb (color) {
if (/^#[0-9a-fA-F]{3,6}$/.test(color)) {
return hexColorToRgb(color);
} else {
const hexColor = strColorToHex(color);
if (!/^black$/i.test(color) && hexColor === '#000000') {
throw new Error(`args of standardizeToRgb, ${color} is invalid.`);
}
return hexColorToRgb(hexColor);
}
};
function rgbToHexColor (rgb) {
return rgb
.map(x => x.toString(16).padStart(2, '0'))
.reduce((p, x) => p + x, '#');
};
// Make graduation of background color from startColor to endColor
// with gradationsLength steps by using colorSpace('rgb', 'hsv' or 'hsl').
// Determine readable foregroundColor from web safe color automatically.
// eslint-disable-next-line no-unused-vars
function makeGradualColorScheme (
startColor, endColor, colorSpace = 'rgb', gradationsLength = 5, defaultForegroundColor = null
) {
const rgbToHsv = (rgb) => {
const [r, g, b] = rgb.map(x => x / 255);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const diff = max - min;
const h = (() => {
if (max !== min) {
if (max === r) {
return (60 * ((g - b) / diff) + 360) % 360;
} else if (max === g) {
return (60 * ((b - r) / diff) + 120) % 360;
} else if (max === b) {
return (60 * ((r - g) / diff) + 240) % 360;
}
}
return 0;
})();
const s = max === 0 ? 0 : diff / max * 100;
const v = max * 100;
return [h, s, v];
};
const hsvToRgb = (hsv) => {
const [h, s, v] = [hsv[0], hsv[1] / 100, hsv[2] / 100];
const f = (n, k = (n + h / 60) % 6) => {
return v - v * s * Math.max(Math.min(k, 4 - k, 1), 0);
};
return [f(5), f(3), f(1)].map(x => Math.round(x * 255));
};
function hsvToHsl (hsv) {
const [h, sHsv, v] = [hsv[0], hsv[1] / 100, hsv[2] / 100];
const l = v - v * sHsv / 2;
const m = Math.min(l, 1 - l);
const sHsl = m ? (v - l) / m : 0;
return [h, sHsl * 100, l * 100];
};
function hslToHsv (hsl) {
const [h, sHsl, l] = [hsl[0], hsl[1] / 100, hsl[2] / 100];
const v = l + sHsl * Math.min(l, 1 - l);
const sHsv = v === 0 ? 0 : 2 - 2 * l / v;
return [h, sHsv * 100, v * 100];
};
function rgbToHsl (rgb) {
return hsvToHsl(rgbToHsv(rgb));
}
function hslToRgb (hsl) {
return hsvToRgb(hslToHsv(hsl));
}
// Check colorSpace
if (!['rgb', 'hsv', 'hsl'].includes(colorSpace)) {
throw new Error(`args of makeGradualColorScheme, ${colorSpace} is invalid.`);
}
// Convert hex color str into int rgb array.
const startRgb = standardizeToRgb(startColor);
const endRgb = standardizeToRgb(endColor);
// Make rgb arrays of gradations made in rgb or hsv color space.
const rgbGradations = (() => {
if (colorSpace === 'rgb') {
// Make rgb gradations
const rgbGradation =
[0, 1, 2].map(x => (endRgb[x] - startRgb[x]) / (gradationsLength - 1));
const rgbMiddleGradationsByRgb = [...Array(gradationsLength - 1).keys()]
.slice(1)
.map(gradationStep => {
return startRgb
.map((x, i) => x + rgbGradation[i] * gradationStep)
.map(x => Math.round(x));
});
return [startRgb, ...rgbMiddleGradationsByRgb, endRgb];
} else if (colorSpace === 'hsv' || colorSpace === 'hsl') {
// Convert rgb into hsv
const startHsv = rgbToHsv(startRgb);
const endHsv = rgbToHsv(endRgb);
// Make hsv gradations
const hsvGradation = (() => {
const hd = endHsv[0] - startHsv[0];
const minHd = Math.abs(hd) < Math.abs(hd - 360) ? hd : hd - 360;
const sd = endHsv[1] - startHsv[1];
const vd = endHsv[2] - startHsv[2];
return [minHd, sd, vd].map(x => x / (gradationsLength - 1));
})();
const rgbMiddleGradationsByHsv = [...Array(gradationsLength - 1).keys()]
.slice(1)
.map(gradationStep => {
const h = (startHsv[0] + hsvGradation[0] * gradationStep + 360) % 360;
const s = startHsv[1] + hsvGradation[1] * gradationStep;
const v = startHsv[2] + hsvGradation[2] * gradationStep;
return [h, s, v].map(x => Math.round(x));
}).map(x => hsvToRgb(x));
return [startRgb, ...rgbMiddleGradationsByHsv, endRgb];
} else if (colorSpace === 'hsl') {
// Convert rgb into hsl
const startHsl = rgbToHsl(startRgb);
const endHsl = rgbToHsl(endRgb);
// Make hsl gradations
const hslGradation = (() => {
const hd = endHsl[0] - startHsl[0];
const minHd = Math.abs(hd) < Math.abs(hd - 360) ? hd : hd - 360;
const sd = endHsl[1] - startHsl[1];
const ld = endHsl[2] - startHsl[2];
return [minHd, sd, ld].map(x => x / (gradationsLength - 1));
})();
const rgbMiddleGradationsByHsl = [...Array(gradationsLength - 1).keys()]
.slice(1)
.map(gradationStep => {
const h = (startHsl[0] + hslGradation[0] * gradationStep + 360) % 360;
const s = startHsl[1] + hslGradation[1] * gradationStep;
const l = startHsl[2] + hslGradation[2] * gradationStep;
return [h, s, l].map(x => Math.round(x));
}).map(x => hslToRgb(x));
return [startRgb, ...rgbMiddleGradationsByHsl, endRgb];
}
})();
const hexGradations = rgbGradations.map(rgb => rgbToHexColor(rgb));
// Make readable pairs of backgroundColor and foregroundColor.
const hexGradualColorSchemes = hexGradations.map(backgroundHex => {
return [
backgroundHex,
getReadableColor(backgroundHex, defaultForegroundColor)
];
});
return hexGradualColorSchemes;
};
// Get readable color by comparing backgroundColor and possible foregroundColor
// according to contrast ratio and hue difference of backgroundColor and foregroundColor.
// Return defaultForegroundColor if it is contrastRatio > 4.5 (WCAG 2 AA Compliant).
// Otherwise return WCAG 2 AA Compliant color with highest hueDiff.
function getReadableColor (backgroundColor, defaultForegroundColor = null) {
const backgroundRgb = standardizeToRgb(backgroundColor);
// Get contrast ratio and hue difference of two colors
const getColorContrast = (rgb1, rgb2) => {
const table = [rgb1, rgb2];
// https://www.w3.org/TR/WCAG20/#contrast-ratiodef
const lWeight = [0.2126, 0.7152, 0.0722];
const relativeLuminances = table
.map(rgb => rgb.map(x => x / 255))
.map(rgb => rgb.map(x => {
if (x <= 0.03928) {
return x / 12.92;
} else {
return ((x + 0.055) / 1.055) ** 2.4;
}
})).map(rgb => rgb.map((x, i) => x * lWeight[i]).reduce((p, x) => p + x))
.sort((a, b) => b - a);
const contrastRatio = (relativeLuminances[0] + 0.05) / (relativeLuminances[1] + 0.05);
// https://www.w3.org/TR/AERT/#color-contrast
const hueDiff =
[0, 1, 2].map(i => Math.abs(rgb1[i] - rgb2[i])).reduce((p, x) => p + x);
const yFilter = [0.299, 0.587, 0.114];
const brightnessDiff = Math.abs(
table.map(rgb => rgb.map((x, i) => x * yFilter[i]).reduce((p, x) => p + x))
.reduce((p, x) => p - x)
);
const contrastRatioThresholdAA = 4.5;
const contrastRatioThresholdAAA = 7;
const hueThreshold = 500;
const brightnessThreshold = 125;
return {
'contrastRatio': contrastRatio,
'contrastComplianceAA': contrastRatio >= contrastRatioThresholdAA,
'contrastComplianceAAA': contrastRatio >= contrastRatioThresholdAAA,
'hueDiff': hueDiff,
'hueDiffCompliance': hueDiff >= hueThreshold,
'brightnessDiff': brightnessDiff,
'brightnessDiffCompliance': brightnessDiff >= brightnessThreshold
};
};
// Return defaultForegroundColor if it is readable
if (defaultForegroundColor) {
const defaultForegroundRgb = standardizeToRgb(defaultForegroundColor);
const defaultColorContrast = getColorContrast(defaultForegroundRgb, backgroundRgb);
if (defaultColorContrast.readable) {
return defaultForegroundColor;
}
}
// Generate web safe color
const rgbValues = [...Array(6).keys()].map(x => x * 255 / 5);
const foregroundRgbs = rgbValues
.map(r => rgbValues.map(g => rgbValues.map(b => [r, g, b])))
.reduce((p, x) => p.concat(x), [])
.reduce((p, x) => p.concat(x), []);
// Calculate each colorContrast of foregroundRgb and backgroundRgb
const colorContrasts = foregroundRgbs
.map(foregroundRgb => getColorContrast(foregroundRgb, backgroundRgb));
// Find index of WCAG 2 AA Compliant color with highest hueDiff.
colorContrasts.forEach((x, i) => {
x.index = i;
});
let sortedColorContrasts = colorContrasts
.filter(x => x.contrastComplianceAA)
.sort((a, b) => b.hueDiff - a.hueDiff);
if (sortedColorContrasts.length === 0) {
sortedColorContrasts = colorContrasts.sort((a, b) => b.contrastRatio - a.contrastRatio);
}
// Return readable foreground hexColor
return rgbToHexColor(foregroundRgbs[sortedColorContrasts[0].index]);
};
// Regex functions
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
function escapeRegExp (string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
// Main
// Check standard of filterDic
const defaultFilterDataKeys = ['dataId', 'text', 'title', 'mode', 'options', 'reverse', 'condition'];
const modesRequireOptions = ['gt', 'ge', 'le', 'dateRange'];
const filterDicUpToStandard = Object.keys(filterDic)
.map(filterKey => {
const filterData = filterDic[filterKey];
const everyKeyUpToStandard = Object.keys(filterData)
.map(filterDataKey => {
const keyUpToStandard = defaultFilterDataKeys.includes(filterDataKey);
if (!keyUpToStandard) {
console.log(`${filterKey} filter: '${filterDataKey}' is an irregular key.`);
}
return keyUpToStandard;
}).every(x => x);
const modeRequirementUpToStandard =
modesRequireOptions.includes(filterData.mode) ? 'options' in filterData : true;
if (!modeRequirementUpToStandard) {
console.log(`${filterKey} filter: '${filterData.mode}' mode filter requires to specify options.`);
}
return everyKeyUpToStandard && modeRequirementUpToStandard;
}).every(x => x);
if (!filterDicUpToStandard) {
console.log("filterDic isn't up to standard.");
return;
}
const setDatasetToZListTag = (x) => {
// .filter_placeholder don't have children.
// https://greasyfork.org/ja/scripts/13486-fanfiction-net-unwanted-result-filter
if (x.firstElementChild) {
const zPadtop2Tag = x.getElementsByClassName('z-padtop2')[0];
const rawText = zPadtop2Tag.textContent;
const dataText = rawText.replace(/ - Complete$/, '');
const matches =
dataText.match(/^(Crossover - )?(.+ - )?Rated: ([^ ]+) - ([^ ]+)( - [^ ]+)? - Chapters: (\d+) - Words: ([\d,]+)( - Reviews: [\d,]+)?( - Favs: [\d,]+)?( - Follows: [\d,]+)? ?(- Updated: [^-]+)?(- Published: [^-]+)?(- .*)?$/);
// These dataset are defined in author page.
if (!x.dataset.story_id) {
// FicLab add .ficlab-save tag at the place of first child of .z-list tag.
// https://www.ficlab.com/
const titleTag = x.getElementsByClassName('stitle')[0];
const url = new URL(titleTag.href);
x.dataset.storyid = url.pathname.split('/')[2];
x.dataset.title = titleTag.textContent;
x.dataset.category = matches[2] ? matches[2].replace(/ - $/g, '') : '';
x.dataset.chapters = matches[6].replace(/[^\d]/g, '');
x.dataset.wordcount = matches[7].replace(/[^\d]/g, '');
x.dataset.ratingtimes = matches[8] ? matches[8].replace(/[^\d]/g, '') : 0;
const xutimes = zPadtop2Tag.getElementsByTagName('span');
x.dataset.datesubmit = xutimes[xutimes.length - 1].dataset.xutime;
x.dataset.dateupdate = xutimes.length === 2
? xutimes[0].dataset.xutime : x.dataset.datesubmit;
x.dataset.statusid = / - Complete$/.test(rawText) ? 2 : 1;
}
// Set following dataset for makeStoryData.
x.dataset.crossover = matches[2] ? (matches[1] ? 1 : 0) : '';
x.dataset.rating = matches[3];
x.dataset.language = matches[4];
x.dataset.favtimes = matches[9] ? matches[9].replace(/[^\d]/g, '') : 0;
x.dataset.followtimes = matches[10] ? matches[10].replace(/[^\d]/g, '') : 0;
const genreList = [
'Adventure', 'Angst', 'Crime', 'Drama', 'Family', 'Fantasy',
'Friendship', 'General', 'Horror', 'Humor', 'Hurt/Comfort',
'Mystery', 'Parody', 'Poetry', 'Romance', 'Sci-Fi', 'Spiritual',
'Supernatural', 'Suspense', 'Tragedy', 'Western'
];
x.dataset.genre = matches[5]
? genreList.filter(genre => matches[5].includes(genre)) : '';
x.dataset.character = '';
x.dataset.relationship = '';
if (matches[13]) {
const bracketMatches = matches[13].match(/\[[^\]]+\]/g);
if (bracketMatches) {
const relationship = [];
for (let bracketMatch of bracketMatches) {
// [foo, bar] => [bar, foo]
if (SORT_CHARACTERS_OF_RELATIONSHIP) {
const sortedCharacters = bracketMatch
.split(/\[|\]|, /)
.map(x => x.trim())
.filter(x => x)
.sort()
.join(', ');
relationship.push('[' + sortedCharacters + ']');
// [foo, bar] => [foo, bar]
} else {
relationship.push(bracketMatch);
}
}
if (relationship.length) {
x.dataset.relationship = relationship;
}
}
x.dataset.character =
matches[13].slice(2).split(/\[|\]|, /).map(x => x.trim()).filter(x => x);
}
}
};
const getFandomData = () => {
const aTags = [...document.getElementById('content_wrapper_inner').children]
.filter(element => element.tagName === 'A');
if (aTags.length === 1) {
const fandom = aTags[0].nextElementSibling.nextSibling.textContent.trim();
return { category: fandom, crossover: 0 };
} else {
const crossoverFandom = aTags
.filter(aTag => /\/crossovers\/[^/]+\/\d+\//.test(aTag.href))
.map(aTag => aTag.textContent)
.join(' & ');
return { category: crossoverFandom, crossover: 1 };
}
};
async function loadAllPages () {
const badge = document.getElementById('l_' + this.tabId);
const btn = badge.getElementsByClassName('fas-load-button')[0];
btn.disabled = true;
// get zListTags from urls
const getZListTags = async (url) => {
// eslint-disable-next-line no-undef
const res = await fetch(url);
const text = await res.text();
// eslint-disable-next-line no-undef
const parsedDoc = new DOMParser().parseFromString(text, "text/html");
return parsedDoc.getElementsByClassName('z-list');
};
// Add progress bar
const progressBar = document.createElement('div');
progressBar.classList.add('fas-progress-bar');
const progress = document.createElement('div');
progress.classList.add('fas-progress');
progress.style.width = 1 / (this.urls.length + 1) * 100 + '%';
progressBar.appendChild(progress);
badge.parentElement.insertBefore(progressBar, badge.nextElementSibling);
// Set Dataset to zListTag
const loadedZListTags = [];
const fandomData = getFandomData();
for (let i = 0; i < this.urls.length; i++) {
if (i !== 0) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
const zListTags = await getZListTags(this.urls[i]);
[...zListTags].forEach(x => {
setDatasetToZListTag(x);
if (!x.dataset.category && !x.dataset.crossover) {
x.dataset.category = fandomData.category;
x.dataset.crossover = fandomData.crossover;
}
loadedZListTags.push(x);
});
progress.style.width = (i + 2) / (this.urls.length + 1) * 100 + '%';
}
// Set storyid to .filter_placeholder tags.
// https://greasyfork.org/ja/scripts/13486-fanfiction-net-unwanted-result-filter
for (let i = 0; i < loadedZListTags.length - 1; i++) {
if (!loadedZListTags[i].dataset.storyid && loadedZListTags[i + 1].dataset.storyid) {
loadedZListTags[i].dataset.storyid = loadedZListTags[i + 1].dataset.storyid;
i++;
}
}
// Add loaded zListTags to #id + '_inside'
const inside = document.getElementById(this.tabId + '_inside');
loadedZListTags.forEach(x => {
inside.appendChild(x);
});
// Render page links in the strikethrough style.
const aTags = document.querySelectorAll('#l_cs > a, #content_wrapper_inner > center > a');
[...aTags].forEach(aTag => {
aTag.classList.add('fas-loaded-page');
});
// Reset filter
const clearTag =
document.getElementsByClassName('fas-filter-menus')[0].lastElementChild;
clearTag.click();
};
// Restructure elements for community, search and browse pages
// and add "Load all pages" button
if (/www\.fanfiction\.net\/community\//.test(window.location.href)) {
// Restructure elements of community page.
const zListTags = document.getElementsByClassName('z-list');
if (zListTags.length <= 1) {
return;
}
const newTabInside = document.createElement('div');
newTabInside.id = 'cs_inside';
[...zListTags].forEach(x => {
newTabInside.appendChild(x);
});
const newTab = document.createElement('div');
newTab.id = 'cs';
newTab.appendChild(document.createElement('br'));
newTab.appendChild(newTabInside);
const scriptTag = document.querySelector('#content_wrapper_inner script');
scriptTag.parentElement.insertBefore(newTab, scriptTag);
// Make cs badge which contain number of community stories,
// page information and "Load all pages" button
const badge = document.createElement('div');
badge.id = 'l_' + newTab.id;
badge.align = 'center';
badge.classList.add('fas-badge');
const badgeSpan = document.createElement('span');
badgeSpan.classList.add('fas-badge-number');
badgeSpan.textContent = [...zListTags]
.filter(zListTag => !zListTag.classList.contains('filter_placeholder'))
.length;
badge.appendChild(document.createTextNode('Community Stories: '));
badge.appendChild(badgeSpan);
const pager = document.querySelector('#content_wrapper_inner center');
if (pager) {
badge.appendChild(document.createTextNode(' / '));
pager.childNodes.forEach(x => {
badge.appendChild(x.cloneNode(true));
});
}
// When community page has plural pages, add "Load all pages" button
const aTags = pager ? pager.getElementsByTagName('a') : [];
if (aTags.length) {
const loadBtn = document.createElement('button');
loadBtn.appendChild(document.createTextNode("Load all pages"));
loadBtn.disabled = false;
loadBtn.classList.add('fas-load-button');
const currentUrlSplits = window.location.href.split('/');
const startCurrentUrl = currentUrlSplits.slice(0, 8).join('/');
const current = parseInt(currentUrlSplits[8]);
const endCurrentUrl = currentUrlSplits.slice(9).join('/');
const last = [...aTags]
.map(x => parseInt(x.href.split('/')[8]))
.reduce((p, x) => p > x ? p : x, current);
const urls = [...Array(last).keys()]
.map(x => x + 1)
.filter(x => x !== current)
.map(x => [startCurrentUrl, x, endCurrentUrl].join('/'));
// Add click event
loadBtn.addEventListener('click', {
urls: urls, tabId: 'cs', handleEvent: loadAllPages
});
badge.appendChild(document.createTextNode(' '));
badge.appendChild(loadBtn);
}
scriptTag.parentElement.insertBefore(badge, newTab);
} else if (
/www\.fanfiction\.net\/search\//.test(window.location.href) &&
/&type=story/.test(window.location.search)
) {
// Restructure elements of search page.
const divTags = document.querySelectorAll('#content_wrapper_inner > div');
const zListTags = document.getElementsByClassName('z-list');
if (divTags.length < 2 || zListTags.length <= 1) {
return;
}
const newTabInside = document.createElement('div');
newTabInside.id = 'ss_inside';
newTabInside.appendChild(divTags[0]);
newTabInside.appendChild(divTags[1]);
const newTab = document.createElement('div');
newTab.id = 'ss';
newTab.appendChild(document.createElement('br'));
newTab.appendChild(newTabInside);
divTags[2].parentElement.insertBefore(newTab, divTags[2]);
// Reshape center tag to ss badge which contain number of searched stories,
// page information and "Load all pages" button
const badge = document.getElementsByTagName('center')[0];
badge.id = 'l_' + newTab.id;
badge.classList.add('fas-badge');
const badgeSpan = document.createElement('span');
badgeSpan.classList.add('fas-badge-number');
badgeSpan.textContent = [...zListTags]
.filter(zListTag => !zListTag.classList.contains('filter_placeholder'))
.length;
const fragment = document.createDocumentFragment();
fragment.appendChild(document.createTextNode('Searched Stories: '));
fragment.appendChild(badgeSpan);
fragment.appendChild(document.createTextNode(' / '));
badge.insertBefore(fragment, badge.firstChild);
// When search page has plural pages, add "Load all pages" button
const aTags = badge.getElementsByTagName('a');
if (aTags.length) {
const loadBtn = document.createElement('button');
loadBtn.appendChild(document.createTextNode("Load all pages"));
loadBtn.disabled = false;
loadBtn.classList.add('fas-load-button');
const currentPageMatch = window.location.search.match(/&ppage=(\d+)/);
const current = currentPageMatch ? parseInt(currentPageMatch[1]) : 1;
const last = [...aTags]
.map(aTag => aTag.href.match(/&ppage=(\d+)/))
.map(matches => parseInt(matches[1]))
.reduce((p, x) => p > x ? p : x, current);
const urls = [...Array(last).keys()]
.map(x => x + 1)
.filter(x => x !== current)
.map(x => aTags[0].href.replace(/&ppage=\d+/, "&ppage=" + x));
// Add click event
loadBtn.addEventListener('click', {
urls: urls, tabId: 'ss', handleEvent: loadAllPages
});
const fragment = document.createDocumentFragment();
fragment.appendChild(document.createTextNode(' '));
fragment.appendChild(loadBtn);
badge.appendChild(fragment);
}
} else if (document.getElementById('filters')) {
// Restructure elements of browse page.
const zListTags = document.getElementsByClassName('z-list');
if (zListTags.length <= 1) {
return;
}
const newTabInside = document.createElement('div');
newTabInside.id = 'bs_inside';
[...zListTags].forEach(x => {
newTabInside.appendChild(x);
});
const newTab = document.createElement('div');
newTab.id = 'bs';
newTab.appendChild(document.createElement('br'));
newTab.appendChild(newTabInside);
const centerTags = [...document.getElementsByTagName('center')]
.filter(centerTag => centerTag.getElementsByTagName('a').length);
if (centerTags.length) {
centerTags[0].parentElement.insertBefore(newTab, centerTags[1]);
} else {
const scriptTag = document.querySelector('#content_wrapper_inner script');
scriptTag.parentElement.insertBefore(newTab, scriptTag);
}
// Reshape center tag to bs badge which contain number of browse stories,
// page information and "Load all pages" button
const badge = centerTags.length ? centerTags[0] : document.createElement('center');
badge.id = 'l_' + newTab.id;
badge.classList.add('fas-badge');
const badgeSpan = document.createElement('span');
badgeSpan.classList.add('fas-badge-number');
badgeSpan.textContent = [...zListTags]
.filter(zListTag => !zListTag.classList.contains('filter_placeholder'))
.length;
const fragment = document.createDocumentFragment();
fragment.appendChild(document.createTextNode('Browse Stories: '));
fragment.appendChild(badgeSpan);
if (!centerTags.length) {
badge.insertBefore(fragment, badge.firstChild);
newTab.parentElement.insertBefore(badge, newTab);
} else {
fragment.appendChild(document.createTextNode(' / '));
badge.insertBefore(fragment, badge.firstChild);
}
// When search page has plural pages, add "Load all pages" button
const aTags = badge.getElementsByTagName('a');
if (aTags.length) {
const loadBtn = document.createElement('button');
loadBtn.appendChild(document.createTextNode("Load all pages"));
loadBtn.disabled = false;
loadBtn.classList.add('fas-load-button');
const currentPageMatch = window.location.search.match(/&p=(\d+)/);
const current = currentPageMatch ? parseInt(currentPageMatch[1]) : 1;
const last = [...aTags]
.map(aTag => aTag.href.match(/&p=(\d+)/))
.map(matches => parseInt(matches[1]))
.reduce((p, x) => p > x ? p : x, current);
const urls = [...Array(last).keys()]
.map(x => x + 1)
.filter(x => x !== current)
.map(x => aTags[0].href.replace(/&p=\d+/, "&p=" + x));
// Add click event
loadBtn.addEventListener('click', {
urls: urls, tabId: 'bs', handleEvent: loadAllPages
});
const fragment = document.createDocumentFragment();
fragment.appendChild(document.createTextNode(' '));
fragment.appendChild(loadBtn);
badge.appendChild(fragment);
}
} else if (/www\.fanfiction\.net\/u\//.test(window.location.href)) {
// Hide author biography automatically
if (HIDE_BIO_AUTOMATICALLY) {
const bioTag = document.getElementById('bio_text');
if (bioTag && bioTag.textContent === "hide bio") {
bioTag.click();
}
}
}
// Add filters and sorters
for (let tabId of ['st', 'fs', 'cs', 'ss', 'bs']) {
// Initiation
const tab = document.getElementById(tabId);
const tabInside = document.getElementById(tabId + '_inside');
// Is there a need to add sorters and filters?
const moreThanOneStories = tabInside && tabInside.getElementsByClassName('z-list').length >= 2;
if (!moreThanOneStories) {
continue;
}
// Data-set initiation
const zListTags = tabInside.getElementsByClassName('z-list');
[...zListTags].forEach(x => {
setDatasetToZListTag(x);
});
const datasetIncludeCategory = [...zListTags].some(x => x.dataset.category);
if (!datasetIncludeCategory) {
const fandomData = getFandomData();
[...zListTags].forEach(x => {
x.dataset.category = fandomData.category;
x.dataset.crossover = fandomData.crossover;
});
}
// Set storyid to .filter_placeholder tags.
// https://greasyfork.org/ja/scripts/13486-fanfiction-net-unwanted-result-filter
for (let i = 0; i < zListTags.length - 1; i++) {
if (!zListTags[i].dataset.storyid && zListTags[i + 1].dataset.storyid) {
zListTags[i].dataset.storyid = zListTags[i + 1].dataset.storyid;
i++;
}
}
// Sorter functions
const makeSorterFunctionBy = (dataId, order = 'asc') => {
const sorterFunctionBy = (a, b) => {
const aData = makeStoryData(a);
const bData = makeStoryData(b);
if (aData[dataId] < bData[dataId]) {
return order === 'asc' ? -1 : 1;
} else if (aData[dataId] > bData[dataId]) {
return order === 'asc' ? 1 : -1;
} else {
if (dataId !== 'title') {
const sortByTitle = makeSorterFunctionBy('title');
return sortByTitle(a, b);
} else {
return 0;
}
}
};
return sorterFunctionBy;
};
const makeSorterTag = (sorterDic) => {
const sorterId = sorterDic.dataId;
const sorterText = sorterDic.text;
const firstOrder = sorterDic.order;
const sorterSpan = document.createElement('span');
sorterSpan.textContent = sorterText;
sorterSpan.classList.add('fas-sorter');
sorterSpan.dataset.order = '';
sorterSpan.addEventListener('click', (e) => {
const sortedWithFirstOrder = e.target.dataset.order === orderSymbol[firstOrder];
const sorterTags = document.getElementsByClassName('fas-sorter');
[...sorterTags].forEach(sorterTag => {
sorterTag.dataset.order = '';
});
const [secondOrder] = ['asc', 'dsc'].filter(x => x !== firstOrder);
const nextOrder = sortedWithFirstOrder ? secondOrder : firstOrder;
e.target.dataset.order = orderSymbol[nextOrder];
const sortBySorterId = makeSorterFunctionBy(sorterId, nextOrder);
// .filter_placeholder is added by
// https://greasyfork.org/ja/scripts/13486-fanfiction-net-unwanted-result-filter
const zListTags = tabInside.querySelectorAll('div.z-list:not(.filter_placeholder)');
const placeHolderTags = tabInside.getElementsByClassName('filter_placeholder');
const fragment = document.createDocumentFragment();
[...zListTags]
.sort(sortBySorterId)
.forEach(x => {
if (placeHolderTags.length) {
[...placeHolderTags]
.filter(p => x.dataset.storyid === p.dataset.storyid)
.forEach(p => fragment.appendChild(p));
}
fragment.appendChild(x);
});
tabInside.appendChild(fragment);
});
return sorterSpan;
};
// Make sorters
// Remove original sorter span in author page.
if (['st', 'fs'].includes(tabId)) {
while (tab.firstElementChild.firstChild) {
tab.firstElementChild.removeChild(tab.firstElementChild.firstChild);
}
}
// Append sorters
const fragment = document.createDocumentFragment();
fragment.appendChild(document.createTextNode('Sort: '));
sorterDicList.forEach(sorterDic => {
const sorterSpan = makeSorterTag(sorterDic);
fragment.appendChild(sorterSpan);
fragment.appendChild(document.createTextNode(' . '));
});
if (['st', 'fs'].includes(tabId)) {
tab.firstElementChild.appendChild(fragment);
} else if (['cs', 'ss', 'bs'].includes(tabId)) {
const sorterTag = document.createElement('div');
sorterTag.classList.add('fas-sorter-div');
sorterTag.appendChild(fragment);
tab.insertBefore(sorterTag, tab.firstElementChild);
}
// Filter functions
// List of exceptional fandoms contain ' & '
// eslint-disable-next-line no-undef
const resourceText = GM_getResourceText('JSON');
const exceptionalFandomList = resourceText ? JSON.parse(resourceText).fandoms : [];
// Make story data from .zList tag.
const makeStoryData = (zList) => {
const storyData = {};
storyData.story_id = parseInt(zList.dataset.storyid);
// .zList.filter_placeholder tag have only dataset.storyid.
// https://greasyfork.org/ja/scripts/13486-fanfiction-net-unwanted-result-filter
if (zList.dataset.title) {
storyData.title = zList.dataset.title;
storyData.crossover = parseInt(zList.dataset.crossover) ? 'X' : '=';
const rawFandom = zList.dataset.category;
if (storyData.crossover === 'X') {
const splitFandoms = rawFandom.split(' & ');
if (splitFandoms.length === 2) {
storyData.fandom = splitFandoms.sort();
} else {
storyData.fandom = [];
for (let fandom of exceptionalFandomList) {
const escapedFandom = escapeRegExp(fandom);
const fandomRegex =
new RegExp('^' + escapedFandom + " & (.+)$|^(.+) & " + escapedFandom + '$', '');
const matches = rawFandom.match(fandomRegex);
if (matches) {
const fandom2 = matches[1] || matches[2];
storyData.fandom = [fandom, fandom2].sort();
break;
}
}
if (!storyData.fandom.length) {
storyData.fandom = [rawFandom];
}
}
} else {
storyData.fandom = [rawFandom];
}
storyData.rating = zList.dataset.rating;
storyData.language = zList.dataset.language;
storyData.genre = zList.dataset.genre
? zList.dataset.genre.split(',') : [];
storyData.chapters = parseInt(zList.dataset.chapters);
storyData.word_count = parseInt(zList.dataset.wordcount);
storyData.reviews = parseInt(zList.dataset.ratingtimes);
storyData.favs = parseInt(zList.dataset.favtimes);
storyData.follows = parseInt(zList.dataset.followtimes);
storyData.published = parseInt(zList.dataset.datesubmit);
storyData.updated = parseInt(zList.dataset.dateupdate);
storyData.character = zList.dataset.character
? zList.dataset.character.split(',') : [];
storyData.relationship = zList.dataset.relationship
? zList.dataset.relationship.match(/\[[^\]]+\]/g) : [];
storyData.status =
parseInt(zList.dataset.statusid) === 1 ? 'In-Progress' : 'Complete';
}
return storyData;
};
const timeStrToInt = (timeStr) => {
const hour = 3600;
const day = hour * 24;
const week = hour * 24 * 7;
const month = week * 4;
const year = month * 12;
const matches = timeStr
.replace(/hour(s)?/, hour.toString())
.replace(/day(s)?/, day.toString())
.replace(/week(s)?/, week.toString())
.replace(/month(s)?/, month.toString())
.replace(/year(s)?/, year.toString())
.match(/\d+/g);
return matches ? parseInt(matches[0]) * parseInt(matches[1]) : null;
};
// Judge if a story with storyValue passes through filter with selectValue.
const throughFilter = (storyValue, selectValue, filterKey) => {
if (selectValue === 'default') {
return true;
} else {
const filterMode = filterDic[filterKey].mode;
const resultByFilterMode = (() => {
if (filterMode === 'equal') {
return storyValue === selectValue;
} else if (filterMode === 'contain') {
return storyValue.includes(selectValue);
} else if (filterMode === 'dateRange') {
const now = Math.floor(Date.now() / 1000);
const intRange = timeStrToInt(selectValue);
return intRange === null || now - storyValue <= intRange;
} else if (['gt', 'ge', 'le'].includes) {
const execResult = /\d+/.exec(selectValue.replace(/K/, '000'));
const intSelectValue = execResult ? parseInt(execResult[0]) : null;
if (filterMode === 'gt') {
return storyValue > intSelectValue;
} else if (filterMode === 'ge') {
return storyValue >= intSelectValue;
} else if (filterMode === 'le') {
return intSelectValue === null || storyValue <= intSelectValue;
}
}
})();
return filterDic[filterKey].reverse ? !resultByFilterMode : resultByFilterMode;
}
};
const makeStoryDic = () => {
const selectFilterDic = {};
Object.keys(filterDic).forEach(filterKey => {
const selectId = tabId + '_' + filterKey + '_select';
const selectTag = document.getElementById(selectId);
selectFilterDic[filterKey] = selectTag ? selectTag.value : null;
});
const storyDic = {};
const zListTags = tabInside.getElementsByClassName('z-list');
[...zListTags].forEach(x => {
const storyData = makeStoryData(x);
const id = storyData.story_id;
storyDic[id] = storyDic[id] || {};
// .filter_placeholder is added by
// https://greasyfork.org/ja/scripts/13486-fanfiction-net-unwanted-result-filter
if (x.classList.contains('filter_placeholder')) {
storyDic[id].placeHolder = x;
} else {
storyDic[id].dom = x;
Object.keys(filterDic).forEach(filterKey => {
const dataId = filterDic[filterKey].dataId;
storyDic[id][filterKey] = storyData[dataId];
});
storyDic[id].filterStatus = {};
Object.keys(selectFilterDic).forEach(filterKey => {
if (selectFilterDic[filterKey] === null) {
storyDic[id].filterStatus[filterKey] = true; // Initialization
} else {
const filterFlag =
throughFilter(storyDic[id][filterKey], selectFilterDic[filterKey], filterKey);
storyDic[id].filterStatus[filterKey] = filterFlag;
}
});
}
});
return storyDic;
};
const changeStoryDisplay = (story) => {
// If a story passes through every filter
story.displayFlag = Object.keys(story.filterStatus).every(x => story.filterStatus[x]);
// .filter_placeholder is added by
// https://greasyfork.org/ja/scripts/13486-fanfiction-net-unwanted-result-filter
if (story.placeHolder) {
story.placeHolder.style.display = story.displayFlag ? '' : 'none';
} else {
story.dom.style.display = story.displayFlag ? '' : 'none';
}
};
const makeAlternatelyFilteredStoryIds = (storyDic, alternateOptionValue, filterKey) => {
return Object.keys(storyDic)
.filter(x => {
const filterStatus = { ...storyDic[x].filterStatus };
filterStatus[filterKey] =
throughFilter(storyDic[x][filterKey], alternateOptionValue, filterKey);
return Object.keys(filterStatus).every(x => filterStatus[x]);
}).sort();
};
// Collect all filter doms at once by making selectDic
const makeSelectDic = () => {
const selectDic = {};
Object.keys(filterDic).forEach(filterKey => {
const selectTag = document.getElementById(tabId + '_' + filterKey + '_select');
selectDic[filterKey] = {};
selectDic[filterKey].dom = selectTag;
selectDic[filterKey].value = selectDic[filterKey].dom.value;
selectDic[filterKey].displayed = selectDic[filterKey].dom.style.display === '';
selectDic[filterKey].disabled = selectDic[filterKey].dom.hasAttribute('disabled');
selectDic[filterKey].accessible =
selectDic[filterKey].displayed && !selectDic[filterKey].disabled;
selectDic[filterKey].optionDic = {};
if (selectDic[filterKey].accessible) {
const optionTags = selectTag.getElementsByTagName('option');
[...optionTags].forEach(optionTag => {
selectDic[filterKey].optionDic[optionTag.value] = { dom: optionTag };
});
}
});
return selectDic;
};
// generateCombinations([1, 2, 3], 2) => [[1, 2], [1, 3], [2, 3]]
const generateCombinations = (xs, count, previous = []) => {
if (count === 0) {
return [previous];
} else {
return xs.reduce((acc, c, i) => {
const nxs = xs.filter((_, j) => j > i);
return [...acc, ...generateCombinations(nxs, count - 1, [...previous, c])];
}, []);
}
};
// Apply selectKey filter with selectValue to all stories.
const filterStories = (selectKey, selectValue) => {
const storyDic = makeStoryDic();
// Change display of each story.
Object.keys(storyDic).forEach(x => {
storyDic[x].filterStatus[selectKey] =
throughFilter(storyDic[x][selectKey], selectValue, selectKey);
changeStoryDisplay(storyDic[x]);
});
// Hide useless options.
const selectDic = makeSelectDic();
Object.keys(selectDic)
.filter(filterKey => selectDic[filterKey].accessible)
.forEach(filterKey => {
const optionDic = selectDic[filterKey].optionDic;
// By changing to one of usableOptionValues, display of stories would change.
// Excluded options can't change display of stories.
const usableOptionValues = (() => {
// Make usableStoryValues from alternately filtered stories
// by neutralizing each filter.
const usableStoryValues = Object.keys(storyDic)
.filter(x => {
const filterStatus = { ...storyDic[x].filterStatus };
filterStatus[filterKey] = true;
return Object.keys(filterStatus).every(x => filterStatus[x]);
}).map(x => storyDic[x][filterKey])
.reduce((p, x) => p.concat(x), [])
.filter((x, i, self) => self.indexOf(x) === i)
.sort((a, b) => a - b);
// Remove redundant options when filter mode is 'gt', 'ge', 'le', or 'dateRange'
const filterMode = filterDic[filterKey].mode;
if (['gt', 'ge', 'le', 'dateRange'].includes(filterMode)) {
const reverse = (filterDic[filterKey].reverse);
const sufficientOptionValues = usableStoryValues.map(storyValue => {
const optionValues = Object.keys(optionDic).filter(x => x !== 'default');
const throughOptionValues = optionValues
.filter(optionValue => {
const result = throughFilter(storyValue, optionValue, filterKey);
return reverse ? !result : result;
});
if (filterMode === 'gt' || filterMode === 'ge') {
return throughOptionValues[throughOptionValues.length - 1];
} else if (filterMode === 'le' || filterMode === 'dateRange') {
return throughOptionValues[0];
}
}).filter((x, i, self) => self.indexOf(x) === i);
return sufficientOptionValues;
} else {
return usableStoryValues;
}
})();
// Add/remove hidden attribute to options.
Object.keys(optionDic).forEach(optionValue => {
// usableOptionValues don't include 'default'.
const usable =
optionValue === 'default' ? true : usableOptionValues.includes(optionValue);
optionDic[optionValue].usable = usable;
if (!usable) {
optionDic[optionValue].dom.setAttribute('hidden', '');
} else {
optionDic[optionValue].dom.removeAttribute('hidden');
}
});
});
// Hide same value when filterKey uses same dataId.
Object.keys(filterDic)
.filter(filterKey => selectDic[filterKey].accessible)
.filter(filterKey => !filterDic[filterKey].options)
.forEach(filterKey => {
const filterKeysBySameDataId = Object.keys(filterDic)
.filter(x => selectDic[x].accessible)
.filter(x => x !== filterKey)
.filter(x => filterDic[x].dataId === filterDic[filterKey].dataId);
if (filterKeysBySameDataId.length) {
filterKeysBySameDataId
.filter(x => !filterDic[x].reverse)
.filter(x => selectDic[x].value !== 'default')
.forEach(x => {
const sameValue = selectDic[x].value;
selectDic[filterKey].optionDic[sameValue].dom.setAttribute('hidden', '');
selectDic[filterKey].optionDic[sameValue].usable = false;
});
}
});
const filteredStoryIds = Object.keys(storyDic)
.filter(x => storyDic[x].displayFlag)
.sort();
// Add/remove
// .fas-filter-menu_locked, .fas-filter-menu-item_locked and menuItemGroupClasses.
Object.keys(selectDic)
.filter(filterKey => selectDic[filterKey].accessible)
.forEach(filterKey => {
const optionDic = selectDic[filterKey].optionDic;
// Remove
// .fas-filter-menu_locked and .fas-filter-menu-item_locked and menuItemGroupClasses.
selectDic[filterKey].dom.classList.remove('fas-filter-menu_locked');
Object.keys(optionDic).forEach(x => {
optionDic[x].dom.classList.remove(
'fas-filter-menu-item_locked',
...menuItemGroupClasses,
'fas-filter-menu-item_story-zero'
);
});
// Add .fas-filter-menu-item_locked to each option tag
// when alternatelyFilteredStoryIds are equal to filteredStoryIds.
const optionsLocked = Object.keys(optionDic)
.filter(optionValue => optionDic[optionValue].usable)
.map(optionValue => {
const alternatelyFilteredStoryIds =
makeAlternatelyFilteredStoryIds(storyDic, optionValue, filterKey);
optionDic[optionValue].storyNumber = alternatelyFilteredStoryIds.length;
if (filterDic[filterKey].reverse && alternatelyFilteredStoryIds.length === 0) {
optionDic[optionValue].dom.classList.add('fas-filter-menu-item_story-zero');
}
const idsEqualFlag =
JSON.stringify(filteredStoryIds) === JSON.stringify(alternatelyFilteredStoryIds);
if (idsEqualFlag) {
optionDic[optionValue].dom.classList.add('fas-filter-menu-item_locked');
}
return idsEqualFlag;
}).every(x => x);
if (optionsLocked) {
// Add .fas-filter-menu_locked to select tag
// when every alternatelyFilteredStoryIds are equal to filteredStoryIds.
selectDic[filterKey].dom.classList.add('fas-filter-menu_locked');
} else if (menuItemGroupClasses.length) {
// Highlight options by filter result by adding menuItemGroupClasses
// Remove menuItemGroupClasses
Object.keys(optionDic).forEach(optionValue => {
optionDic[optionValue].dom.classList.remove(...menuItemGroupClasses);
});
// Unique storyNumber in dsc order
const filterResults = Object.keys(optionDic)
.filter(optionValue => optionDic[optionValue].usable)
.map(optionValue => optionDic[optionValue].storyNumber)
.filter((x, i, self) => self.indexOf(x) === i)
.sort((a, b) => b - a);
// Generate combinations of filterResults
// which is divided into menuItemGroupClasses.length groups.
const dividedResultsCombinations = (() => {
if (filterResults.length <= menuItemGroupClasses.length) {
// There is no need to divide filterResults.
return [filterResults.map(x => [x])];
} else {
// Generate combinations of divideIndexes.
// Divide filterResults by using divideIndexesCombination.
const middleIndexes = [...Array(filterResults.length).keys()].slice(1);
return generateCombinations(middleIndexes, menuItemGroupClasses.length - 1)
.map(middleIndexesCombination => {
const divideIndexes = [0, ...middleIndexesCombination, filterResults.length];
const dividedResultsCombination = [];
divideIndexes.reduce((p, x) => {
dividedResultsCombination.push(filterResults.slice(p, x));
return x;
});
return dividedResultsCombination;
});
}
})();
// Jenks Natural Breaks.
// For each dividedResultsCombination,
// calculate sum of squared deviations for class means(SDCM).
// dividedResultsCombination with minimum SDCM score is the best match.
const minIndex = (() => {
if (dividedResultsCombinations.length === 1) {
return 0;
} else {
return dividedResultsCombinations.map(dividedResultsCombination => {
return dividedResultsCombination.map(dividedResults => {
const classMean =
dividedResults.reduce((p, x) => p + x) / dividedResults.length;
return dividedResults.map(x => (x - classMean) ** 2).reduce((p, x) => p + x);
}).reduce((p, x) => p + x);
}).reduce((iMin, x, i, self) => x < self[iMin] ? i : iMin, 0);
}
})();
// Add menuItemGroupClasses according to dividedResultsCombinations[minIndex]
Object.keys(optionDic)
.filter(optionValue => optionDic[optionValue].usable)
.forEach(optionValue => {
const dividedResultsIndex = dividedResultsCombinations[minIndex]
.findIndex(dividedResults =>
dividedResults.includes(optionDic[optionValue].storyNumber)
);
optionDic[optionValue].dom.classList.add(menuItemGroupClasses[dividedResultsIndex]);
});
}
});
// Change badge's story number.
const badge = document.getElementById('l_' + tabId).firstElementChild;
const displayedStoryNumber =
[...Object.keys(storyDic).filter(x => storyDic[x].displayFlag)].length;
badge.textContent = displayedStoryNumber;
};
// Append filter Div
const appendFilterDiv = () => {
// Make filterDiv
const filterDiv = document.createElement('div');
filterDiv.classList.add('fas-filter-menus');
filterDiv.appendChild(document.createTextNode('Filter: '));
// Make initialStoryDic from initial state of stories.
const initialStoryDic = makeStoryDic();
const initialStoryIds = Object.keys(initialStoryDic).sort();
// Log initial attributes and classList for clear feature.
const initialSelectDic = {};
const makeSelectTag = (filterKey, defaultText) => {
const selectTag = document.createElement('select');
selectTag.id = tabId + '_' + filterKey + '_select';
selectTag.title = filterDic[filterKey].title;
selectTag.classList.add('fas-filter-menu');
if (filterDic[filterKey].reverse) {
selectTag.classList.add('fas-filter-exclude-menu');
}
// Make optionValues from filterKey values of
// each story, wordCountOptions, kudoCountOptions or dateRangeOptions.
const optionValues = (() => {
const storyValues = Object.keys(initialStoryDic)
.map(x => initialStoryDic[x][filterKey])
.reduce((p, x) => p.concat(x), [])
.filter((x, i, self) => self.indexOf(x) === i)
.sort();
const filterMode = filterDic[filterKey].mode;
if (filterKey === 'rating') {
const orderedOptions = ['K', 'K+', 'T', 'M'];
return orderedOptions.filter(x => storyValues.includes(x));
} else if (['gt', 'ge', 'le', 'dateRange'].includes(filterMode)) {
const allOptionValues = (() => {
if (filterMode === 'gt') {
return ['0'].concat(filterDic[filterKey].options)
.map(x => x + ' <');
} else if (filterMode === 'ge') {
return ['0'].concat(filterDic[filterKey].options)
.map(x => x + ' ≤');
} else if (filterMode === 'le') {
return filterDic[filterKey].options.concat(['∞'])
.map(x => '≤ ' + x);
} else if (filterMode === 'dateRange') {
return filterDic[filterKey].options.concat(['∞'])
.map(x => 'With in ' + x);
}
})();
// Remove redundant options
// when filter mode is 'gt', 'ge', 'le', or 'dateRange'
const reverse = (filterDic[filterKey].reverse);
const sufficientOptionValues = storyValues.map(storyValue => {
const throughOptionValues = allOptionValues
.filter(optionValue => {
const result = throughFilter(storyValue, optionValue, filterKey);
return reverse ? !result : result;
});
if (filterMode === 'gt' || filterMode === 'ge') {
return throughOptionValues[throughOptionValues.length - 1];
} else if (filterMode === 'le' || filterMode === 'dateRange') {
return throughOptionValues[0];
}
}).filter((x, i, self) => self.indexOf(x) === i);
// "return sufficientOptionValues;" would disturb order of options.
return allOptionValues.filter(x => sufficientOptionValues.includes(x));
} else {
return storyValues;
}
})();
initialSelectDic[filterKey] = {};
initialSelectDic[filterKey].initialOptionDic = {};
const initialOptionDic = initialSelectDic[filterKey].initialOptionDic;
// Add .fas-filter-menu-item_locked to each option tag
// when alternatelyFilteredStoryIds are equal to initialStoryIds.
const initialOptionLocked = ['default', ...optionValues].map(optionValue => {
initialOptionDic[optionValue] = {};
const option = document.createElement('option');
option.textContent = optionValue === 'default' ? defaultText : optionValue;
option.value = optionValue;
option.classList.add('fas-filter-menu-item');
const alternatelyFilteredStoryIds =
makeAlternatelyFilteredStoryIds(initialStoryDic, optionValue, filterKey);
initialOptionDic[optionValue].storyNumber = alternatelyFilteredStoryIds.length;
if (filterDic[filterKey].reverse && alternatelyFilteredStoryIds.length === 0) {
option.classList.add('fas-filter-menu-item_story-zero');
}
const idsEqualFlag =
JSON.stringify(initialStoryIds) === JSON.stringify(alternatelyFilteredStoryIds);
if (idsEqualFlag) {
option.classList.add('fas-filter-menu-item_locked');
}
selectTag.appendChild(option);
return idsEqualFlag;
}).every(x => x);
const optionTags = selectTag.getElementsByTagName('option');
if (initialOptionLocked) {
// When every alternatelyFilteredStoryIds are equal to initialStoryIds,
if (optionTags.length === 1) {
// if every story have no filter value, don't display filter.
selectTag.style.display = 'none';
} else if (optionTags.length === 2) {
// if every stories has same value, disable filter.
selectTag.value = optionTags[1].value;
selectTag.setAttribute('disabled', '');
} else {
// else, add .fas-filter-menu_locked.
selectTag.classList.add('fas-filter-menu_locked');
}
} else if (menuItemGroupClasses.length) {
// Highlight options by filter result by adding menuItemGroupClasses
// Unique storyNumber in dsc order
const filterResults = Object.keys(initialOptionDic)
.map(optionValue => initialOptionDic[optionValue].storyNumber)
.filter((x, i, self) => self.indexOf(x) === i)
.sort((a, b) => b - a);
// Generate combinations of filterResults
// which is divided into menuItemGroupClasses.length groups.
const dividedResultsCombinations = (() => {
if (filterResults.length <= menuItemGroupClasses.length) {
// There is no need to divide filterResults.
return [filterResults.map(x => [x])];
} else {
// Generate combinations of divideIndexes.
// Divide filterResults by using divideIndexesCombination.
const middleIndexes = [...Array(filterResults.length).keys()].slice(1);
return generateCombinations(middleIndexes, menuItemGroupClasses.length - 1)
.map(middleIndexesCombination => {
const divideIndexes =
[0, ...middleIndexesCombination, filterResults.length];
const dividedResultsCombination = [];
divideIndexes.reduce((p, x) => {
dividedResultsCombination.push(filterResults.slice(p, x));
return x;
});
return dividedResultsCombination;
});
}
})();
// Jenks Natural Breaks.
// For each dividedResultsCombination,
// calculate sum of squared deviations for class means(SDCM).
// dividedResultsCombination with minimum SDCM score is the best match.
const minIndex = (() => {
if (dividedResultsCombinations.length === 1) {
return 0;
} else {
return dividedResultsCombinations.map(dividedResultsCombination => {
return dividedResultsCombination.map(dividedResults => {
const classMean =
dividedResults.reduce((p, x) => p + x) / dividedResults.length;
return dividedResults
.map(x => (x - classMean) ** 2)
.reduce((p, x) => p + x);
}).reduce((p, x) => p + x);
}).reduce((iMin, x, i, self) => x < self[iMin] ? i : iMin, 0);
}
})();
// Add menuItemGroupClasses according to dividedResultsCombinations[minIndex]
Object.keys(initialOptionDic)
.forEach(optionValue => {
const dividedResultsIndex = dividedResultsCombinations[minIndex]
.findIndex(dividedResults => {
return dividedResults.includes(
initialOptionDic[optionValue].storyNumber
);
});
[...optionTags]
.filter(x => x.value === optionValue)
.forEach(x => {
x.classList.add(menuItemGroupClasses[dividedResultsIndex]);
});
});
}
// Log initial classList
initialSelectDic[filterKey].initialMenuClassName = selectTag.className;
[...optionTags].forEach(optionTag => {
initialOptionDic[optionTag.value].initialItemClassName = optionTag.className;
});
// Change display of stories by selected filter value.
selectTag.addEventListener('change', (e) => {
filterStories(filterKey, selectTag.value);
});
return selectTag;
};
// Make and append filters
Object.keys(filterDic).forEach(filterKey => {
const filterTag = makeSelectTag(filterKey, filterDic[filterKey].text);
filterDiv.appendChild(filterTag);
filterDiv.appendChild(document.createTextNode(' '));
});
// Don't display filter which doesn't meet a filterDic[filterKey].condition
Object.keys(filterDic)
.filter(filterKey => filterDic[filterKey].condition)
.forEach(filterKey => {
const condition = filterDic[filterKey].condition;
const conditionInitialOptions =
Object.keys(initialSelectDic[condition.filterKey].initialOptionDic);
if (!conditionInitialOptions.includes(condition.value)) {
const selectTag = [...filterDiv.children]
.find(selectTag => selectTag.id === tabId + '_' + filterKey + '_select');
selectTag.style.display = 'none';
}
});
// Add Clear button:
// Clear filter settings and revert attributes and class according to initialSelectDic.
// Make new filterDiv when "Load all pages" button is clicked.
const clear = document.createElement('span');
clear.textContent = 'Clear';
clear.title = "Reset filter values to default";
clear.className = 'gray';
clear.addEventListener('click', (e) => {
const selectDic = makeSelectDic();
const changed = Object.keys(selectDic)
.filter(filterKey => selectDic[filterKey].accessible)
.map(filterKey => selectDic[filterKey].value !== 'default')
.some(x => x);
const zListTags = [...tabInside.getElementsByClassName('z-list')]
.filter(zListTag => !zListTag.classList.contains('filtered'));
const allPageLoaded = zListTags.length !== initialStoryIds.length;
// Is there a need to run clear feature?
if (changed) {
Object.keys(selectDic)
.filter(filterKey => selectDic[filterKey].accessible)
.forEach(filterKey => {
// Clear each filter
if (selectDic[filterKey].value !== 'default') {
selectDic[filterKey].dom.value = 'default';
}
// Revert attributes and class of select tag according to initialSelectDic.
const initialMenuClassName = initialSelectDic[filterKey].initialMenuClassName;
if (selectDic[filterKey].dom.className !== initialMenuClassName) {
selectDic[filterKey].dom.className = initialMenuClassName;
}
// Revert attributes and class of option tag according to optionDic.
const optionDic = selectDic[filterKey].optionDic;
const initialOptionDic = initialSelectDic[filterKey].initialOptionDic;
Object.keys(optionDic).forEach(optionValue => {
const initialItemClassName =
initialOptionDic[optionValue].initialItemClassName;
if (optionDic[optionValue].dom.hasAttribute('hidden')) {
optionDic[optionValue].dom.removeAttribute('hidden');
}
if (optionDic[optionValue].dom.className !== initialItemClassName) {
optionDic[optionValue].dom.className = initialItemClassName;
}
});
});
}
if (changed || allPageLoaded) {
// Change display of stories to initial state.
zListTags
.filter(zListTag => zListTag.style.display === 'none')
.forEach(x => {
x.style.display = '';
});
// Change story number to initial state.
const badge = document.getElementById('l_' + tabId).firstElementChild;
badge.textContent = zListTags.length;
}
// When "Load all pages" button is clicked,
// remove old filterDiv and add new filterDiv.
if (allPageLoaded) {
tab.removeChild(tab.firstElementChild);
appendFilterDiv();
}
});
filterDiv.appendChild(clear);
// Append filterDiv
tab.insertBefore(filterDiv, tab.firstChild);
};
// Append filters
appendFilterDiv();
}
})();