From Wikipedia, the free encyclopedia
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 == $colorPalette0 || 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 && userCategoriesi]; i++) {

				var key = userCategoriesi].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: userCategoriesi].key, text: userCategoriesi].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 />", categoriesi ) );

		}

		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 />", sortCriteriai ) );

		}

		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( obsoleteKeysi]) ) !== "undefined" ) {

					storage.removeItem( obsoleteKeysi );

				}

			}

		}

	};

	

	// 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.userCategoriesi].key,

				             settings.userCategoriesi].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);

	};

} ) ();