// ==UserScript== // @name Fanfiction.net: Filter and Sorter // @namespace https://greasyfork.org/en/users/163551-vannius // @version 1.01 // @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 // @downloadURL none // ==/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); const pager = document.querySelector('#content_wrapper_inner center'); if (pager) { badge.appendChild(document.createTextNode(' / ')); 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); } })();