// ==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.1
//
// @require http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.11.2.min.js
// @require https://greasyfork.org/scripts/2855-gm-config/code/GM_config.js?version=33973
//
// @grant GM_registerMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_log
// @noframes
//
// @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):',
'title': 'One entry per line',
'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,
}
};
var config_params = {
'id': 'ffnet_result_filter',
'title': 'Fanfiction.net Unwanted Result Filter',
'fields': fieldDefs,
'css': (
// Match Fanfiction.net styling
".section_header { background-color: #339 !important; }\n" +
".section_desc { background-color: #f6f7ee !important; }\n" +
// Form layout fixes
"input[type=text], textarea { width: 99%; resize: vertical; }\n" +
"input[type=checkbox] { vertical-align: middle; }\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; }"),
'events': {
'open': function(doc) {
$(this.frame).css('border-color', '#d4d4d4');
$("<div>", {'id': 'gm_modal_back'}).css({
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
background: 'black',
zIndex: 998,
opacity: 0.15,
}).click(function() {
GM_config.close();
}).appendTo('body');
},
'close': function() {
$('#gm_modal_back').remove();
}
}
};
// ----==== 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.onChange = 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);