// ==UserScript==
// @name Fanfiction.net: Filter and Sorter
// @namespace https://greasyfork.org/en/users/163551-vannius
// @version 1.0
// @license MIT
// @description Add filters and additional sorters to author page and community page of Fanfiction.net.
// @author Vannius
// @match https://www.fanfiction.net/u/*
// @match https://www.fanfiction.net/community/*
// @grant GM_addStyle
// ==/UserScript==
(function () {
'use strict';
// 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'];
// Options for reviews, favs and follows filters.
// Format: [\d+(K)?] in ascending order
const kudoCountOptions = ['10', '50', '100', '200', '400', '600', '800', '1K'];
// 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'];
// 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: when mode is 'gt', 'ge', 'le', 'dateRange', you have to specify.
// reverse: reverse result of throughFilter()
const filterDic = {
fandom: { dataId: 'fandom', text: 'Fandom', title: "Fandom filter", mode: 'contain' },
crossover: { dataId: 'crossover', text: 'Crossover ?', title: "Crossover filter", mode: 'equal' },
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' },
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
// [[backgroundColor, color]]
const red = ['#ff1111', '#f96540', '#f4a26d', '#efcc99', 'white'].map(color => [color, '#555']);
// eslint-disable-next-line no-unused-vars
const blue = makeGradualColorScheme('#11f', '#fff', 'rgb', 5, '#555');
// eslint-disable-next-line no-unused-vars
const purple = makeGradualColorScheme('#cd47fd', '#e8eaf6', 'hsv', 5, '#555');
// colorScheme setting
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-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
// Make graduation of backgournd color from startHexColor to endHexColor with gradationsLength steps
// by using colorSpace('rgb' or 'hsv').
// Determine readable letterColor from [defaultForegroundHexColor, white, black] automatically.
function makeGradualColorScheme (startHexColor, endHexColor, colorSpace, gradationsLength, defaultForegroundHexColor) {
if (![4, 7].includes(startHexColor.length) || ![4, 7].includes(endHexColor.length)) {
console.log(`Error!, args of makeGradualColorScheme, ${startHexColor} or ${endHexColor} is invalid.`);
return [];
}
if (!['rgb', 'hsv'].includes(colorSpace)) {
console.log(`Error!, args of makeGradualColorScheme, ${colorSpace} is invalid.`);
return [];
}
// Convert Functions
const 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));
};
const rgbToHexColor = (rgb) => {
return rgb
.map(x => x.toString(16).padStart(2, '0'))
.reduce((p, x) => p + x, '#');
};
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].map(x => Math.round(x));
};
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));
};
// Convert hex color str into int rgb array.
const startRgb = hexColorToRgb(startHexColor);
const endRgb = hexColorToRgb(endHexColor);
const defaultForegroundRgb = hexColorToRgb(defaultForegroundHexColor);
// 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') {
// 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];
}
})();
// Using rgbGradations as backgroundColor, determine foregroundColor
// according to difference of brightness between background and foreground.
// Make readable pairs of backgroundColor and foregroundColor.
const rgbGradualColorSchemes = rgbGradations.map(backgroundRgb => {
const readableForegroundRgb = (() => {
const yiqFilter = [0.587, 0.299, 0.114];
const backgroundBrightness =
backgroundRgb.map((x, i) => x * yiqFilter[i]).reduce((p, x) => p + x);
const foregroundRgbs = [defaultForegroundRgb, [0, 0, 0], [255, 255, 255]];
const brightDiffs = foregroundRgbs.map(rgb => {
const letterBrightness = rgb.map((x, i) => x * yiqFilter[i]).reduce((p, x) => p + x);
return Math.abs(backgroundBrightness - letterBrightness);
});
// If brightDiff > 123, foregroundColor is readable on backgroundColor.
const foregroundRgbsIndex = brightDiffs[0] > 123
? 0
: brightDiffs.reduce((iMax, x, i, self) => self[iMax] < x ? i : iMax, 0);
return foregroundRgbs[foregroundRgbsIndex];
})();
return [backgroundRgb, readableForegroundRgb];
});
// Convert int rgb array into hex color str.
const hexGradualColorSchemes = rgbGradualColorSchemes
.map(rgbColorScheme => {
return rgbColorScheme.map(rgb => rgbToHexColor(rgb));
});
return hexGradualColorSchemes;
};
// Main
// Check standard of filterDic
const defaultFilterDataKeys = ['dataId', 'text', 'title', 'mode', 'options', 'reverse'];
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;
}
// Restructure elements of community page.
if (/www\.fanfiction\.net\/community\//.test(window.location.href)) {
const newTab = document.createElement('div');
newTab.id = 'cs';
// Store zListTags to newTabInside
const newTabInside = document.createElement('div');
newTabInside.id = 'cs_inside';
const zListTags = document.getElementsByClassName('z-list');
[...zListTags].forEach(x => {
newTabInside.appendChild(x);
});
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 show number of stories and page information
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 = document.querySelectorAll('div.z-list:not(.filter_placeholder)').length;
badge.appendChild(document.createTextNode('Community Stories: '));
badge.appendChild(badgeSpan);
badge.appendChild(document.createTextNode(' / '));
const pager = document.querySelector('#content_wrapper_inner center');
pager.childNodes.forEach(x => {
badge.appendChild(x.cloneNode(true));
});
scriptTag.parentElement.insertBefore(badge, newTab);
}
for (let tabId of ['st', 'fs', 'cs']) {
// 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 => {
// .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) {
const url = new URL(x.firstElementChild.href);
x.dataset.storyid = url.pathname.split('/')[2];
x.dataset.title = x.firstElementChild.textContent;
x.dataset.category = matches[2];
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[0].dataset.xutime;
x.dataset.dateupdate = xutimes.length === 2 ? xutimes[1].dataset.xutime : x.dataset.datesubmit;
x.dataset.statusid = / - Complete$/.test(rawText) ? 2 : 1;
}
// Set following dataset for makeStoryData.
x.dataset.crossover = Boolean(matches[1]);
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);
}
}
});
// 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 {
const sortByTitle = makeSorterFunctionBy('title');
return sortByTitle(a, b);
}
};
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 (tabId === 'cs') {
const sorterTag = document.createElement('div');
sorterTag.classList.add('fas-sorter-div');
sorterTag.appendChild(fragment);
tab.insertBefore(sorterTag, tab.firstElementChild);
}
// Filter functions
// 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 = zList.dataset.crossover ? 'Crossover' : 'Not Crossover';
storyData.fandom = zList.dataset.category;
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;
};
// Add filters
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].initialMenuClasses = [];
initialSelectDic[filterKey].menuDisabled = false;
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;
initialSelectDic[filterKey].menuDisabled = true;
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 => dividedResults.includes(initialOptionDic[optionValue].storyNumber));
[...optionTags]
.filter(x => x.value === optionValue)
.forEach(x => x.classList.add(menuItemGroupClasses[dividedResultsIndex]));
});
}
// Log initial classList
initialSelectDic[filterKey].initialMenuClasses = [...selectTag.classList];
[...optionTags].forEach(optionTag => {
initialOptionDic[optionTag.value].initialItemClasses = [...optionTag.classList];
});
// Change display of stories by selected filter value.
selectTag.addEventListener('change', (e) => {
filterStories(filterKey, selectTag.value);
});
return selectTag;
};
// Add filters
Object.keys(filterDic).forEach(filterKey => {
const filterTag = makeSelectTag(filterKey, filterDic[filterKey].text);
filterDiv.appendChild(filterTag);
filterDiv.appendChild(document.createTextNode(' '));
});
// Don't display filter when other filter which uses same dataId is disabled.
Object.keys(filterDic)
.forEach(filterKey => {
const filterDisabled = Object.keys(filterDic)
.filter(x => x !== filterKey)
.filter(x => filterDic[x].dataId === filterDic[filterKey].dataId)
.filter(x => initialSelectDic[x].menuDisabled);
if (filterDisabled.length) {
const selectTag = filterDiv.querySelector('#' + tabId + '_' + filterKey + '_select');
selectTag.style.display = 'none';
}
});
// Clear filter settings and revert attributes and class according to initialSelectDic.
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);
// Is there a need to run clear feature?
if (changed) {
Object.keys(selectDic)
.filter(filterKey => selectDic[filterKey].accessible)
.forEach(filterKey => {
// Clear each filter
selectDic[filterKey].dom.value = 'default';
// Revert attributes and class of select tag according to initialSelectDic.
selectDic[filterKey].dom.classList.remove('fas-filter-menu_locked', 'fas-filter-menu_selected');
if (initialSelectDic[filterKey].initialMenuClasses.length > 1) {
selectDic[filterKey].dom.classList.add(initialSelectDic[filterKey].initialMenuClasses);
}
// Revert attributes and class of option tag according to optionDic.
const optionDic = selectDic[filterKey].optionDic;
Object.keys(optionDic).forEach(optionValue => {
optionDic[optionValue].dom.classList.remove(
'fas-filter-menu-item_locked', ...menuItemGroupClasses, 'fas-filter-menu-item_story-zero'
);
optionDic[optionValue].dom.removeAttribute('hidden');
const initialOptionDic = initialSelectDic[filterKey].initialOptionDic;
if (initialOptionDic[optionValue].initialItemClasses.length > 1) {
optionDic[optionValue].dom.classList.add(...initialOptionDic[optionValue].initialItemClasses);
}
});
});
// Change display of stories to initial state.
const storyDic = makeStoryDic();
Object.keys(storyDic).forEach(x => changeStoryDisplay(storyDic[x]));
// Change story number to initial state.
const badge = document.getElementById('l_' + tabId).firstElementChild;
badge.textContent = [...Object.keys(storyDic)].length;
}
});
filterDiv.appendChild(clear);
// Append filters
tab.insertBefore(filterDiv, tab.firstChild);
}
})();