User:UncleDouggie/smart watchlist.js

Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/** Smart watchlist
*
* Provides ability to selectively hide and/or highlight changes in a user's watchlist display.
* Author: [[User:UncleDouggie]]
*
*/

// Extend jQuery to add a simple color picker optimized for our use
( function() {

	// works on any display element
	$.fn.swlActivateColorPicker = function( callback ) {    
		if (this.length > 0 && !$colorPalette) {
			constructPalette();
		}
		return this.each( function() { 
			attachColorPicker( this, callback );
		} );
	};

	$.fn.swlDeactivateColorPicker = function() {    
		return this.each( function() { 
			deattachColorPicker( this );
		} );
	};

	// set background color of elements using the palette within this class
	$.fn.swlSetColor = function( paletteIndex ) {    
		return this.each( function() { 
			setColor( this, paletteIndex );
		} );
	};

	var colorPickerOwner;
	var $colorPalette = null;
	var paletteVisible = false;
	var onChangeCallback = null;  // should be able to vary for each color picker using a subclosure (not today)

	var constructPalette = function() {
		$colorPalette = $( "<div />" )
		.css( {
			width: '97px',
			position: 'absolute',
			border: '1px solid #0000bf',
			'background-color': '#f2f2f2',
			padding: '1px'
		} );

		// add each color swatch to the pallete
		$.each( colors, function(i) {
			$("<div>&nbsp;</div>").attr("flag", i)
			.css( {
				height: '12px',
				width: '12px',
				border: '1px solid #000',
				margin: '1px',
				float: 'left',
				cursor: 'pointer',
				'line-height': '12px',
				'background-color': "#"   this
			} )
			.bind( "click", function() { 
				changeColor( $(this).attr("flag"), $(this).css("background-color") )
			} )
			.bind( "mouseover", function() { 
				$(this).css("border-color", "#598FEF"); 
			} ) 
			.bind( "mouseout", function() { 
				$(this).css("border-color", "#000");
			} )
			.appendTo( $colorPalette );
		} );
		$("body").append( $colorPalette );
		$colorPalette.hide();
	};

	var attachColorPicker = function( element, callback ) {
		onChangeCallback = callback;
		$( element )
		.css( {
			border: '1px solid #303030',
			cursor: 'pointer'
		} )
		.bind("click", togglePalette);
	};

	var deattachColorPicker = function(element) {
		if ($colorPalette) {
			$( element )
			.css( {
				border: 'none',  // should restore previous value
				cursor: 'default'  // should restore previous value
			} )
			.unbind("click", togglePalette);
			hidePalette();
		}
	};

	var setColor = function( element, paletteIndex ) {
		$(element).css( {
			'background-color': '#'   colors[ paletteIndex ]
		} );
		var bright = brightness( colors[ paletteIndex ] );
		if ( bright < 128 ) {
			$(element).css( "color", "#ffffff" );  // white text on dark background
		}
		else {
			$(element).css( "color", "" );
		}
	};

	var checkMouse = function(event) {
	
		// check if the click was on the palette or on the colorPickerOwner
		var selectorParent = $(event.target).parents($colorPalette).length;
		if (event.target == $colorPalette[0] || event.target == colorPickerOwner || selectorParent > 0) {
			return;
		}
		hidePalette();   
	};

	var togglePalette = function() {
		colorPickerOwner = this; 
		paletteVisible ? hidePalette() : showPalette();
	};

	var hidePalette = function(){
		$(document).unbind( "mousedown", checkMouse );
		$colorPalette.hide();
		paletteVisible = false;
	};

	var showPalette = function() {
		$colorPalette
		.css( {
			top: $(colorPickerOwner).offset().top   ( $(colorPickerOwner).outerHeight() ),
			left: $(colorPickerOwner).offset().left
		} )
		.show();

		//bind close event handler
		$(document).bind("mousedown", checkMouse);
		paletteVisible = true;
	};

	var changeColor = function( paletteIndex, newColor) {
		setColor( colorPickerOwner, paletteIndex );
		hidePalette();
		if ( typeof(onChangeCallback) === "function" ) {
			onChangeCallback.call( colorPickerOwner, paletteIndex );
		}
	};
	
	var brightness = function( hexColor ) {
		// returns brightness value from 0 to 255
		// algorithm from http://www.w3.org/TR/AERT

		var c_r = parseInt( hexColor.substr(0, 2), 16);
		var c_g = parseInt( hexColor.substr(2, 2), 16);
		var c_b = parseInt( hexColor.substr(4, 2), 16);

		return ((c_r * 299)   (c_g * 587)   (c_b * 114)) / 1000;
	};

	var colors = [
		'ffffff', 'ffffbd','bdffc2', 'bdf7ff', 'b3d6f9', 'ffbdfa',
		'feb88a', 'ffff66','a3fe8a', '8afcfe', 'c1bdff', 'ff80e9',
		'ff7f00', 'ffd733','39ff33', '33fffd', '0ea7dd', 'cf33ff',
		'db0000', 'e0b820','0edd1f', '0ba7bf', '3377ff', 'a60edd',
		'990c00', '997500','0c9900', '008499', '1a0edd', '800099',
		'743436', '737434','347440', '346674', '1b0099', '743472' ];
} ) ();


