Fanfiction.net Unwanted Result Filter

Make up for how limited Fanfiction.net's result filtering is

// ==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, 2018, Stephan Sokolow (http://www.ssokolow.com/)
// @license     MIT; http://www.opensource.org/licenses/mit-license.php
// @version     0.1.11
//
// @match       *://www.fanfiction.net/*
// @noframes
//
// @require      https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js
// @require     https://openuserjs.org/src/libs/sizzle/GM_config.js
//
// @grant       GM_registerMenuCommand
// @grant       GM.registerMenuCommand
//
// @grant       GM_getValue
// @grant       GM_setValue
// NOTE: GM_config doesn't currently support GM4 APIs, so allowing the GM4
//       versions of these isn't helpful and could result in users losing 
//       access to their settings if GM_config suddenly starts supporting
//       them without transparently migrating data from its localStorage
//       fallback.
// ==/UserScript==

// ----==== Configuration ====----

var IGNORED_OPACITY = 0.3;

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, blank 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,
  },
  'filter_manual': {
    'section': ['Manual Filter',
      'Hide an arbitrary list of story IDs'],
      'label': 'Enabled',
      'labelPos': 'right',
      'type': 'checkbox',
      'default': true,
  },
  'hide_manual': {
      'label': 'No Placeholder',
      'labelPos': 'right',
      'type': 'checkbox',
      'default': false,
  },
  'manual_reason': {
    'label': 'Reason to display in placeholders:',
    'type': 'text',
    'size': 255,
    'default': "Already Read"
  },
  'unwanted_manual': {
    'label': 'Unwanted story IDs (One per line, blank lines and lines ' +
      'beginning with # will be ignored):',
      'type': 'textarea',
      'size': 100,
      'default': "",
  },
  // 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
  // - filter sets which can be toggled
};

