// ==UserScript==
// @name			Newslight
// @namespace		http://w-shadow.com/
// @description		Highlights new items on webpages
// @copyright 		2009+, Janis Elsts (http://w-shadow.com/)
// @version			1.0.0
// @homepage		http://w-shadow.com/blog/2009/10/12/newslight-greasemonkey-script/
//
// @include			http://*
// @include			https://*
//
// @require			http://w-shadow.com/files/newslight/json.js
// @require			http://w-shadow.com/files/newslight/persist.js
// @require			http://code.jquery.com/jquery-latest.min.js
// ==/UserScript==

//A set of strings 
function StringLookupSet( serializedData ){
	if ( serializedData ){
		this.unserialize(serializedData);	
	} else {
		this.items = {};
	}
}

//Add a string to the set
StringLookupSet.prototype.add = function(str){
	this.items[str] = 1;
}

//Check if the set contains a given string
StringLookupSet.prototype.contains = function(str){
	return (str in this.items);
}

//Get the set items as a single string (useful for storage)   
StringLookupSet.prototype.serialize = function(){
	return escape(JSON.stringify(this.items));
}

//The reverse of serialize()
StringLookupSet.prototype.unserialize = function(serializedData){
	this.items = JSON.parse(unescape(serializedData));
	if ( !this.items || (typeof this.items != 'object') ){
		this.items = {};
	}
}