/** Smart watchlist settings
*
* All settings are grouped together to support save, load, undo, import and export.
* Child objects are read from local storage or created on the fly.
* Structure of the settings object:
*
* settings: {
*    controls: {},
*       Used for control of the GUI and meta data about the settings object.
*       Not subject to undo or import operations, but it is saved, loaded and exported.
*
*    userCategories: [ (displayed category names in menu order, 1 based with no gaps)
*       1: {
*          key: category key,
*          name: category display name
*       },
*       2: ...
*    ],
*    nextCategoryKey: 1 (monotonically increasing key to link page categories with display names)
*    rebuildCategoriesOnUndo: "no" or "rebuild" (optimization for undo)
*
*    wikiList: [ (in display order when sorted by wiki)
*       0: {
*          domain: wiki domain (e.g., "en.wikipedia.org")
*          displayName: "English Wikipedia"
*       },
*       1: ...
*    ],
*    wikis: {
*       wiki domain 1: {
*          watchlistToken: [  // not included for home wiki/account
*             0: { token: tokenID,
*                  userName: username on remote wiki }
*             1: ...
*          ],
*          active: boolean,
*          expanded: boolen,
*          lastLoad: time,
*          pages {  // contains only pages with settings, not everything on a watchlist
*             pageID1: {
*                category: category key,
*                patrolled: revision ID,
*                flag: page flag key,
*                hiddenSections: {
*                   section 1 title: date hidden,
*                   ...
*                   }
*                hiddenRevs: {
*                   revID1: date hidden,
*                   ...
*                }
*             },
*             pageID2: ...
*          },
*          users {
*             username1: {
*                flag: user flag key,
*                hidden: date hidden
*             },
*             username2: ...
*          }
*       },
*       wiki domain 2: ...
*    }
* }
*/

