- // ==UserScript==
- // @name Fanfiction.net Unwanted Result Filter
- // @namespace http://www.ficfan.org/
- // @description Make up for how limited Fanfiction.net's result filtering is
- // compared to sites like Twisting the Hellmouth.
- // @copyright 2014-2015, Stephan Sokolow (http://www.ssokolow.com/)
- // @license MIT; http://www.opensource.org/licenses/mit-license.php
- // @version 0.1.2
- //
- // @require http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.11.2.min.js
- // @require https://openuserjs.org/src/libs/sizzle/GM_config.js
- //
- // @grant GM_registerMenuCommand
- // @grant GM_getValue
- // @grant GM_setValue
- // @grant GM_log
- // @noframes
- //
- // @compatible firefox Tested regularly under Greasemonkey.
- // @compatible chrome Tested occasionally under Tampermonkey.
- //
- // @match *://www.fanfiction.net/*
- // ==/UserScript==
- (function($) {
-
- /* NOTE TO USERS OF VERSION 0.0.1:
- * Sorry for erasing any modifications you made to the filter settings.
- *
- * I didn't put much thought into version 0.0.1 and, as a result, it was
- * impossible to release an update without erasing modifications.
- *
- * I'm now storing settings via GM_setValue, so it should never happen again
- * and there's a proper configuration GUI available at
- * Greasemonkey > User Script Commands... > Configure Result Filter...
- */
-
- // ----==== Configuration ====----
-
- var fieldDefs = {
- 'filter_slash': {
- 'section': ['Slash Filter',
- 'Hide stories with slash based on warnings in story descriptions'],
- 'label': 'Enabled',
- 'labelPos': 'right',
- 'type': 'checkbox',
- 'default': true,
- },
- 'hide_slash': {
- 'label': 'No Placeholder',
- 'labelPos': 'right',
- 'type': 'checkbox',
- 'default': false,
- },
- 'slash_pat': {
- 'label': 'Slash is...',
- 'title': 'A regular expression which matches slash in descriptions',
- 'type': 'text',
- 'size': 255,
- 'default': ".*(slash|yaoi)([.,!: ]|$)"
- },
- 'not_slash_pat': {
- 'label': '...but not...',
- 'title': 'A regular expression which matches "not slash" and so on',
- 'type': 'text',
- 'size': 255,
- 'default': ".*(fem|not?[ ]+)(slash|yaoi)([.,!: ]|$)"
- },
- 'filter_cats': {
- 'section': ['Unwanted Category Filter',
- 'Hide unwanted fandoms in author pages and "All Crossovers" searches'],
- 'label': 'Enabled',
- 'labelPos': 'right',
- 'type': 'checkbox',
- 'default': true,
- },
- 'hide_cats': {
- 'label': 'No Placeholder',
- 'labelPos': 'right',
- 'type': 'checkbox',
- 'default': false,
- },
- 'unwanted_cats': {
- 'label': 'Unwanted categories (One per line, blanks lines and lines ' +
- 'beginning with # will be ignored):',
- 'type': 'textarea',
- 'size': 100,
- 'default': [
- "# --== Never wanted ==-- ",
- "Invader Zim",
- "Supernatural",
- "Twilight",
- "",
- "# --== Not right now ==-- ",
- "Harry Potter & Avengers",
- "Naruto",
- ].join("\n"),
- },
- 'unwanted_cats_escape': {
- 'label': 'Lines are literal strings (uncheck for regular expressions)',
- 'labelPos': 'right',
- 'title': 'NOTE: Leading/trailing whitespace is always ignored and ' +
- 'newlines always have OR behaviour.',
- 'type': 'checkbox',
- 'default': true,
- },
- 'unwanted_cats_commute': {
- 'label': 'Automatically generate "B & A" lines from "A & B" lines',
- 'labelPos': 'right',
- 'title': "WARNING: This will break regexes with & inside () or []",
- 'type': 'checkbox',
- 'default': true,
- }
- // TODO: Ideas for future filters:
- // - Genre (allowing more than one whitelist/blacklist entry)
- // TODO: Ideas for filters requiring an in-page UI:
- // - sort orders not already offered (eg. faves/follows on authors faves)
- // - min/max words as a freeform range entry
- // - already read (ideally, support hooking in an external API w/i caching)
- // - filter sets which can be toggled
- };
-
- var frame = $('<div>').appendTo('body')[0];
- var config_params = {
- 'id': 'ffnet_result_filter',
- 'title': 'Fanfiction.net Unwanted Result Filter',
- 'fields': fieldDefs,
- 'css': ('#ffnet_result_filter ' + [
- // Match Fanfiction.net styling more closely
- ".section_header { background-color: #339 !important; }",
- ".section_desc { background-color: #f6f7ee !important; border: none; " +
- " padding: 1px; }",
- ".config_header { font-size: 16pt; }",
- ".field_label { font-size: 13px; font-weight: normal; }",
- // Layout adjustments for using a non-iframe container
- "#ffnet_result_filter_wrapper { flex: 1 1 auto; display: flex; " +
- " flex-direction: column; }\n" +
- ".force_display_flex { display: flex !important; }",
- "label { display: inline; }",
- ".section_header_holder { padding: 15px; margin-bottom: 2em; }",
- ".modal-footer { margin-top: -2em; }",
- ".saveclose_buttons.btn { margin: 0 0 0 5px !important; }",
- ".reset_holder { padding-right: 12px; }",
- "input[type=checkbox] { margin: 2px 4px 2px; }",
- // Form layout fixes
- "input[type=text], textarea " +
- " { width: calc(100% - 1.1em); resize: vertical; }\n" +
- "#ffnet_result_filter_filter_slash_var, " +
- "#ffnet_result_filter_filter_cats_var, " +
- "#ffnet_result_filter_hide_slash_var, " +
- "#ffnet_result_filter_hide_cats_var " +
- " { display: inline-block; margin-right: 1em !important; } " +
- "#ffnet_result_filter_field_unwanted_cats { min-height: 10em; }"
- ].join('\n#ffnet_result_filter ')),
- 'events': {
- 'open': function(doc) {
- // Reconcile GM_config and Bootstrap CSS
- $(this.frame).css({
- 'z-index': 1050,
- 'top': '50px',
- 'height': 'calc(99% - 100px)',
- 'flex-direction': 'column',
- 'border-color': '#d4d4d4',
- }).addClass('modal fade in force_display_flex');
- var header = $('.config_header').addClass('modal-header');
- $('<div>', {id: 'ffnrfilter_contentbox'})
- .insertAfter(header)
- .css('flex', '1')
- .append($('.section_header_holder').detach());
- $('#ffnet_result_filter_buttons_holder').addClass('modal-footer');
- $('button', this.frame).addClass('btn');
- $('.reset_holder').addClass('btn pull-left');
- $("<div>", {'id': 'gm_modal_back'}).click(function() {
- GM_config.close();
- }).addClass('modal-backdrop fade in').appendTo('body');
- },
- 'close': function() {
- $(this.frame).removeClass('force_display_flex');
- $('#gm_modal_back').remove();
- }
- },
- 'frame': frame,
- };
-
- // ----==== Functions ====----
-
- /// Escape string for literal meaning inside a regular expression
- /// Source: http://stackoverflow.com/a/3561711/435253
- var re_escape = function(s) {
- return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
- };
-
- /// Use with Array.filter() to remove comments and blank lines
- var rows_filter = function(elem, idx, arr) {
- elem = elem.trim()
- if (elem.charAt(0) == '#' || !elem) {
- return false;
- }
- return true;
- };
-
- /// Parse an array from a newline-delimited string containing # comments
- var parse_lines = function(s) {
- return s.split("\n").map(function(e, i, a) {
- return e.trim();
- }).filter(rows_filter);
- };
-
- /// Parse a usable list of category patterns from a raw string
- var parse_cats_list = function(s, escape, commute) {
- // Parse the config
- var cats_raw = parse_lines(s);
- if (escape) { cats_raw = cats_raw.map(re_escape) }
- if (commute) {
- var cats_out = [];
- for (var i = 0, len = cats_raw.length; i < len; i++) {
- var line = cats_raw[i];
- cats_out.push(line);
- var parts = line.split(' & ');
- if (parts.length > 1) {
- cats_out.push(parts[1] + ' & ' + parts[0]);
- }
- }
- }
- return cats_out;
- }
-
- /// Hide a story entry in a way it can be recovered
- var hide_entry = function(node) {
- $(node).addClass('filtered').hide();
- };
-
- /// Hide a story entry and add a clickable placeholder
- var add_placeholder = function(node, reason) {
- var $story = $(node);
- var $placeholder = $story.clone();
-
- $placeholder.html("Click to Show (" + reason + ")").css({
- minHeight: 0,
- maxHeight: '1em',
- color: 'lightgray',
- textAlign: 'center',
- cursor: 'pointer',
- }).click($story, function(e) {
- $(this).slideUp();
- e.data.css('min-height', 0).slideDown();
- }).addClass('filter_placeholder').insertBefore($story);
- hide_entry($story);
- };
-
- /// Code which must be re-run to reapply filters after changing the config
- var initialize_filters_and_apply = function() {
- // Parse the config
- var bad_cats = parse_cats_list(
- GM_config.get('unwanted_cats'),
- GM_config.get('unwanted_cats_escape'),
- GM_config.get('unwanted_cats_commute')
- );
-
- // Generate RegExp objects from the parsed config
- var slash_re = new RegExp(GM_config.get('slash_pat'), 'i');
- var not_slash_re = new RegExp(GM_config.get('not_slash_pat'), 'i');
- var cats_re = new RegExp(".*(" + bad_cats.join('|') + ').*Rated:.*');
-
-
- // Clean up after any previous run
- $(".filter_placeholder").remove();
- $(".filtered").show();
-
- var results = $(".z-list");
- for (var i = 0, reslen = results.length; i < reslen; i++) {
- var story = results[i];
- var meta_row = $('.xgray', story).text();
- var description = $('.z-padtop', story).contents()[0].data;
-
- // TODO: Redesign to collapse runs of hidden entries
- var reason = null;
- if (GM_config.get('filter_slash') && slash_re.test(description)
- && !not_slash_re.test(description)) {
- if (GM_config.get('hide_slash')) {
- hide_entry(story);
- } else {
- add_placeholder(story, "Slash");
- }
- } else if (GM_config.get('filter_cats')) {
- var matches = meta_row.match(cats_re);
- if (matches && matches.length > 0) {
- if (GM_config.get('hide_cats')) {
- hide_entry(story);
- } else {
- add_placeholder(story, matches[1]);
- }
- }
- }
- }
- };
-
- // ----==== Begin Main Program ====----
-
- // Stuff which either must or need only be called once
- GM_config.init(config_params);
- GM_config.onSave = initialize_filters_and_apply;
- GM_registerMenuCommand("Configure Result Filter...",
- function() { GM_config.open(); }, 'C');
-
- // Clear out ad box which misaligns "Hidden" message if it's first result
- $($('#content_wrapper_inner ins.adsbygoogle').parent()[0]).remove();
-
- initialize_filters_and_apply();
-
- }).call(this, jQuery);