//The magical news highlighter
var Newslight = {
	
	highlight_class : 'ws-new-node',
	highlight_color : '#fff87b',
	
	autosave_state : false, //Automatically set a reference point on each pageload
	auto_highlight : true,	//Automatically highglight new items without having to use the context menu
	
	notificationsOn : true,	//Show pop-up notifications when something happens
	notificationClass : 'ws-notification-box',
	notificationColor: 'yellow',
	
	minTextLength : 1,		//Highlight only text fragments that are at least this long
	
	oldLookupTable: null, //A lookup table containing all the string fragments and images detected in the 
						  //old version of the current page.
	
	highlightOn: false, 
	
    //Build a lookup table listing all the text nodes and images found among rootNode's children.
	makeLookupTable: function( rootNode ){
    	
		var table = new StringLookupSet;
		
		//Fetch all text and image nodes (except those that are inside script/style etc tags, the user can't see those anyway)
		var nodes = document.evaluate(
			'(//body//*[ (name() != "SCRIPT") and (name() != "STYLE") and (name() != "LINK") ]/text()) | //img', 
			document, //XXX Should be rootNode, but that appears to have no effect in my FF version
			null, 
			XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, 
			null
		);
		
		//Iterate over the found nodes
		for (var i=0; i < nodes.snapshotLength; i++ ){ 
			var node = nodes.snapshotItem(i);
			
			if ( node.nodeType == 3 ){ //text node
				
				//Save text node data into the lookup table
				var text = Newslight.trim(node.data);
				if ( text.length > 0 ){ //Only consider nodes that aren't pure whitespace
					table.add( 'text:' + text );
				}
				
			} else if ( (node.nodeName == 'img') || (node.nodeName == 'IMG') ){ //image node
				
				//Save image data as well. Images are identified by their URL.
				table.add( 'img:' + node.getAttribute('src') );
			}
		}
		
		return table;			
	},
	
	//Save a lookup table to persistent storage
	saveLookupTable: function( url, table ) {
		url = Newslight.stripHash(url);
		Newslight.lookupTables.set(url, table.serialize());
	},
	
	//Add the CSS class {highlight_classname} to all salient DOM nodes (i.e. text nodes and images)
	//that aren't listed in the {lookup_table} set. 
	highlightNewNodes: function( rootNode, lookup_table, highlight_classname ){
		Newslight.highlightedNodes = 0;
		
		//Fetch all text nodes
		var textNodes = document.evaluate(
			'//body//*[ (name() != "SCRIPT") and (name() != "STYLE") and (name() != "LINK") ]/text()', 
			document, //Should be rootNode, but that appears to have no effect?
			null, 
			XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, 
			null
		);
		
		//Iterate over all found nodes
		for (var i=0; i < textNodes.snapshotLength; i++ ){ 
			var node = textNodes.snapshotItem(i);
			
			var text = Newslight.trim(node.data);
			if ( text.length >= Newslight.minTextLength ){ //Only consider text nodes that are big enough
				var lookup_val = 'text:' + text;
				//Is this a new node?
				if ( !lookup_table.contains(lookup_val) ){
					//Highlight it (or rather its parent, as #text nodes can't have a CSS classes assigned).
					Newslight.addClass(node.parentNode, highlight_classname);
					Newslight.highlightedNodes++;
					//Newslight.log('Adding a highlight to a '+node.nodeName+' node ("'+node.data+'")');
				}
			}
		}
		
		//Fetch all images
		var imageNodes = document.evaluate(
			'//img', 
			document, //Should be rootNode, but that appears to have no effect?
			null, 
			XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE, 
			null
		);
		
		//Iterate over the found images
		for (var i=0; i < imageNodes.snapshotLength; i++ ){ 
			var node = imageNodes.snapshotItem(i);
			
			var lookup_val = 'img:' + node.getAttribute('src');
			//Is this a new image?
			if ( !lookup_table.contains(lookup_val) ){
				//Yes, so highlight it
				Newslight.addClass(node, highlight_classname);
				Newslight.highlightedNodes++;
				//Newslight.log('Adding a highlight to a '+node.nodeName+' node ('+lookup_val+')');
			}
		}
		
		Newslight.highlightOn = (Newslight.highlightedNodes > 0);
	},
	
	//Create a lookup table for the current page and save it to persistent storage
	savePageState: function( moreNotifications ){
		var currentLookupTable = Newslight.makeLookupTable( document );
		Newslight.saveLookupTable( location.href, currentLookupTable );
		//Newslight.oldLookupTable = currentLookupTable;
		if ( moreNotifications ){
			Newslight.notify('Page state saved');
		}
	},
	
	//Highglight the page elements that weren't present in the old version of the page  
	highlightNews: function(moreNotifications){
		
		//Do we have the old version data on hand?
		if ( Newslight.oldLookupTable ){
			//Yep, highlight the current document tree
			Newslight.highlightNewNodes( document.getElementsByTagName('body')[0], Newslight.oldLookupTable, Newslight.highlight_class );
		}
		
		if ( Newslight.highlightedNodes > 0 ){
			//Let the user know we found something new
			Newslight.notify('Found '+Newslight.highlightedNodes+' new items');
			Newslight.log('Found '+Newslight.highlightedNodes+' new items');
		} else if (moreNotifications) {
			Newslight.notify('No changes detected');
		}
	},
	
	//Remove the highlight class from all nodes 
	removeHighlights: function(moreNotifications){
		$('.'+Newslight.highlight_class).removeClass(Newslight.highlight_class);
		
		Newslight.highlightOn = false; 
		Newslight.highlightedNodes = 0;
		
		if ( moreNotifications ){
			Newslight.notify('Highlights off');
		}
	},
	
	//Toggle new item highlighting
	toggleHighlights: function( moreNotifications ){
		if ( Newslight.highlightOn ){
			Newslight.removeHighlights(moreNotifications)
		} else {
			Newslight.highlightNews(moreNotifications);
		}
	},
	
	//Turn autosave on/off
	toggleAutosave: function(){
		Newslight.autosave_state = !Newslight.autosave_state;
		GM_setValue( 'Newslight:autosave:'+location.host, Newslight.autosave_state );
		
		if ( Newslight.autosave_state ){
			Newslight.notify('Autosave enabled on '+location.host);
		} else {
			GM_deleteValue( 'Newsligh:autosave:'+location.host );//doesn't work, but should
			Newslight.notify('Autosave disabled on '+location.host);
		}
	},
	
	//Turn automatic highlighting on/off
	toggleAutoHighlight: function(){
		Newslight.auto_highlight = !Newslight.auto_highlight;
		GM_setValue( 'Newslight:autohighlight:'+location.host, Newslight.auto_highlight );
		
		if ( Newslight.auto_highlight ){
			Newslight.notify('Autohighlight enabled on '+location.host);
		} else {
			GM_deleteValue( 'Newsligh:autohighlight:'+location.host );//doesn't work, but should
			Newslight.notify('Autohighlight disabled on '+location.host);
		}
	},	
    
    //Initializes the higlighter
    init: function(){
		//Get autosave & auto-highlight settings for the current domain
		Newslight.autosave_state = GM_getValue('Newslight:autosave:'+location.host, Newslight.autosave_state);
		Newslight.auto_highlight = GM_getValue('Newslight:autohighlight:'+location.host, Newslight.auto_highlight);
		
		Newslight.log('Autosave is '+(Newslight.autosave_state?'en':'dis')+'abled on '+location.host);
		Newslight.log('Autohighlight is '+(Newslight.auto_highlight?'en':'dis')+'abled on '+location.host);
    	
    	Persist.remove('cookie'); //Disable cookies as a storage backend, their size is too limited  
		Persist.remove('gears'); //Disable the Gears backend, it shows an annoying confirmation dialog for each new domain
    	
    	//Inject the highlighter CSS rules
	    Newslight.injectStyle();
    	
		//Initialize my persistent storage
		Newslight.lookupTables = new Persist.Store('WSLookupTableDB');
	    
	    //Load the old lookup table for this page, assuming one exists
	    Newslight.oldLookupTable = null;
	    Newslight.lookupTables.get( Newslight.stripHash(location.href), function(ok, value){
	    	
			if ( ok && (typeof value != 'undefined') && value ){
				//Successfully loaded reference info about an older version of this page.
				Newslight.log('Successfully loaded a reference table for ' + Newslight.stripHash(location.href));
				Newslight.oldLookupTable = new StringLookupSet(value);
				
				//Now we can use it to highlight any new nodes added since then.
				if ( Newslight.auto_highlight ){
					Newslight.highlightNews(); 
				}
			}
			
			//Save the current page state so that we can use it later to detect new items.
			if ( Newslight.autosave_state ){
				Newslight.savePageState();
			}
			
		});
		
		//Add the Greasemonkey menus
		GM_registerMenuCommand("Remember this version of the page", function(){
			Newslight.savePageState(true);
		});
		GM_registerMenuCommand("Highlight changes", function(){
			Newslight.toggleHighlights(true);
		});
		
		GM_registerMenuCommand(
			"Automatically remember pages on "+ location.host + (Newslight.autosave_state?' (On)':' (Off)'), 
			Newslight.toggleAutosave
		);
		GM_registerMenuCommand(
			"Automatically highlight changes on " + location.host + (Newslight.auto_highlight?' (On)':' (Off)'), 
			Newslight.toggleAutoHighlight
		);
				
		//Expose the save/toggle functions for bookmarklet use
		unsafeWindow.NewslightSavePageState = Newslight.savePageState;
		unsafeWindow.NewslightToggleHighlights = Newslight.toggleHighlights;
	},
	
	/******************************************************************
							Utility functions
	*******************************************************************/
	
	//Remove leading and trailing whitespace and newlines from a string
	trim: function(str) {
		var	str = str.replace(/^[\s\r\n][\s\r\n]*/, ''),
			ws = /[\s\r\n]/,
			i = str.length;
		while (ws.test(str.charAt(--i)));
		return str.slice(0, i + 1);
	},
	
	//Remove the #fragment from an URL
	stripHash: function(url){
		return url.replace( /#.*$/, '' );
	},
	
	//Create a <style> tag for the highlight class and pop-up notifications
	injectStyle: function(){
		//Build the CSS rules
		var style_rules = '.'+Newslight.highlight_class + 
			' { background-color : '+ Newslight.highlight_color + ' !important; }\n'+
			'img.' + Newslight.highlight_class + ' { padding: 4px !important; }\n'+
			'.'+Newslight.notificationClass+' { position: fixed; right: 0; bottom: 0; background-color: '+Newslight.notificationColor+'; padding: 6px; border: 0px; display: none; font-size: 12px; font-family: Verdana, "Sans-serif"; color: black; font-style: normal; font-weight: normal; text-decoration: none;}';
		
		//Create a new <style> element
		var style = document.createElement('style');
		style.type = 'text/css';
		style.media = 'screen';
		
		if ( style.styleSheet ){
			style.styleSheet.cssText = style_rules; //IE
		} else {
			style.appendChild( document.createTextNode(style_rules) ); //Anything else
		}
		
		//Append the style element to <head>
		var head = document.getElementsByTagName('head')[0];
		head.appendChild(style);
	},
	
	//Add a given CSS class to a node or its parent
	addClass: function( node, classname ){
		//If this node doesn't have the className property attempt
		//to apply the new class to it's parent instead. 
		if ( !('className' in node) && node.parentNode ){
			//console.log('\tThis node has no className, trying parent');
			return Newslight.addClass( node.parentNode, classname );
		}
		
		if( node.className ){
			//The node already has one or more CSS classes applied, so we need to 
			//check if the new class is already among them.
			
			var arrList = node.className.split(' '); 
			var strClassUpper = classname.toUpperCase(); //for case-insensitive comparison
			
			//Check if our classname is already among the node's classes
			for ( var i = 0; i < arrList.length; i++ ){
				if ( arrList[i].toUpperCase() == strClassUpper ){
					return; //Found it. There's no point in adding it again, so we bail.
				}
			}
			//Didn't find the new class, so add it to the end of the array
			arrList.push(classname);
			//...and update the node
			node.className = arrList.join(' ');
			
		} else {
			//This is the first class added to the node; just set the property
			node.className = classname;
		}
		
	},
	
	//Display a pop-up DHTML notification with the specified message
	notify: function(message, duration){
		if ( !Newslight.notificationsOn ) return;
	
		if ( !duration ){
			duration = 700; //milliseconds
		}
	
		//Create the notification element
		$('<div class="'+Newslight.notificationClass+'">'+message+'</div>')
			.appendTo('body')
			.fadeIn('fast') 				//Fade in
			.fadeTo(duration, 100)			//Stay visible for the specified duration (hack)
			.fadeTo('fast', 0, function(){  //Fade out
				$(this).remove();			//Remove the notification element when done
			});
	},
	
	//Display a log message in the JS console
	log: function(message) {
	    if (typeof console == 'object') {
	        console.log(message);
	    } else if (typeof opera == 'object'){
			opera.postError(message);
	    }
	},
	
}

Newslight.init();