// create a closure so the methods aren't global but we can still directly reference them
( function() {

	// global hooks for event handler callbacks into functions within the closure scope
	SmartWatchlist = {
		changeDisplayedCategory: function() {
			changeDisplayedCategory.apply(this, arguments);
		},
		changePageCategory: function() {
			changePageCategory.apply(this, arguments);
		},
		hideRev: function() {
			hideRev.apply(this, arguments);
		},
		patrolRev: function() {
			patrolRev.apply(this, arguments);
		},
		hideUser: function() {
			hideUser.apply(this, arguments);
		},
		processOptionCheckbox: function() {
			processOptionCheckbox.apply(this, arguments);
		},
		clearSettings: function() {
			clearSettings.apply(this, arguments);
		},
		undo: function() {
			undo.apply(this, arguments);
		},
		setupCategories: function() {
			if (setupCategories) {
				setupCategories.apply(this, arguments);
			}
			else {
				alert("Category editor did not load. Try reloading the page.");
			}
		}
	};
	
	var settings = {};
	var lastSettings = [];
	var maxSettingsSize = 2000000;
	var maxUndo = 100;  // dynamically updated
	var maxSortLevels = 4;
	
	// for local storage - use separate settings for each wiki user account
	var storageKey = "SmartWatchlist."   mw.config.get( 'wgUserName' );  
	var storage = null;
	
	var initialize = function() {
	
		// check for local storage availability
		try {
			if ( typeof(localStorage) === "object"  && typeof(JSON) === "object" ) {
				storage = localStorage;
			}
		}
		catch(e) {}  // ignore error in FF 3.6 with dom.storage.enabled=false
		
		readLocalStorage();  // load saved user settings
		initSettings();
		createSettingsPanel();
		
		// build menu to change the category of a page
		var $categoryMenuTemplate = $constructCategoryMenu( "no meta" )
				// no attributes other than onChange allowed so the menu can be rebuilt in setupCategories()!
				.attr( "onChange", "javascript:SmartWatchlist.changePageCategory(this, value);" );
			
		var lastPageID = null;
		var rowsProcessed = 0;
		
		// process each displayed change row
		$("table.mw-enhanced-rc tr").each( function() {
		
			rowsProcessed  ;
			var $tr = $(this);
			var $td = $tr.find("td:last-child");
			var isHeader = false;
			
			// check if this is the header for an expandable list of changes
			if ( $tr.find(".mw-changeslist-expanded").length > 0 ) {
				isHeader = true;
				lastPageID = null;  // start of a new page section
			}

			/* Parse IDs from the second link. The link text can be of the following forms:
			     1. "n changes" - used on a header row for a collapsable list of changes
				 2. "cur" - an individual change within a list of changes to the same page
				 3. "diff" - single change with no header row 
				 4. "talk" - deleted revision. No page ID is present on such a row. */
				 
			var $secondLink = $td.find("a:eq(1)");  // get second <a> tag in the cell
			var href = $secondLink.attr("href");
			var linkText = $secondLink.text();
			var pageID = href.replace( /.*&curid=/, "" ).replace( /&.*/, "" );
			var revID = href.replace( /.*&oldid=/, "" ).replace( /&.*/, "" );
			var user = $td.find(".mw-userlink").text();
			
			// check if we were able to parse the page ID
			if ( !isNaN(parseInt(pageID)) ) {
				lastPageID = pageID;
			}
			// check for a deleted revision
			else if ( $td.find(".history-deleted").length > 0 && lastPageID ) {
				pageID = lastPageID;  // use page ID from the previous row in the same page, if any
			}
			// unable to determine type of row
			else {
				pageID = null;
				if (console) {
					console.log("SmartWatchlist: unable to parse row "   $td.text());
				}
			}
				
			if (pageID) {
					
				$tr.attr( {
					pageID: pageID,
					wiki: document.domain
				} );

				// check if we were able to parse the rev ID and have an individual change row
				if ( !isNaN(parseInt(revID) ) &&  
					 (linkText == "cur" || linkText == "diff") ) {

					// add the hide change link
					$tr.attr( "revID", revID );
					var $revLink = $("<a/>", {
						href: "javascript:SmartWatchlist.hideRev('"   pageID   "', '"   revID   "');",
						title: "Hide this change",
						text: "hide change"
					});
					$td.append( $( "<span/>" )
						.addClass( "swlRevisionButton" )
						.append( " [" ).append( $revLink ).append( "]" )
					);

					// add the patrol prior changes link
					var $patrolLink = $("<a/>", {
						href: "javascript:SmartWatchlist.patrolRev('"   pageID   "', '"   revID   "');",
						title: "Hide previous changes",
						text: "patrol"
					});
					$td.append( $( "<span/>" )
						.addClass( "swlRevisionButton" )
						.append( " [" ).append( $patrolLink ).append( "]" )
					);
				}

				// check if this is the top-level row for a page
				if ( isHeader || linkText == "diff") {
				
					// add the category menu with the current page category pre-selected
					$newMenu = $categoryMenuTemplate.clone();
					$td.prepend( $newMenu );
					
					// add the page attribute to the link to the page to support highlighting specific pages
					$td.find("a:eq(0)")  // get first <a> tag in the cell
						.attr( {
							pageID: pageID,
							wiki: document.domain
						} )
						.addClass( "swlPageTitleLink" );
				}
			}
			
			// check if we parsed a user for an individual change row
			if (user && !isHeader) {
				
				// mark change row for possible hiding/flagging
				$tr.attr( "wpUser", user );
				if ( !$tr.attr("wiki") ) {
					$tr.attr( "wiki", document.domain );
				}

				// add the hide user link
				var $hideUserLink = $("<a/>", {
					href: "javascript:SmartWatchlist.hideUser('"   user   "');",
					title: "Hide changes by "   user   " on all pages",
					text: "hide user"
				});
				$td.append( $( "<span/>" )
					.addClass( "swlHideUserButton" )
					.append( " [" ).append( $hideUserLink ).append( "]" )
				);
			}
		});  // close each()
		
		// set the user attribute for each username link to support highlighting specific users
		$(".mw-userlink").each( function() {
			var $userLink = $(this);
			$userLink.attr( {
				wiki: document.domain,
				wpUser: $userLink.text() 
			} )
			.addClass("swlUserLink");
		});
		
		initDisplayControls();
		
		// restore last displayed category and apply display settings
		changeDisplayedCategory( 
			selectCategoryMenu( $( "#swlSettingsPanelCategorySelector" ), getSetting("controls", "displayedCategory" ) ) );

		// check if we were able to do anything
		if (rowsProcessed == 0) {
			$("#SmartWatchlistOptions")
				.append( $( "<p/>", {
						text: 'To use Smart Watchlist, enable "enhanced recent changes" in your user preferences.' 
					} )
					.css("color", "#cc00ff")
				);
		}
	};

	var initDisplayControls = function() {
		// set visibility of buttons and pulldowns shown on each change row
		$( ".swlOptionCheckbox" ).each( function() {
			$checkbox = $(this);
			
			// restore saved checkbox setting
			$checkbox.attr( "checked", getSetting("controls", [ $checkbox.attr("controlsProperty") ] ) );
			
			// apply checkbox value to buttons
			processOptionCheckbox( this );
		} );
	};

	// if the desired category exists, pre-select it in the menu
	// otherwise, fallback to the default selection
	var selectCategoryMenu = function( $selector, category ) {
	
		// check if page category has been deleted
		if ( typeof( category ) === "undefined" ) {
			$selector.attr("selectedIndex", "0");  // fallback to first option
		}
		else {
			// attempt to use set page category
			$selector.val( category );
			if ( $selector.val() == null ) {
				// desired category not in the menu, fallback to first option
				$selector.attr("selectedIndex", "0");
			}
		}
		return $selector.val();  // return actual category selected
	};

	// called when the displayed category menu setting is changed
	var changeDisplayedCategory = function(category) {
		setSetting( "controls", "displayedCategory", category );
		applySettings();
		writeLocalStorage();
	};
	
	// called when the category for a page is changed
	var changePageCategory = function( td, category ) {
	
		var $tr = $( td.parentNode.parentNode );
		var pageID = $tr.attr( "pageID" );
		var wiki = $tr.attr( "wiki" );
		
		// convert category to a number if possible
		if ( typeof( category ) === "string" ) {
			var intCategory = parseInt( category );
			if ( !isNaN( intCategory ) ) {
				category = intCategory;
			}
		}
		
		// update category selection menus for all other instances of the page
		$( 'tr[wiki="'   document.domain   '"][pageID="'   pageID   '"] select' ).val( category );
		
		// update settings
		snapshotSettings("change page category");
		
		if ( category == "uncategorized" ) {
			deleteSetting("wikis", document.domain, "pages", pageID, "category")
		} else {
			setSetting("wikis", document.domain, "pages", pageID, "category", category);
		}
		writeLocalStorage();

		// hide the page immediately if auto refresh
		applySettings();
	};
	
	// callback for "hide change"
	var hideRev = function( pageID, revID ) {

		var mode = getSetting( "controls", "displayedCategory" );
		
		// hide the rows unless displaying everything currently
		if ( mode != "all " ) {
			var $tr = $( 'tr[wiki="'   document.domain   '"][revID="'   revID   '"]' );  // retrieve individual change row
			hideElements($tr);
			suppressHeaders();
		}
		
		// update settings
		snapshotSettings("hide change");
		if ( mode == "hide" ) {
			deleteSetting( "wikis", document.domain, "pages", pageID, "hiddenRevs", revID );  // unhide
		}
		else {
			setSetting( "wikis", document.domain, "pages", pageID, "hiddenRevs", revID, new Date() );  // hide
		}
		writeLocalStorage();
	};
	
	// callback for "patrol"
	var patrolRev = function( pageID, revID ) {

		var mode = getSetting( "controls", "displayedCategory" );
		
		// hide the rows unless displaying everything currently
		if ( mode != "all " ) {
			var $tr = $( 'tr[wiki="'   document.domain   '"][pageID="'   pageID   '"]' ).filter( function() {  // filter all rows for the page
				var rowRevID = $(this).attr("revID");
				return (rowRevID <= revID);
			});
			hideElements($tr);
			suppressHeaders();
		}
		
		// update settings
		snapshotSettings("patrol action");
		setSetting("wikis", document.domain, "pages", pageID, "patrolled", revID);
		writeLocalStorage();
	};
	
	// callback for "hide user"
	var hideUser = function( user ) {
	
		var mode = getSetting( "controls", "displayedCategory" );

		// hide the rows unless displaying everything currently
		if ( mode != "all " ) {
			var $tr = $( 'tr[wiki="'   document.domain   '"][wpUser="'   user   '"]' );  // retrieve all changes by user
			hideElements($tr);
			suppressHeaders();
		}

		// update settings
		snapshotSettings("hide user");
		if ( mode == "hide" ) {
			deleteSetting( "wikis", document.domain, "users", user, "hide" );  // unhide
		}
		else {
			setSetting( "wikis", document.domain, "users", user, "hide", new Date() );  // hide
		}
		writeLocalStorage();
	};
	
	// toggle the state of a given class of user interface elements
	var processOptionCheckbox = function( checkbox ) {
		var $checkbox = $(checkbox);
		var $elements = $( "."   $checkbox.attr("controlledClass") );
		if ( checkbox.checked ) {
			if ( $checkbox.hasClass("swlColorPickerControl") ) {
				$elements
				.attr( "onClick", "javascript:return false;")  // disable links so color picker can activate
				.swlActivateColorPicker( setFlag );
			}
			else {
				$elements.show();
			}
		} else {
			if ( $checkbox.hasClass("swlColorPickerControl") ) {
				$elements
				.attr( "onClick", "")  // re-enable links
				.swlDeactivateColorPicker();
			}
			else {
				$elements.hide();
			}
		}
		setSetting( "controls", $checkbox.attr("controlsProperty"), checkbox.checked );
		writeLocalStorage();
	};
	
	// callback from the color picker to flag a user or page
	var setFlag = function( flag ) {
	
		$this = $(this);  // element to be flagged
		var $tr = $this.parents( "tr[wiki]" );
		var wiki = $tr.attr( "wiki" );
		var idLabel;
		var settingPath;
		var $idElement;
		
		if ( $this.hasClass("swlUserLink") ) {
			idLabel = "wpUser";
			$idElement = $this;
			settingPath = "users";
		}
		else {
			idLabel = "pageID";
			$idElement = $tr;
			settingPath = "pages";
		}
		
		var id = $idElement.attr( idLabel );
		
		if ( typeof(id) === "string" ) {
			snapshotSettings("highlight");

			// update the color on all other instances of the element
			$( 'a[wiki="'   wiki   '"]['   idLabel   '="'   id   '"]' ).swlSetColor( flag );

			// update settings
			flag = parseInt( flag );
			if ( !isNaN( flag ) && flag > 0 ) {
				setSetting( "wikis", wiki, settingPath, id, "flag", flag );
			}
			else {
				deleteSetting("wikis", wiki, settingPath, id, "flag");
			}
			writeLocalStorage();
		}
	};
	
	// hide header rows that don't have any displayed changes
	var suppressHeaders = function() {
	
		// process all change list tables (page headers   changes)
		var $tables = $("table.mw-enhanced-rc");
		$tables.each( function( index ) {
		
			var $table = $(this);
			
			// check if this is a header table with a following table
			if ( $table.filter( ":has(.mw-changeslist-expanded)" ).length > 0 &&
			     index   1 < $tables.length ) {

				// check if the following table has visible changes
				var $visibleRows = $tables.filter( ":eq("   (index   1)   ")" )
					.find( "tr" )
					.not( ".swlHidden" );
					
				if ( $visibleRows.length == 0 ) {
					hideElements($table);
				}
			}
		});
	};
	
	// hide a set of jQuery elements and apply our own class 
	// to support header suppression and later unhiding
	var hideElements = function( $elements ) {
		$elements.hide();
		$elements.addClass("swlHidden");
	};

	// reinitialize displayed content using current settings
	var applySettings = function() {
	
		var displayedCategory = getSetting( "controls", "displayedCategory" );
		
		// show all changes, including heading tables
		$( ".swlHidden" ).each( function() {
			var $element = $(this);
			$element.show()
			$element.removeClass("swlHidden");
		});

		if ( displayedCategory != "all " && displayedCategory != "hide" ) {  // XXX should showing these be a new option?
		
			// hide changes by set users
			$( 'tr[wiki="'   document.domain   '"][wpUser]').each( function() {
				var $tr = $(this);
				if ( getSetting( "wikis", document.domain, "users", $tr.attr("wpUser"), "hide" ) ) {
					hideElements($tr);
				}
			});
		}
		
		// process each change row
		$( 'tr[wiki="'   document.domain   '"][pageID]').each( function() {
			var $tr = $(this);
			var pageID = $tr.attr("pageID");
			var revID = $tr.attr("revID");
			var pageCategory = getSetting( "wikis", document.domain, "pages", pageID, "category" );
			var pageFlag = getSetting( "wikis", document.domain, "pages", pageID, "flag" );
			
			// check if there is a page category menu on the row
			var $select = $tr.find( 'select' );
			if ( $select.length == 1 ) {
			
				// select proper item in the menu
				var newCategoryKey = selectCategoryMenu( $select, pageCategory );
					
				// reset page category if the current category has been deleted
				if ( pageCategory && pageCategory != newCategoryKey ) {
					deleteSetting( "wikis", document.domain, "pages", pageID, "category");
					pageCategory = newCategoryKey;
				}
			}

			// check if change should be hidden
			// XXX should we show changes by hidden users when in "hidden" display mode? Maybe a new option.
			var visible;
			
			if (displayedCategory == "all ") {
				visible = true;
			}
			else if ( revID &&
				(  getSetting( "wikis", document.domain, "pages", pageID, "hiddenRevs", revID ) ||  // specific revision is hidden
				  getSetting( "wikis", document.domain, "pages", pageID, "patrolled" ) >= revID  // revision has been patrolled
				) ) {
				visible = false;
			}
			// check if page is hidden
			else if ( pageCategory == "hide" && displayedCategory != "hide" ) {
				visible = false;
			} 
			else if (displayedCategory == "all") {
				visible = true;
			}
			// check for no category
			else if ( displayedCategory == "uncategorized" ) {
				if (pageCategory) {
					visible = false;
				} else {
					visible = true;
				}
			}
			// check if page is flagged
			else if ( displayedCategory == "flag" && typeof(pageFlag) !== "undefined" ) {
				visible = true;
			}
			// check for selected category
			else if ( pageCategory && displayedCategory == pageCategory ) {
				visible = true;
			} 
			else {
				visible = false;
			}
			
			if ( !visible ) {
				hideElements($tr);
			}
		});
		
		// hide changes to unknown pages if not displaying all pages
		if ( displayedCategory != "all " && displayedCategory != "all" && displayedCategory != "uncategorized" ) {
			hideElements( $("table.mw-enhanced-rc tr").not( '[pageID]') );
		}
		
		// decorate user links
		$(".mw-userlink").each( function() {
			var $userLink = $(this);
			var user = $userLink.attr( "wpUser" );
			var flag =  getSetting( "wikis", document.domain, "users", user, "flag" );
			if ( typeof( flag ) == "number" ) {
				$userLink.swlSetColor( flag );
			} else {
				$userLink.swlSetColor( 0 );
			}
		});
		
		// decorate page titles
		$( 'a[pageID]').each( function() {
			var $pageTitleLink = $(this);
			var flag = getSetting( "wikis", document.domain, "pages", [ $pageTitleLink.attr("pageID") ], "flag" );
			if ( typeof( flag ) == "number" ) {
				$pageTitleLink.swlSetColor( flag );
			} else {
				$pageTitleLink.swlSetColor( 0 );
			}
		});
		
		suppressHeaders();
	};
	
	// add smart watchlist settings panel below the standard watchlist options panel
	var createSettingsPanel = function() {
	
		// construct panel column 1
		var $column1 = $( "<td />" ).attr("valign", "top")
			.append( 
				$( "<input>", {
					type: "checkbox",
					"class": "swlOptionCheckbox",
					controlledClass: "swlRevisionButton",
					controlsProperty: "showRevisionButtons",
					onClick: "javascript:SmartWatchlist.processOptionCheckbox(this);"
				} )
			)
			.append("Enable hide/patrol change buttons")
			.append( "<br />" )
			.append( 
				$( "<input>", {
					type: "checkbox",
					"class": "swlOptionCheckbox",
					controlledClass: "swlHideUserButton",
					controlsProperty: "showUserButtons",
					onClick: "javascript:SmartWatchlist.processOptionCheckbox(this);"
				} )
			)
			.append("Enable hide user buttons")
			.append( "<br />" )
			.append( 
				$( "<input>", {
					type: "checkbox",
					"class": "swlOptionCheckbox swlColorPickerControl",
					controlledClass: "swlUserLink",
					controlsProperty: "showUserColorPickers",
					onClick: "javascript:SmartWatchlist.processOptionCheckbox(this);"
				} )
			)
			.append("Assign user highlight colors")
			.append( "<br />" )
			.append( 
				$( "<input>", {
					type: "checkbox",
					"class": "swlOptionCheckbox swlColorPickerControl",
					controlledClass: "swlPageTitleLink",
					controlsProperty: "showPageColorPickers",
					onClick: "javascript:SmartWatchlist.processOptionCheckbox(this);"
				} )
			)
			.append("Assign page highlight colors")
			.append( "<br />" )
			.append( 
				$( "<input>", {
					type: "checkbox",
					"class": "swlOptionCheckbox",
					controlledClass: "swlPageCategoryMenu",
					controlsProperty: "showPageCategoryButtons",
					onClick: "javascript:SmartWatchlist.processOptionCheckbox(this);"
				} )
			)
			.append("Assign page categories");
		
		// construct panel column 2
		var $column2 = $( "<div />" )
			.attr("style", "padding-left: 25pt;")
			.append( 
				$( "<div />" ).attr("align", "center")
				.append(
					$("<input />", {
						type: "button",
						onClick: "javascript:SmartWatchlist.clearSettings();",
						title: "Reset all page and user settings and remove all custom categories",
						value: "Clear settings"
					} ) 
				)
				.append("&nbsp;&nbsp;")
				.append(
					$("<input />", {
						type: "button",
						onClick: "javascript:SmartWatchlist.setupCategories();",
						title: "Create, change and delete custom category names",
						value: "Setup categories"
					} )
				)
				.append("&nbsp;&nbsp;")
				.append(
					$("<input />", {
						type: "button",
						id: "swlUndoButton",
						onClick: "javascript:SmartWatchlist.undo();",
						title: "Nothing to undo",
						disabled: "disabled",
						value: "Undo"
					} ) 
				)
				.append( "<p />" )
				.append( "Display pages in:&nbsp;" )
				.append( 
					$constructCategoryMenu( "meta" )
						// no attributes other than onChange allowed so the menu can be rebuild in setupCategories()!
						.attr( "onChange", "javascript:SmartWatchlist.changeDisplayedCategory(value);" )
				)
			);

		$sortPanel = $( "<div />" ).attr("align", "right")
			.append( "Sort order:&nbsp;" );
		
		for (var i = 0; i < maxSortLevels; i  ) {
			$sortPanel
			.append( $constructSortMenu().attr("selectedIndex", i) )
			.append( "<br />" );
			if (i == 0) {
				$sortPanel.append( "(not yet)&nbsp;&nbsp;" );
			}
		}
		
		// construct panel column 3
		var $column3 = $( "<div />" )
			.attr("style", "padding-left: 25pt;")
			.append( $sortPanel );
			
		// construct main settings panel
		$("#mw-watchlist-options")
			.after( 
				$( "<fieldset />", {
					id: "SmartWatchlistOptions"
				} )
				.append( 
					$( "<legend />", {
						text: "Smart watchlist settings"
					} ) 
				)
				.append( 
					$( "<table />" )
					.append( 
						$( "<tr />" )
						.append( $column1 )
						.append( 
							$( "<td />", {
								valign: "top"
							} )
							.append( $column2 )
						)
						.append( 
							$( "<td />", {
								valign: "top"
							} )
							.append( $column3 )
						)
					)
				)
			);
		
		if ( !storage ) {
			$("#SmartWatchlistOptions")
			.append( 
				$( "<p />", {
					text: "Your browser does not support saving settings to local storage. "  
					"Items hidden or highlighted will not be retained after reloading the page."
				} )
				.css("color", "red")
			);
		}
	};

	// construct a page category menu
	var $constructCategoryMenu = function( metaOptionString ) {

		var $selector = 
			$( "<select />", {
				"class": "namespaceselector swlCategoryMenu",
				withMeta: metaOptionString  // flag so the menu can be rebuilt in setupCategories()
			} );

		if (metaOptionString == "meta") {
			// for updating the displayed category selection
			$selector.attr( "id", "swlSettingsPanelCategorySelector");
		}
		else {
			// for hiding/showing page category menus
			$selector.addClass( "swlPageCategoryMenu" );
		}

		// create default category, must be first in the menu!!!
		var categories = [
			{ value: "uncategorized", text: "uncategorized" }
		];
		
		
		// add user categories, if any
		var userCategories = getSetting("userCategories");
		if ( typeof(userCategories) === "object" ) {
			for (var i = 0; i < userCategories.length && userCategories[i]; i  ) {
				var key = userCategories[i].key;
				if ( typeof(key) !== "number" ) {
					alert("Smart watchlist user category definitions are corrupt. You will need to clear your settings. Sorry.");
					break;
				}
				else {
					categories.push( { value: userCategories[i].key, text: userCategories[i].name } )
				}
			}
		}
		
		// add special categories to settings menu
		if (metaOptionString == "meta") {
			categories.push(
				{ value: "all", text: "all except hidden" },
				{ value: "flag", text: "highlighted" }
			);
		}

		categories.push( { value: "hide", text: "hidden" } );
		
		if (metaOptionString == "meta") {
			categories.push( { value: "all ", text: "everything" } );
		}

		// construct all <option> elements
		for (var i in categories) {
			$selector.append( $( "<option />", categories[i] ) );
		}
		return $selector;
	};

	// construct a page category menu
	var $constructSortMenu = function() {

		var $selector = 
			$( "<select />", {
				"class": "namespaceselector swlSortMenu"
			} );

		var sortCriteria = [
			{ value: "wiki", text: "Wiki" },
			{ value: "title", text: "Title" },
			{ value: "timeDec", text: "Time (newest first)" },
			{ value: "timeInc", text: "Time (oldest first)" },
			{ value: "risk", text: "Vandalism risk" },
			{ value: "namespace", text: "Namespace" },
			{ value: "flagPage", text: "Highlighted pages" },
			{ value: "flagUser", text: "Highlighted users" }
		];
		
		// construct all <option> elements
		for (var i in sortCriteria) {
			$selector.append( $( "<option />", sortCriteria[i] ) );
		}
		return $selector;
	};

	// save settings for later undo
	var snapshotSettings = function( currentAction, rebuildOption ) {

		if (typeof(rebuildOption) === "undefined") {
			rebuildOption = "no";
		}
		setSetting("rebuildCategoriesOnUndo", rebuildOption);
		
		var settingsClone = $.extend( true, {}, settings );
		lastSettings.push( settingsClone );
		while (lastSettings.length > maxUndo) {
			lastSettings.shift();
		}
		
		if (currentAction) {
			currentAction = "Undo "   currentAction;
		} else {
			currentAction = "Undo last change";
		}
		setSetting("undoAction", currentAction);
		$( "#swlUndoButton" )
			.attr("disabled", "")
			.attr( "title", currentAction );
	};

	// restore previous settings
	var undo = function() {
		if (lastSettings.length > 0) {
		
			var currentControls = settings.controls;
			settings = lastSettings.pop();
			settings.controls = currentControls;  // controls aren't subject to undo
			
			// only rebuild menus when needed because it takes several seconds
			if (getSetting("rebuildCategoriesOnUndo") == "rebuild") {
				rebuildCategoryMenus();  // also updates display and local storage
			}
			else {
				writeLocalStorage();
				applySettings();
			}
			
			var lastAction = getSetting("undoAction");
			if (!lastAction) {
				lastAction = "";
			}
			$( "#swlUndoButton" ).attr( "title", lastAction );
			
			if (lastSettings.length == 0) {
				$( "#swlUndoButton" )
					.attr( "disabled", "disabled" )
					.attr( "title", "Nothing to undo" );
			}
		}
	};
	
	// for use after a change to the category settings
	var rebuildCategoryMenus = function() {
	
		// rebuild existing category menus
		$( '.swlCategoryMenu' ).each( function() {
			var $newMenu = $constructCategoryMenu( $(this).attr('withMeta') );
			$newMenu.attr( "onChange", $(this).attr("onChange") );  // retain old menu action
			this.parentNode.replaceChild( $newMenu.get(0), this );
		} );
		
		// update menu selections and save settings
		changeDisplayedCategory( 
			selectCategoryMenu( $( "#swlSettingsPanelCategorySelector" ), getSetting("controls", "displayedCategory" ) ) );
			
		initDisplayControls();
	};

	// read from local storage to current in-work settings during initialization
	var readLocalStorage = function() {
		if (storage) {
		
			var storedString = storage.getItem(storageKey);
			if (storedString) {

				try {
					settings = JSON.parse( storedString );
				}
				catch (e) {
					alert( "Smart watchlist: error loading stored settings!" );
					settings = {};
				}
			}
		
			// delete all obsolete local storage keys from prior versions and bugs
			// this can eventually go away
			var obsoleteKeys = [
				"undefinedmarkedUsers",
				"undefinedmarkedPages",
				"undefinedpatrolledRevs",
				"undefinedhiddenRevs",
				"undefinedGUI",
				"SmartWatchlist.flaggedPages",
				"SmartWatchlist.flaggedUsers",
				"SmartWatchlist.hiddenPages",
				"SmartWatchlist.hiddenUsers",
				"SmartWatchlist.markedUsers",
				"SmartWatchlist.markedPages",
				"SmartWatchlist.patrolledRevs",
				"SmartWatchlist.hiddenRevs",
				"SmartWatchlist.GUI",
				"SmartWatchlist."   mw.config.get( "wgUserName" )   ".markedUsers",
				"SmartWatchlist."   mw.config.get( "wgUserName" )   ".markedPages",
				"SmartWatchlist."   mw.config.get( "wgUserName" )   ".patrolledRevs",
				"SmartWatchlist."   mw.config.get( "wgUserName" )   ".userFlag",
				"SmartWatchlist."   mw.config.get( "wgUserName" )   ".pageCategory",
				"SmartWatchlist."   mw.config.get( "wgUserName" )   ".pageFlag",
				"SmartWatchlist."   mw.config.get( "wgUserName" )   ".patrolledRevision",
				"SmartWatchlist."   mw.config.get( "wgUserName" )   ".hiddenRevs",
				"SmartWatchlist."   mw.config.get( "wgUserName" )   ".GUI",
				"length"
			];
			for (var i in obsoleteKeys) {
				if ( typeof( storage.getItem( obsoleteKeys[i]) ) !== "undefined" ) {
					storage.removeItem( obsoleteKeys[i] );
				}
			}
		}
	};
	
	// update local storage to current in-work settings
	var writeLocalStorage = function() {
		if (storage) {
			var storeString = JSON.stringify( settings );
			var size = storeString.length;
			if ( size > maxSettingsSize ) {
				storeString = "";
				alert( "Smart watchlist: new settings are too large to be saved ("   size   " bytes)!" )
				return;
			}
				
			var lastSaveString = storage.getItem(storageKey);

			try {
				storage.setItem( storageKey, storeString );
			}
			catch (e) {
				storeString = "";				
				alert( "Smart watchlist: error saving new settings!" );
				
				// revert to previously saved settings that seemed to work
				storage.setItem( storageKey, lastSaveString );
			}
			maxUndo = Math.floor( maxSettingsSize / size )   2;
		}
	};
	
	// erase all saved settings
	var clearSettings = function() {
		snapshotSettings("clear settings", "rebuild");
		var currentControls = settings.controls;
		settings = {};
		settings.controls = currentControls;  // controls aren't subject to clearing
		initSettings();
		rebuildCategoryMenus();  // also updates display and local storage
	};
	
	// lookup a setting path passed as a series of arguments
	// returns undefined if no setting exists
	var getSetting = function() {
		var obj = settings;
		for (var index in arguments) {
			if (typeof( obj ) !== "object") {
				return undefined;  // part of path is missing
			}
			obj = obj[ arguments[ index ] ];
		}
		return obj;
	};
	
	// set the value of a setting path passed as a series of argument strings
	// creates intermediate objects as needed
	// number arguments reference arrays and string arguments reference associative array properties
	// the last argument is the value to be set (can be any type)
	var setSetting = function() {
		if (arguments.length < 2) {
			throw "setSetting: insufficient arguments";
		}
		var obj = settings;
		for (var index = 0; index < arguments.length - 2; index  ) {
			var nextObj = obj[ arguments[ index] ];
			if (typeof( nextObj ) !== "object") {
				if ( typeof( arguments[ index   1 ] ) === "number" ) {
					nextObj = obj[ arguments[ index ] ] = [];
				} else {
					nextObj = obj[ arguments[ index ] ] = {};
				}
			}
			obj = nextObj;
		}
		obj[ arguments[ arguments.length - 2 ] ] = arguments[ arguments.length - 1 ];
	};
	
	// delete a setting path passed as a series of argument strings if the entire path exists
	var deleteSetting = function() {
		if (arguments.length < 1) {
			throw "deleteSetting: insufficient arguments";
		}
		var obj = settings;
		for (var index = 0; index < arguments.length - 1; index  ) {
			// check if we hit a snag and still have more arguments to go
			if (typeof( obj ) !== "object") {
				return;
			}
			obj = obj[ arguments[ index ] ];
		}
		if (typeof( obj ) === "object") {
			delete obj[ arguments[ index ] ];
		}
	};

	var initSettings = function() {
	
		// check if home domain already exists
		if ( !getSetting("wikis", document.domain) ) {
			setSetting("wikis", document.domain, "active", true);
			var wikiNumber = 0;
			var wikiList = getSetting("wikiList");
			if (wikiList) {
				wikiNumber = wikiList.length;
			}
			setSetting("wikiList", wikiNumber, {
				domain: document.domain,
				displayName: document.domain
			} );
		}
		
		if ( !settings.nextCategoryKey ) {
			settings.nextCategoryKey = 1;
		}
	};

	// dialog windows
	var setupCategories = null;
	mw.loader.using( ['jquery.ui'], function() {

		setupCategories = function () {
		
			// construct a category name row for editing
			var addCategory = function ( key, name ) {
				$editTable.append( 
					$( '<tr />' )
					.append( 
						$( '<td />' ).append( $( '<span />' ).addClass( 'ui-icon ui-icon-arrowthick-2-n-s' ) )
					)
					.append(
						$( '<td />' ).append(
							$( '<input />', {
								type: 'text',
								size: '20',
								categoryKey: key,
								value: name
							} )
						)
					)
				);
			};
		
			// jQuery UI sortable() seems to only like <ul> top-level elements
			var $editTable = $( '<ul />' ).sortable( { axis: 'y' } );
			
			for (var i in settings.userCategories) {
				addCategory( settings.userCategories[i].key,
				             settings.userCategories[i].name );
			}
			if ( !getSetting( 'userCategories', 0 ) ) {
				addCategory( settings.nextCategoryKey  , '' );  // pre-add first category if needed
			}
			
			var $interface = $('<div />')
				.css( {
					'position': 'relative',
					'margin-top': '0.4em'
				} )
				.append( 
					$( '<ul />')
					.append( $( '<li />', { text: "Renamed categories retain current pages." } ) )
					.append( $( '<li />', { text: "Dragging lines changes the order in category menus." } ) )
					.append( $( '<li />', { text: "To delete a category, blank its name." } ) )
					.append( $( '<li />', { text: "Pages in deleted categories revert to uncategorized." } ) )
				)
				.append( $( '<br />' ) )
				.append( $editTable )
				.append( $( '<br />' ) )
				.dialog( {
					width: 400,
					autoOpen: false,
					title: 'Custom category setup',
					modal: true,
					buttons: { 
						'Save': function() { 
							$(this).dialog('close');
							snapshotSettings('category setup', 'rebuild');
							
							// replace category names in saved settings
							deleteSetting( 'userCategories' );
							var index = 0;
							$editTable.find('input').each( function() {

								var name = $.trim(this.value);
								if (name.length > 0) {  // skip blank categories
								
									// convert category key back into a number
									var key = $(this).attr('categoryKey');
									if ( typeof( key ) === "string" ) {
										var intKey = parseInt( key );
										if ( !isNaN( intKey ) ) {
											setSetting( 'userCategories', index  , {
												key: intKey,
												name: name
											} );
										}
									}
								}
							} );
							rebuildCategoryMenus();
						},
						'Add category': function() {
							addCategory( settings.nextCategoryKey  , '' );
						},
						'Cancel': function() { 
							$(this).dialog('close');
						}
					}
				} );
			$interface.dialog('open');
		}
	} );
	
	// activate only on the watchlist page
	if ( mw.config.get("wgNamespaceNumber") == -1 && mw.config.get("wgTitle") == "Watchlist" ) {
		$(document).ready(initialize);
	};
} ) ();