Tumblr Savior

Saves you from ever having to see another post about certain things ever again (idea by bjornstar, rewritten by Vindicar).

// ==UserScript==
// @name              Tumblr Savior
// @namespace         bjornstar
// @description       Saves you from ever having to see another post about certain things ever again (idea by bjornstar, rewritten by Vindicar).
// @version           3.1.4
// @require 	      https://greasyfork.org/scripts/1884-gm-config/code/GM_config.js?version=4836
// @run-at            document-start
// @grant             unsafeWindow
// @grant             GM_getValue
// @grant             GM_setValue
// @grant             GM_registerMenuCommand
// @grant             GM_addStyle
// @include           http://www.tumblr.com/*
// @include           https://www.tumblr.com/*
// ==/UserScript==

(function(){
	'use strict';
	//preparing the config file
	//If we have no access to Greasemonkey methods, we will need dummy replacements
	if (typeof GM_getValue !== 'function') GM_getValue = function (target, deflt) { return deflt; };
	// >>> YOU CAN SPECIFY DEFAULT VALUES BELOW <<<
	var cfg = {
		//posts matching black list will be hidden completely
		blacklist : parseList(GM_getValue('blacklist', '')),
		//posts matching gray list will be hidden under spoiler and can be revealed with a single click
		graylist  : parseList(GM_getValue('graylist', '')),
		//posts matching white list will never be affected by black or gray lists.
		whitelist : parseList(GM_getValue('whitelist', '')),
		//which action to take against sponsored posts
		sponsored_action : GM_getValue('sponsored', '0'),
		//which action to take against recommended posts
		recommended_action : GM_getValue('recommended', '0'),
		//if set to true, black and white lists will affect notifications and post notes as well.
		process_notifications_and_notes : GM_getValue('notes', false),
		//if true, script will search post HTML for triggerwords instead of it's visible text content
		search_html : GM_getValue('inhtml', false),
		// settings below this point are internal and have no GUI
		//if post removal should simply hide it
		soft_removal : true,
		post_selector : 'li.post_container:not(#new_post_buttons)',
		notification_selector : 'li.notification',
		note_selector : '.notes li.note',
		post_body_selector : '.post_body',
		//constants for the sake of code simplicity
		actions : {
			PROCESS		: '0',
			WHITELIST	: '1',
			HIDE		: '2',
			SPOILER		: '3',
			REMOVE		: '4',
		},
	};
	//=======================================================================
	//Main Tumblr Saviour object (maybe it's a god-object antipattern, I don't care)
	//=======================================================================
	var TumblrSaviour = { 
		config : cfg, //configuration object from above
		//helper function that looks up keywords from supplied array in a string and returns array of found keywords
		findKeywords : function (data, list) {
			var result = [];
			for (var i = 0; i < list.length; i++)
				if (data.indexOf(list[i]) >= 0)
					result.push(list[i]);
			return result;
		},
		//helper function that strips specified attributes from the root element and all its descendants
		stripAttrs : function (root, attrs) {
			//make sure we got an array
			if (typeof attrs == 'undefined')
				attrs = [];
			else if (typeof attrs == 'string')
				attrs = [attrs];
			for (var a=0; a<attrs.length; a++) {
				//stripping the node itself
				if (root.hasAttribute(attrs[a]))
					root.removeAttribute(attrs[a]);
				//finding all descendants that have this attribute
				var nodes = root.querySelectorAll('*['+attrs[a]+']');
				//and stripping them all
				for (var i=0; i<nodes.length; i++)
					nodes[i].removeAttribute(attrs[a]);
			}
		},
		//helper function that removes all matching descendants of the root element
		stripNodes : function (root, items) {
			if (typeof items == 'string')
				items = [items];
			for (var i=0; i<items.length; i++) {
				var nodes = root.querySelectorAll(items[i]);
				for (var j=0; j<nodes.length; j++)
					nodes[j].parentNode.removeChild(nodes[j]);
			}
		},
		//converts a post element into string for keyword lookup
		extractPostData : function (post) {
			var data = '';
			var clone = post.cloneNode(true);
			this.stripNodes(clone, ['script', '.post_footer']);
			if (this.config.search_html) {
				//these attributes may have fragments of text from blog description, which can lead to false positives
				this.stripAttrs(clone, ['data-tumblelog-popover', 'data-json']);
				data = clone.innerHTML;
			} else {
				recoursiveWalk(clone, function(el) {
					if (el.nodeType == el.TEXT_NODE)
						data += ' '+el.nodeValue;
				});
			}
			data = data.toLowerCase();
			return data;
		},
		//converts a notification element into string for keyword lookup
		extractNotificationData : function (notification) {
			return this.extractPostData(notification);
		},
		//converts a note element into string for keyword lookup
		extractNoteData : function (note) {
			return this.extractPostData(note);
		},
		//post and notification processing routines - they do actual work of hiding/removing posts
		//returns a previous non-blacklisted post element or null
		getPreviousPost : function (post) {
			var prev = post.previousSibling;
			while ( (prev !== null) && ( (prev.nodeType != 1) || (prev.querySelector('.post:not(.new_post)') === null) ) )
				prev = prev.previousSibling;
			return prev;
		},
		//returns post author name or empty string
		getPostAuthor : function (post) {
			if (post === null) return '';
			var actual_post = post.querySelector('.post');
			if (actual_post !== null)
				return actual_post.getAttribute('data-tumblelog');
			else
				return '';
		},
		//if there are several posts from the same author in a row, all but first will have "same_user_as_last" class applied.
		//back in the day such posts had their author icon hidden
		//currently it seems to be unused, but we will adjust the class nonetheless
		adjustPost : function (post) {
			var actual_post = post.querySelector('.post');
			if (actual_post === null) return; //is it even a post?
			var prev = this.getPreviousPost(post); //look up the previous one
			if (prev === null) {
				//we're dealing with the first visible post on dashboard - just make sure it has no "same user" class applied
				actual_post.className = actual_post.className.replace(/\bsame_user_as_last\b/, '');
			} else {
				//there is a previous post - let's check the authors
				if (this.getPostAuthor(post) == this.getPostAuthor(prev))
					//same author - setting the class
					actual_post.className += ' same_user_as_last'; 
				else
					//different authors - removing the class
					actual_post.className = actual_post.className.replace(/\bsame_user_as_last\b/, '');
			}
		},
		//for those who didn't trigger any list
		ignorePost : function (post, reason) {
			post.setAttribute('data-tumblr-saviour-status', 'unaffected');
			post.setAttribute('data-tumblr-saviour-reason', reason);
			this.adjustPost(post);
		},
		ignoreNotification : function (notification, reason) {
			notification.setAttribute('data-tumblr-saviour-status', 'unaffected');
			notification.setAttribute('data-tumblr-saviour-reason', reason);
		},
		//for those that triggered whitelist
		whiteListPost : function (post, reason) {
			post.setAttribute('data-tumblr-saviour-status', 'whitelisted');
			post.setAttribute('data-tumblr-saviour-reason', reason);
			this.adjustPost(post);
		},
		whiteListNotification : function (notification, reason) {
			notification.setAttribute('data-tumblr-saviour-status', 'whitelisted');
			notification.setAttribute('data-tumblr-saviour-reason', reason);
		},
		whiteListNote : function (note, reason) {
			note.setAttribute('data-tumblr-saviour-status', 'whitelisted');
			note.setAttribute('data-tumblr-saviour-reason', reason);
		},
		//for those that triggered graylist
		hidePostSpoiler : function (post, reason) {
			post.setAttribute('data-tumblr-saviour-status', 'graylisted');
			post.setAttribute('data-tumblr-saviour-reason', reason);			
			var content = post.querySelector('.post_content_inner');
			var contentstyle = content.style.display;
			content.style.display = 'none';
			if (!content) return;
			var placeholder = document.createElement('div');
			placeholder.className = 'tumblr_saviour_placeholder';
			placeholder.innerHTML = '<span>You have been saved from this post because of: '+reason+'. </span>';
			var trigger = document.createElement('span');
			trigger.innerHTML = '[<span class="tumblr_saviour_trigger">Show</span>]';
			placeholder.appendChild(trigger);
			content.parentNode.insertBefore(placeholder, content);
			trigger.addEventListener('click', function(e) {
				e.preventDefault();
				content.style.display = contentstyle;
				placeholder.style.display = 'none';
				placeholder.parentNode.removeChild(placeholder);
			});
			this.adjustPost(post);
		},
		//for those that triggered blacklist
		hidePost : function (post, reason) {
			//soft removal - just hiding the post
			post.setAttribute('data-tumblr-saviour-status', 'blacklisted');
			post.setAttribute('data-tumblr-saviour-reason', reason);			
			post.style.display = 'none';
			//we have to strip it of "post" class to ensure that keyboard navigation won't see it
			var actual_post = post.querySelector('.post');
			if (actual_post !== null)
				actual_post.className = actual_post.className.replace(/\bpost\b/, '');
			//we should tell Tumblr to update it's keyboard navigation, if possible
			checkIfExists('Tumblr.KeyCommands.update_post_positions', function (update_post_positions) {
				try {
					update_post_positions();
				} catch (e) {
					//we ignore any errors that might have happened
				}
			});
		},
		removePost : function (post, reason) {
			post.parentNode.removeChild(post);
			//we should tell Tumblr to update it's keyboard navigation, if possible
			checkIfExists('Tumblr.KeyCommands.update_post_positions', function (update_post_positions) {
				try {
					update_post_positions();
				} catch (e) {
					//we ignore any errors that might have happened
				}
			});
		},
		hideNotification : function (notification, reason) {
			notification.setAttribute('data-tumblr-saviour-status', 'blacklisted');
			notification.setAttribute('data-tumblr-saviour-reason', reason);			
			notification.style.display = 'none';
		},
		removeNotification : function (notification, reason) {
			notification.parentNode.removeChild(notification);
		},
		removeNote : function (note, reason) {
			if (this.config.soft_removal) {
				note.setAttribute('data-tumblr-saviour-status', 'blacklisted');
				note.setAttribute('data-tumblr-saviour-reason', reason);
				note.style.display = 'none';
			} else {
				note.parentNode.removeChild(note);
			}			
		},
		//post and notification analysis routines - in case Tumblr changes something
		isMyPost : function (post) {
			return (post.querySelector('.not_mine') === null);
		},
		isSponsoredPost : function (post) {
			return (post.querySelector('.sponsored_post') !== null);
		},	
		isSponsoredNotification : function (notification) {
			return (notification.querySelector('.sponsor') !== null);
		},
		isRecommendedPost : function (post) {
			return (post.querySelector('.is_recommended') !== null) || (post.querySelector('.recommendation-reason-footer') !== null);
		},
		isRecommendedNotification : function (notification) {
			return checkSelectorMatch(notification,'.takeover-container');
		},
		//main post analysis routine
		analyzePost : function (post) {
			if (this.isMyPost(post)) {
				//user's own posts are always whitelisted
				this.whiteListPost(post, 'my post');
				return;
			}
			//check if it's a sponsored post
			if (this.isSponsoredPost(post))
				switch (this.config.sponsored_action){
					case this.config.actions.WHITELIST: {
						this.whiteListPost(post,'sponsored post');
						return;
					}; break;
					case this.config.actions.HIDE: {
						this.hidePost(post,'sponsored post');
						return;
					}; break;
					case this.config.actions.REMOVE: {
						this.removePost(post,'sponsored post');
						return;
					}; break;
					case this.config.actions.SPOILER: {
						this.hidePostSpoiler(post,'sponsored post');
						return;
					}; break;
					default: break;
				}
			//check if it's a recommended post
			if (this.isRecommendedPost(post))
				switch (this.config.recommended_action){
					case this.config.actions.WHITELIST: {
						this.whiteListPost(post,'recommended post');
						return;
					}; break;
					case this.config.actions.HIDE: {
						this.hidePost(post,'recommended post');
						return;
					}; break;
					case this.config.actions.REMOVE: {
						this.removePost(post,'recommended post');
						return;
					}; break;
					case this.config.actions.SPOILER: {
						this.hidePostSpoiler(post,'recommended post');
						return;
					}; break;
					default: break;
				}
			//white list takes priority
			var data = this.extractPostData(post);
			var keywords;
			keywords = this.findKeywords(data, this.config.whitelist);
			if (keywords.length) {
				this.whiteListPost(post, keywords.join(';'));
				return;
			}
			//black list
			keywords = this.findKeywords(data, this.config.blacklist);
			if (keywords.length) {
				this.hidePost(post, keywords.join(';'));
				return;
			}
			//check the gray list
			keywords = this.findKeywords(data, this.config.graylist);
			if (keywords.length) {
				this.hidePostSpoiler(post, keywords.join(';'));
				return;
			}
			//if nothing triggered, we mark post as such
			this.ignorePost(post, '');
		},
		//main notification analysis routine
		analyzeNotification : function (notification) {
			if (this.config.process_notifications_and_notes) {
				var data = this.extractNotificationData(notification);
				var keywords;
				keywords = this.findKeywords(data, this.config.whitelist);
				if (keywords.length) {
					this.whiteListNotification(notification, keywords.join(';'));
					return;
				}
				keywords = this.findKeywords(data, this.config.blacklist);
				if (keywords.length) {
					this.hideNotification(notification, keywords.join(';'));
					return;
				}
				if (this.isSponsoredNotification(notification))
					switch (this.config.sponsored_action){
						case this.config.actions.WHITELIST: {
							this.whiteListNotification(notification,'sponsored notification');
							return;
						}; break;
						case this.config.actions.SPOILER:
						case this.config.actions.HIDE: {
							this.hideNotification(notification,'sponsored notification');
							return;
						}; break;
						case this.config.actions.REMOVE: {
							this.removeNotification(notification,'sponsored notification');
							return;
						}; break;
						default: break;
					}
				if (this.isRecommendedNotification(notification))
					switch (this.config.recommended_action){
						case this.config.actions.WHITELIST: {
							this.whiteListNotification(notification,'recommended notification');
							return;
						}; break;
						case this.config.actions.SPOILER:
						case this.config.actions.HIDE: {
							this.hideNotification(notification,'recommended notification');
							return;
						}; break;
						case this.config.actions.REMOVE: {
							this.removeNotification(notification,'recommended notification');
							return;
						}; break;
						default: break;
					}
			}
			this.ignoreNotification(notification,'');
		},
		//main note analysis routine
		analyzeNote : function (note) {
			if (this.config.process_notifications_and_notes) {
				var data = this.extractNoteData(note);
				var keywords;
				keywords = this.findKeywords(data, this.config.whitelist);
				if (keywords.length) {
					this.whiteListNote(note, keywords.join(';'));
					return;
				}
				keywords = this.findKeywords(data, this.config.blacklist);
				if (keywords.length) {
					this.removeNote(note, keywords.join(';'));
					return;
				}
			}
		},
	};
	//=======================================================================
	//Function definitions (don't worry, JS will lift them to the beginning of the block)
	//=======================================================================
	//iterate through the node's descendants
	function recoursiveWalk(element, fn) {
		if (!fn(element) && (element.nodeType == element.ELEMENT_NODE))
			for (var i=0; i<element.childNodes.length; i++)
				recoursiveWalk(element.childNodes[i], fn);
	}
	//parsing semicolon-separated lists into sorted arrays
	function parseList(list) {
		var lst = list.split(';');
		var res = [];
		for (var i=lst.length-1;i>=0;i--) {
			if (lst[i].trim().length>0)
				res.push(lst[i].toLowerCase());
		}
		res.sort();
		return res;
	}
	//helper function that checks if specified object hierarchy exists in the page scope and returns boolean flag/runs a callback if it does.
	function checkIfExists(objects, callback) {
		if (typeof objects === 'string')
			objects = objects.split('.');
		var obj = unsafeWindow;
		for (var index = 0; index<objects.length; index++) {
			if (typeof obj[objects[index]] === 'undefined') 
				return false;
			else
				obj = obj[objects[index]];
		}
		if (typeof callback !== 'undefined')
			callback(obj);
		return true;
	}
	//helper function to determine if specified node matches specified selector
	function checkSelectorMatch(node, selector) {
		if (typeof node[checkSelectorMatch.method] == 'function') //not all nodes have required methods
			return node[checkSelectorMatch.method](selector);
		else //in that case, we simply assume it doesn't match
			return false;
	}
	//determining matching method supported by the browser
	checkSelectorMatch.method = (function(){ 
		var methods = ['matches', 'matchesSelector', 'mozMatchesSelector', 'webkitMatchesSelector'];
		for (var i=0; i<methods.length; i++)
			if (typeof Element.prototype[methods[i]] == 'function')
				return methods[i]; //match found, remember it for future use
		throw "No way to match selector found."; //no match - we have to fail miserably.
	})();
	
	//waits for a node specified by selector to appear/disappear
	function waitForSelector(selector, must_exist, callback, root) { 
		if (typeof root == 'undefined') //we search the whole document unless told otherwise
			root = document;
		//we check if the node has been added/removed already
		var prequery = root.querySelector(selector);
		if ( (prequery !== null) == must_exist ) {
			callback(prequery);
			return;
		}
		//it hasn't - we set up MutationObserver on root element to find it
		var mutation_callback = function(mutations) {
			//checking the list of mutations
			for (var i=0; i<mutations.length; i++) {
				//make sure the event is of correct type
				if (mutations[i].type == 'childList')
					if (must_exist) { //we're waiting for the node to appear, so we look for added nodes that match our selector
						for (var j=0; j<mutations[i].addedNodes.length; j++)
							if (checkSelectorMatch(mutations[i].addedNodes[j], selector)) 
								try {
									callback(mutations[i].addedNodes[j]);
								} 
								finally {
									mutation_callback.docobserver.disconnect();
									delete mutation_callback.docobserver;
									return;
								}
					} else { //we're waiting for the node to disappear, so we look for removed nodes that match our selector
						for (var j=0; j<mutations[i].removedNodes.length; j++)
							if (checkSelectorMatch(mutations[i].removedNodes[j], selector)) 
								try {
									callback(mutations[i].removedNodes[j]);
								} 
								finally {
									mutation_callback.docobserver.disconnect();
									delete mutation_callback.docobserver;
									return;
								}
					}
			}
		};
		mutation_callback.docobserver = new MutationObserver(mutation_callback);
		mutation_callback.docobserver.observe(root, { 
			attributes: false, 
			childList: true, 
			characterData: false, 
			subtree: true,
		});
	}

	//=======================================================================
	//Main script
	//=======================================================================
	//we prepare the DOM observers
	//observer for any new posts coming up
	var new_post_observer = new MutationObserver(function(mutations){
		for (var i=0; i<mutations.length; i++) { //looking through mutations list
			if (mutations[i].type == 'childList') 
				for (var j = 0; j<mutations[i].addedNodes.length; j++) { //only checking additions
					//is it a post?
					if (checkSelectorMatch(mutations[i].addedNodes[j], TumblrSaviour.config.post_selector)) {
						TumblrSaviour.analyzePost.call(TumblrSaviour, mutations[i].addedNodes[j]);
						post_update_observer.observe(mutations[i].addedNodes[j], post_update_observer_config);
					}
					//is it a notification?
					else if (checkSelectorMatch(mutations[i].addedNodes[j], TumblrSaviour.config.notification_selector))
						TumblrSaviour.analyzeNotification.call(TumblrSaviour, mutations[i].addedNodes[j]);
				}
		}
	});
	//configuration: interested only in immediates descendants being added/removed 
	var new_post_observer_config = {
		attributes: false, 
		childList: true, 
		characterData: false, 
		subtree: false,
	};
	//some post don't have post body initially - we have to schedule a check later.
	var post_update_observer = new MutationObserver(function(mutations){
		for (var i=0; i<mutations.length; i++) //looking through mutations list
			for (var j=0; j<mutations[i].addedNodes.length; j++)
				if (checkSelectorMatch(mutations[i].addedNodes[j], TumblrSaviour.config.post_body_selector)) {
					//looking for post node containing this body
					var node = mutations[i].addedNodes[j];
					while ((node !== null) && !checkSelectorMatch(node, TumblrSaviour.config.post_selector))
						node = node.parentNode;
					if (node !== null) //post node found
						TumblrSaviour.analyzePost.call(TumblrSaviour, node);
				}
	});
	//configuration: interested in any changes to DOM tree
	var post_update_observer_config = {
		attributes: false,
		childList: true, 
		characterData: false, 
		subtree: true,
	};
	//we wait for #posts to appear in the DOM tree
	waitForSelector('#posts', true, function(posts){
		//we immediately set an observer on it, so we can catch not-yet-loaded posts, as well as ones added dynamically by the paginator
		new_post_observer.observe(posts, new_post_observer_config);		
		//then we check for items already loaded
		var notifylist = posts.querySelectorAll(TumblrSaviour.config.notification_selector);
		for (var i=0; i<notifylist.length; i++)
			TumblrSaviour.analyzeNotification.call(TumblrSaviour, notifylist[i]);
		var postlist = posts.querySelectorAll(TumblrSaviour.config.post_selector);
		for (var i=0; i<postlist.length; i++)
			//some posts don't initially have a body
			if (postlist[i].querySelector(TumblrSaviour.config.post_body_selector) !== null)
				//if they do, we check them immediately
				TumblrSaviour.analyzePost.call(TumblrSaviour, postlist[i]);
			else
				//if they don't, we observe them so they will get checked once it appears
				post_update_observer.observe(postlist[i], post_update_observer_config);
	});
	//if we want to filter post notes, we will have to get our hands dirty
	if (TumblrSaviour.config.process_notifications_and_notes) {
		//once document is loaded and Tumblr scripts have been set up, we set a hook to catch the moment post notes are being loaded.
		window.addEventListener("load", function() {
			//we remember old function that handles notes loading 
			var old_load_notes = unsafeWindow.Tumblr.Notes.prototype.load_notes;
			//and replace it with ours
			unsafeWindow.Tumblr.Notes.prototype.load_notes = exportFunction(function($post,options,fn){
				//the idea is to allow Tumblr engine to load notes...
				old_load_notes.call(this, $post, options, exportFunction(function(data){
					//...and render those notes...
					var res = fn(data);
					//...but also to filter them immediately afterwards
					var notes = $post[0].querySelectorAll(TumblrSaviour.config.note_selector);
					for (var i=0; i<notes.length; i++)
						TumblrSaviour.analyzeNote.call(TumblrSaviour, notes[i]);
					return res;
				}, unsafeWindow));
			}, unsafeWindow);
		});
	}
	//we set up the configuration panel if possible
	if ( (typeof GM_config !== 'undefined') && (typeof GM_setValue === 'function') && (typeof GM_registerMenuCommand === 'function') ) {
		var fields = {
			"blacklist" : {
				"label" : "Blacklisted words",
				"title" : "Semicolon-separated list of words that will cause the post to disappear.",
				"type" : "text",
				"default" : GM_getValue('blacklist', ''),
			},
			"graylist" : {
				"label" : "Graylisted words",
				"title" : "Semicolon-separated list of words that will cause the post content to be hidden under spoiler.",
				"type" : "text",
				"default" : GM_getValue('graylist', ''),
			},
			"whitelist" : {
				"label" : "Whitelisted words",
				"title" : "Semicolon-separated list of words that will prevent post from being hidden for any reason. Your own posts are always whitelisted.",
				"type" : "text",
				"default" : GM_getValue('whitelist', ''),
			},
			"sponsored" : {
				"label" : "Action for sponsored posts",
				"title" : "If set to anything but 'process like any other post', this setting overrides the effect of lists above.",
				"type" : "select",
				"options" : {
					"0" : "process like any other post",
					"1" : "whitelist post",
					"2" : "blacklist post",
					"3" : "hide post under spoiler",
					"4" : "remove from the page",
				},
				"default" : GM_getValue('sponsored', '0'),
			},
			"recommended" : {
				"label" : "Action for recommended posts",
				"title" : "If set to anything but 'process like any other post', this setting overrides the effect of lists above.",
				"type" : "select",
				"options" : {
					"0" : "process like any other post",
					"1" : "whitelist post",
					"2" : "blacklist post",
					"3" : "hide post under spoiler",
					"4" : "remove from the page",
				},
				"default" : GM_getValue('recommended', '0'),
			},
			"notes" : {
				"label" : "Process notifications and notes as well",
				"type" : "checkbox",
				"default" : !!GM_getValue('notes', 0),
			},
			"inhtml" : {
				"label" : "Check HTML code of the post instead of its text",
				"type" : "checkbox",
				"default" : !!GM_getValue('inhtml', 0),
			},
			save: function() {
				GM_config.values['blacklist'] = parseList(GM_config.values['blacklist']).join(";");
				GM_config.values['graylist'] = parseList(GM_config.values['graylist']).join(";");
				GM_config.values['whitelist'] = parseList(GM_config.values['whitelist']).join(";");
				for (var key in GM_config.values)
					GM_setValue(key,GM_config.values[key]);
			},
		};
		var CSS = [
			'.section_header,.reset_holder { display: none !important; }',
			'body {background-color: #FFF;}',
			'* {font-family: "Helvetica Neue","HelveticaNeue",Helvetica,Arial,sans-serif; color: #444;}',
			'#header {border-bottom: 2px solid #E5E5E5; font-size: 24px; font-weight: normal; line-height: 1; margin: 0px; padding-bottom: 28px;}',
			'.config_var {padding: 2px 0px 2px 200px;}',
			'.config_var>* {vertical-align:middle;}',
			'.config_var .field_label {font-size: 14px !important;line-height: 1.2; display:inline-block; width:200px; margin: 0 0 0 -200px;}',
			'#field_blacklist,#field_graylist,#field_whitelist {width: 100%}',
			'button {padding: 4px 7px 5px; font-weight: 700; border-width: 1px; border-style: solid; text-decoration: none; border-radius: 2px; cursor: pointer; display: inline-block; height: 30px; line-height: 20px;}',
			'#saveBtn {color: #FFF; border-color: #529ECC; background: #529ECC none repeat scroll 0% 0%;}',
			'#cancelBtn {color: #FFF; border-color: #9DA6AF; background: #9DA6AF none repeat scroll 0% 0%;}',
			""].join("\n");
		GM_addStyle([
			'#GM_config {border-radius: 3px !important; border: 0px none !important;}',
			'.tumblr_saviour_placeholder { display: block; padding: 20px;}',
			'.tumblr_saviour_trigger { cursor: pointer !important; text-decoration: underline !important; }',
		""].join("\n"));
		GM_config.init("Tumblr Saviour Settings", fields, CSS);
		GM_registerMenuCommand("Tumblr Saviour Settings", function() {GM_config.open();});
	}

})();