let frame = document.createElement('div')
document.body.append(frame);

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
    "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; }",
    // Make the panel sanely scrollable
    "#ffnet_result_filter_wrapper { " +
      " display: flex; flex-direction: column; height: 100%; }\n" +
    "#ffnrfilter_contentbox { " +
      " flex: 1 1 auto; min-height: 0px; overflow-y: scroll; }\n" +
    // Form layout fixes
    "input[type=text], textarea " +
    "  { width: calc(100% - 1.1em); resize: vertical; }\n" +
    "#ffnet_result_filter_filter_manual_var, " +
    "#ffnet_result_filter_filter_slash_var, " +
    "#ffnet_result_filter_filter_cats_var, " +
    "#ffnet_result_filter_hide_manual_var, " +
    "#ffnet_result_filter_hide_slash_var, " +
    "#ffnet_result_filter_hide_cats_var, " +
    "#ffnet_result_filter_slash_pat_var, " +
    "#ffnet_result_filter_not_slash_pat_var " +
    "    { display: inline-block; margin-right: 1em !important; } " +
    "#ffnet_result_filter_field_slash_pat, " +
    "#ffnet_result_filter_field_not_slash_pat " +
    "    { max-width: 20em; } " +
    "#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.style.zIndex = 1050;
      this.frame.style.top = "50px";
      this.frame.style.height = "calc(99% - 100px)";
      this.frame.style.borderColor = "#d4d4d4";
      this.frame.classList.add('modal', 'fade', 'in');

      let header = document.querySelector('.config_header');
      header.classList.add('modal-header');
      
      this.frame.querySelectorAll('button').forEach(
        function(node) { node.classList.add('btn'); });
      this.frame.querySelectorAll('.reset_holder').forEach(
        function(node) { node.classList.add('btn', 'pull-left'); });
      
      console.log(document.getElementById('ffnet_result_filter_buttons_holder'));       
      document.getElementById('ffnet_result_filter_buttons_holder')
        .classList.add('modal-footer');
      document.getElementById('ffnet_result_filter_slash_pat_var').before(
        document.createElement("br"));

      // Move the content into a wrapper DIV for styling
      let contentbox = document.createElement('div');
      contentbox.id = "ffnrfilter_contentbox";
      header.after(contentbox);

      this.frame.querySelectorAll('.section_header_holder').forEach(function(node) {
        node.remove();
        contentbox.append(node);
      });
      
      // Add a clickable backdrop for the panel
      let modal_back = document.createElement('div');
      modal_back.id = "gm_modal_back";
      modal_back.addEventListener("click", function() { GM_config.close(); });
      modal_back.classList.add('modal-backdrop', 'fade', 'in')
      document.body.append(modal_back);
    },
    'close': function() {
      // Plumb the added backdrop into the close handler
      document.getElementById("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;
};

// Parse a usable list of story IDs from a raw string
var parse_id_list = function(s) {
  return parse_lines(s).filter(rows_filter);
};

/// Hide a story entry in a way it can be recovered
var hide_entry = function(node) {
  node.classList.add('filtered')
  node.style.display = "none";
};

/// Hide a story entry and add a clickable placeholder
var add_placeholder = function(node, reason) {
  let placeholder = node.cloneNode();

  placeholder.textContent = "Click to Show (" + reason + ")";
  placeholder.style.minHeight = 0;
  placeholder.style.maxHeight = "1em";
  placeholder.style.color = "lightgray";
              placeholder.style.textAlign = "center";
  placeholder.style.cursor = "pointer";
  placeholder.classList.add("filter_placeholder");
  
  placeholder.addEventListener("click", (e) => {
    e.stopPropagation();
    e.target.remove();
    node.style.display = "block";
  });
  
  node.before(placeholder);
  hide_entry(node);
};

/// 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')
  );

  if (GM_config.get('filter_manual')) {
    var manual_story_ids = parse_id_list(GM_config.get('unwanted_manual'));
  } else {
    var manual_story_ids = [];
  }
  var story_link_re = new RegExp("\/s\/(\\d+)\/");

  // 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:.*');
  var cat_link_re = new RegExp(bad_cats.join('|'));

  // Clean up after any previous run
  document.querySelectorAll(".filter_placeholder").forEach(function(node) { node.remove() });
  document.querySelectorAll(".filtered").forEach(function(node) { node.style.display = "block"; });
  document.querySelectorAll(".ignored").forEach(function(node) {
    node.style.opacity = 1;
    node.style.display = "block";
    node.classList.remove('ignored');
  });

  document.querySelectorAll(".z-list").forEach(function(story) {
    let meta_row = story.querySelector('.xgray').textContent;
    let description = story.querySelector('.z-padtop').childNodes[0].data;

    // TODO: Redesign to collapse runs of hidden entries
    // TODO: Show a comma-separated list of reasons something was hidden
    var reason = null;
    if (manual_story_ids.length > 0) {
      var story_url = story.querySelector('a.stitle').getAttribute('href');
      var story_id = story_link_re.exec(story_url)[1];
      if (story_id && manual_story_ids.indexOf(story_id) != -1) {
        if (GM_config.get('hide_manual')) {
          hide_entry(story);
        } else {
          add_placeholder(story, GM_config.get('manual_reason'));
        }
        return;
      }
    }

    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");
      }
      return;
    }

    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]);
        }
        return;
      }
    }
  });

  if (GM_config.get('filter_cats')) {
    let hide_cats = GM_config.get('hide_cats');
    document.querySelectorAll('#list_output a').forEach(function(node) {
      // "Browse" wraps the title and entry count in a <div> that lets us
      // easily fade both while "Community" puts them directly in the <td>
      // which defines the column.
      let parent = node.closest('div');
      if (parent) { node = parent; }

      if (cat_link_re.test(node.textContent)) {
        node.classList.add('ignored');
        if (hide_cats) {
          node.style.display = "none";
        } else {
          node.style.opacity = IGNORED_OPACITY;
        }
      }
    });
  }
};

// ----==== 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;

// Only clutter up the context menu (in GM4) on relevant pages
if (document.querySelector('#list_output a, .z-list')) {
	GM.registerMenuCommand("Configure Result Filter...",
  	                     function() { GM_config.open(); }, 'C');
}

// Clear out ad box which misaligns "Hidden" message if it's first result
try {
      document.querySelector('#content_wrapper_inner ins.adsbygoogle')
              .parentElement.remove();
} catch(_) {}

initialize_filters_and_apply();