MediaWiki:Gadget-checkboxList-core.js: Difference between revisions

From RuneRealm Wiki
Jump to navigation Jump to search
Content added Content deleted
(Created page with "→‎* * Adds support for checkbox lists ([[Template:Checklist]]) * * Examples/Tests: <https://rs.wiki/User:Cqm/Scrapbook_4> * * History: * - 1.0: Original implementation - Cqm: /* * DATA STORAGE STRUCTURE * ---------------------- * * In its raw, uncompressed format, the stored data is as follows: * { * hashedPageName1: [ * [0, 1, 0, 1, 0, 1], * [1, 0, 1, 0, 1, 0], * [0, 0, 0, 0, 0, 0] * ], * hashedPageName2: [ *...")
 
No edit summary
 
(3 intermediate revisions by the same user not shown)
Line 79: Line 79:
'use strict';
'use strict';


// constants
// constants
var STORAGE_KEY = 'rs:checkList',
var STORAGE_KEY = 'rs:checkList',
LIST_CLASS = 'checklist',
LIST_CLASS = 'checklist',
CHECKED_CLASS = 'checked',
CHECKED_CLASS = 'checked',
NO_TOGGLE_PARENT_CLASS = 'no-toggle-parent',
NO_TOGGLE_PARENT_CLASS = 'no-toggle-parent',
INDEX_ATTRIBUTE = 'data-checklist-index',
INDEX_ATTRIBUTE = 'data-checklist-index',
BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
PAGE_SEPARATOR = '!',
PAGE_SEPARATOR = '!',
LIST_SEPARATOR = '.',
LIST_SEPARATOR = '.',
CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
UINT32_MAX = 0xffffffff,
UINT32_MAX = 0xffffffff,
conf = mw.config.get(['debug', 'wgPageName']),
self = {
/*
* Stores the current uncompressed data for the current page.
*/
data: null,
/*
* Perform initial checks on the page and browser.
*/
init: function init() {
var $lists = $(['ul.' + LIST_CLASS, 'div.' + LIST_CLASS + ' > ul'].join(', ')),
hashedPageName = self.hashString(mw.config.get('wgPageName'));


// check we have some tables to interact with
conf = mw.config.get([
'debug',
if (!$lists.length) {
'wgPageName'
return;
]),
}


// check the browser supports local storage
if (!rs.hasLocalStorage()) {
return;
}
self.data = self.load(hashedPageName, $lists.length);
self.initLists(hashedPageName, $lists);
},
/*
* Initialise table highlighting.
*
* @param hashedPageName The current page name as a hash.
* @param $lists A list of checkbox lists on the current page.
*/
initLists: function initLists(hashedPageName, $lists) {
$lists.each(function (listIndex) {
var $this = $(this),
toggleParent = !($this.hasClass(NO_TOGGLE_PARENT_CLASS) || $this.parent('div.' + LIST_CLASS).hasClass(NO_TOGGLE_PARENT_CLASS)),
// list items
$items = $this.find('li'),
listData = self.data[listIndex];


// initialise list items if necessary
self = {
while ($items.length > listData.length) {
/*
listData.push(0);
* Stores the current uncompressed data for the current page.
*/
}
data: null,
$items.each(function (itemIndex) {
var $this = $(this),
itemData = listData[itemIndex];


// initialize checking based on the cookie
/*
self.setChecked($this, itemData);
* Perform initial checks on the page and browser.
*/
init: function () {
var $lists = $(['ul.' + LIST_CLASS,
'div.' + LIST_CLASS + ' > ul'].join(', ')),
hashedPageName = self.hashString(mw.config.get('wgPageName'));


// check we have some tables to interact with
// give the item a unique index in the list
if (!$lists.length) {
$this.attr(INDEX_ATTRIBUTE, itemIndex);
return;
}


// check the browser supports local storage
// set mouse events
if (!rs.hasLocalStorage()) {
$this.click(function (e) {
return;
var $this = $(this),
$parent = $this.parent('ul').parent('li'),
$childItems = $this.children('ul').children('li'),
isChecked;

// don't bubble up to parent lists
e.stopPropagation();
function checkChildItems() {
var $this = $(this),
index = $this.attr(INDEX_ATTRIBUTE),
$childItems = $this.children('ul').children('li'),
childIsChecked = $this.hasClass(CHECKED_CLASS);
if (isChecked && !childIsChecked || !isChecked && childIsChecked) {
listData[index] = 1 - listData[index];
self.setChecked($this, listData[index]);
}
if ($childItems.length) {
$childItems.each(checkChildItems);
}
}
}
function checkParent($parent) {

self.data = self.load(hashedPageName, $lists.length);
var parentIndex = $parent.attr(INDEX_ATTRIBUTE),
parentIsChecked = $parent.hasClass(CHECKED_CLASS),
self.initLists(hashedPageName, $lists);
parentShouldBeChecked = true,
},
$myParent = $parent.parent('ul').parent('li');

$parent.children('ul').children('li').each(function () {
/*
* Initialise table highlighting.
var $child = $(this),
childIsChecked = $child.hasClass(CHECKED_CLASS);
*
* @param hashedPageName The current page name as a hash.
if (!childIsChecked) {
* @param $lists A list of checkbox lists on the current page.
parentShouldBeChecked = false;
*/
initLists: function (hashedPageName, $lists) {
$lists.each(function (listIndex) {
var $this = $(this),
toggleParent = !(
$this.hasClass(NO_TOGGLE_PARENT_CLASS) ||
$this.parent('div.' + LIST_CLASS).hasClass(NO_TOGGLE_PARENT_CLASS)
),
// list items
$items = $this.find('li'),
listData = self.data[listIndex];

// initialise list items if necessary
while ($items.length > listData.length) {
listData.push(0);
}
}
});

if (parentShouldBeChecked && !parentIsChecked && toggleParent || !parentShouldBeChecked && parentIsChecked) {
$items.each(function (itemIndex) {
var $this = $(this),
listData[parentIndex] = 1 - listData[parentIndex];
itemData = listData[itemIndex];
self.setChecked($parent, listData[parentIndex]);
}

if ($myParent.length) {
// initialize checking based on the cookie
self.setChecked($this, itemData);
checkParent($myParent);
}

// give the item a unique index in the list
$this.attr(INDEX_ATTRIBUTE, itemIndex);

// set mouse events
$this
.click(function (e) {
var $this = $(this),
$parent = $this.parent('ul').parent('li'),
$childItems = $this.children('ul').children('li'),
isChecked;

// don't bubble up to parent lists
e.stopPropagation();

function checkChildItems() {
var $this = $(this),
index = $this.attr(INDEX_ATTRIBUTE),
$childItems = $this.children('ul').children('li'),
childIsChecked = $this.hasClass(CHECKED_CLASS);

if (
(isChecked && !childIsChecked) ||
(!isChecked && childIsChecked)
) {
listData[index] = 1 - listData[index];
self.setChecked($this, listData[index]);
}

if ($childItems.length) {
$childItems.each(checkChildItems);
}
}

function checkParent($parent) {
var parentIndex = $parent.attr(INDEX_ATTRIBUTE),
parentIsChecked = $parent.hasClass(CHECKED_CLASS),
parentShouldBeChecked = true,
$myParent = $parent.parent('ul').parent('li');

$parent.children('ul').children('li').each(function () {
var $child = $(this),
childIsChecked = $child.hasClass(CHECKED_CLASS);

if (!childIsChecked) {
parentShouldBeChecked = false;
}
});

if (
(parentShouldBeChecked && !parentIsChecked && toggleParent) ||
(!parentShouldBeChecked && parentIsChecked)
) {
listData[parentIndex] = 1 - listData[parentIndex];
self.setChecked($parent, listData[parentIndex]);
}

if ($myParent.length) {
checkParent($myParent);
}
}

// don't toggle highlight when clicking links
if ((e.target.tagName !== 'A') && (e.target.tagName !== 'IMG')) {
// 1 -> 0
// 0 -> 1
listData[itemIndex] = 1 - listData[itemIndex];

self.setChecked($this, listData[itemIndex]);
isChecked = $this.hasClass(CHECKED_CLASS);

if ($childItems.length) {
$childItems.each(checkChildItems);
}

// if the list has a parent
// check if all the children are checked and uncheck the parent if not
if ($parent.length) {
checkParent($parent);
}

self.save(hashedPageName);
}
});
});
// add a button for reset
var reset = $('<div>').append(
$('<sup>').append('[').append(
$('<a>').append('uncheck all')
).append(']')
).addClass('sl-reset');

reset.first('sup').click(function () {
$items.each(function (itemIndex) {
listData[itemIndex] = 0;
self.setChecked($(this), 0);
});

self.save(hashedPageName, $lists.length);
});
$this.append(reset);
});
},

/*
* Change the list item checkbox based on mouse events.
*
* @param $item The list item element.
* @param val The value to control what class to add (if any).
* 0 -> unchecked (no class)
* 1 -> light on
* 2 -> mouse over
*/
setChecked: function ($item, val) {
$item.removeClass(CHECKED_CLASS);

switch (val) {
// checked
case 1:
$item.addClass(CHECKED_CLASS);
break;
}
}
},


// don't toggle highlight when clicking links
/*
if (e.target.tagName !== 'A' && e.target.tagName !== 'IMG') {
* Merge the updated data for the current page into the data for other pages into local storage.
*
// 1 -> 0
* @param hashedPageName A hash of the current page name.
// 0 -> 1
listData[itemIndex] = 1 - listData[itemIndex];
*/
self.setChecked($this, listData[itemIndex]);
save: function (hashedPageName) {
// load the existing data so we know where to save it
isChecked = $this.hasClass(CHECKED_CLASS);
var curData = localStorage.getItem(STORAGE_KEY),
if ($childItems.length) {
compressedData;
$childItems.each(checkChildItems);
}


if (curData === null) {
// if the list has a parent
curData = {};
// check if all the children are checked and uncheck the parent if not
} else {
if ($parent.length) {
curData = JSON.parse(curData);
checkParent($parent);
curData = self.parse(curData);
}
self.save(hashedPageName);
}
}
});
});


// merge in our updated data and compress it
// add a button for reset
var reset = $('<div>').append($('<sup>').append('[').append($('<a>').append('uncheck all')).append(']')).addClass('sl-reset');
curData[hashedPageName] = self.data;
reset.first('sup').click(function () {
compressedData = self.compress(curData);
$items.each(function (itemIndex) {
listData[itemIndex] = 0;
self.setChecked($(this), 0);
});
self.save(hashedPageName, $lists.length);
});
$this.append(reset);
});
},
/*
* Change the list item checkbox based on mouse events.
*
* @param $item The list item element.
* @param val The value to control what class to add (if any).
* 0 -> unchecked (no class)
* 1 -> light on
* 2 -> mouse over
*/
setChecked: function setChecked($item, val) {
$item.removeClass(CHECKED_CLASS);
switch (val) {
// checked
case 1:
$item.addClass(CHECKED_CLASS);
break;
}
},
/*
* Merge the updated data for the current page into the data for other pages into local storage.
*
* @param hashedPageName A hash of the current page name.
*/
save: function save(hashedPageName) {
// load the existing data so we know where to save it
var curData = localStorage.getItem(STORAGE_KEY),
compressedData;
if (curData === null) {
curData = {};
} else {
curData = JSON.parse(curData);
curData = self.parse(curData);
}


// convert to a string and save to localStorage
// merge in our updated data and compress it
curData[hashedPageName] = self.data;
compressedData = JSON.stringify(compressedData);
compressedData = self.compress(curData);
localStorage.setItem(STORAGE_KEY, compressedData);
},


// convert to a string and save to localStorage
/*
compressedData = JSON.stringify(compressedData);
* Compress the entire data set using tha algoritm documented at the top of the page.
localStorage.setItem(STORAGE_KEY, compressedData);
*
},
* @param data The data to compress.
*
/*
* Compress the entire data set using tha algoritm documented at the top of the page.
* @return the compressed data.
*/
*
compress: function (data) {
* @param data The data to compress.
*
var ret = {};
* @return the compressed data.
*/
Object.keys(data).forEach(function (hashedPageName) {
compress: function compress(data) {
var pageData = data[hashedPageName],
var ret = {};
pageKey = hashedPageName.charAt(0);
Object.keys(data).forEach(function (hashedPageName) {
var pageData = data[hashedPageName],
pageKey = hashedPageName.charAt(0);
if (!ret.hasOwnProperty(pageKey)) {
ret[pageKey] = {};
}
ret[pageKey][hashedPageName] = [];
pageData.forEach(function (tableData) {
var compressedListData = '',
i,
j,
k;
for (i = 0; i < Math.ceil(tableData.length / 6); i += 1) {
k = tableData[6 * i];
for (j = 1; j < 6; j += 1) {
k = 2 * k + (6 * i + j < tableData.length ? tableData[6 * i + j] : 0);
}
compressedListData += BASE_64_URL.charAt(k);
}
ret[pageKey][hashedPageName].push(compressedListData);
});
ret[pageKey][hashedPageName] = ret[pageKey][hashedPageName].join(LIST_SEPARATOR);
});
Object.keys(ret).forEach(function (pageKey) {
var hashKeys = Object.keys(ret[pageKey]),
hashedData = [];
hashKeys.forEach(function (key) {
var pageData = ret[pageKey][key];
hashedData.push(key + pageData);
});
hashedData = hashedData.join(PAGE_SEPARATOR);
ret[pageKey] = hashedData;
});
return ret;
},
/*
* Get the existing data for the current page.
*
* @param hashedPageName A hash of the current page name.
* @param numLists The number of lists on the current page. Used to ensure the loaded
* data matches the number of lists on the page thus handling cases
* where lists have been added or removed. This does not check the
* amount of items in the given lists.
*
* @return The data for the current page.
*/
load: function load(hashedPageName, numLists) {
var data = localStorage.getItem(STORAGE_KEY),
pageData;
if (data === null) {
pageData = [];
} else {
data = JSON.parse(data);
data = self.parse(data);
if (data.hasOwnProperty(hashedPageName)) {
pageData = data[hashedPageName];
} else {
pageData = [];
}
}


// if more lists were added
if (!ret.hasOwnProperty(pageKey)) {
ret[pageKey] = {};
// add extra arrays to store the data in
}
// also populates if no existing data was found
while (numLists > pageData.length) {
pageData.push([]);
}


// if lists were removed, remove data from the end of the list
ret[pageKey][hashedPageName] = [];
// as there's no way to tell which was removed
while (numLists < pageData.length) {
pageData.pop();
}
return pageData;
},
/*
* Parse the compressed data as loaded from local storage using the algorithm desribed
* at the top of the page.
*
* @param data The data to parse.
*
* @return the parsed data.
*/
parse: function parse(data) {
var ret = {};
Object.keys(data).forEach(function (pageKey) {
var pageData = data[pageKey].split(PAGE_SEPARATOR);
pageData.forEach(function (listData) {
var hashedPageName = listData.substr(0, 8);
listData = listData.substr(8).split(LIST_SEPARATOR);
ret[hashedPageName] = [];
listData.forEach(function (itemData, index) {
var i, j, k;
ret[hashedPageName].push([]);
for (i = 0; i < itemData.length; i += 1) {
k = BASE_64_URL.indexOf(itemData.charAt(i));


pageData.forEach(function (tableData) {
// input validation
var compressedListData = '',
if (k < 0) {
i, j, k;
k = 0;
}

for (i = 0; i < Math.ceil(tableData.length / 6); i += 1) {
for (j = 5; j >= 0; j -= 1) {
k = tableData[6 * i];
ret[hashedPageName][index][6 * i + j] = k & 0x1;
k >>= 1;

for (j = 1; j < 6; j += 1) {
}
k = 2 * k + ((6 * i + j < tableData.length) ? tableData[6 * i + j] : 0);
}

compressedListData += BASE_64_URL.charAt(k);
}

ret[pageKey][hashedPageName].push(compressedListData);
});

ret[pageKey][hashedPageName] = ret[pageKey][hashedPageName].join(LIST_SEPARATOR);
});

Object.keys(ret).forEach(function (pageKey) {
var hashKeys = Object.keys(ret[pageKey]),
hashedData = [];

hashKeys.forEach(function (key) {
var pageData = ret[pageKey][key];
hashedData.push(key + pageData);
});

hashedData = hashedData.join(PAGE_SEPARATOR);
ret[pageKey] = hashedData;
});

return ret;
},

/*
* Get the existing data for the current page.
*
* @param hashedPageName A hash of the current page name.
* @param numLists The number of lists on the current page. Used to ensure the loaded
* data matches the number of lists on the page thus handling cases
* where lists have been added or removed. This does not check the
* amount of items in the given lists.
*
* @return The data for the current page.
*/
load: function (hashedPageName, numLists) {
var data = localStorage.getItem(STORAGE_KEY),
pageData;

if (data === null) {
pageData = [];
} else {
data = JSON.parse(data);
data = self.parse(data);

if (data.hasOwnProperty(hashedPageName)) {
pageData = data[hashedPageName];
} else {
pageData = [];
}
}
}
});
});
});
return ret;
},
/*
* Hash a string into a big endian 32 bit hex string. Used to hash page names.
*
* @param input The string to hash.
*
* @return the result of the hash.
*/
hashString: function hashString(input) {
var ret = 0,
table = [],
i,
j,
k;


// if more lists were added
// guarantee 8-bit chars
input = window.unescape(window.encodeURI(input));
// add extra arrays to store the data in
// also populates if no existing data was found
while (numLists > pageData.length) {
pageData.push([]);
}


// if lists were removed, remove data from the end of the list
// calculate the crc (cyclic redundancy check) for all 8-bit data
// as there's no way to tell which was removed
// bit-wise operations discard anything left of bit 31
while (numLists < pageData.length) {
for (i = 0; i < 256; i += 1) {
pageData.pop();
k = i << 24;
}
for (j = 0; j < 8; j += 1) {
k = k << 1 ^ (k >>> 31) * CASTAGNOLI_POLYNOMIAL;
}
table[i] = k;
}


return pageData;
// the actual calculation
for (i = 0; i < input.length; i += 1) {
},
ret = ret << 8 ^ table[ret >>> 24 ^ input.charCodeAt(i)];
}


/*
// make negative numbers unsigned
if (ret < 0) {
* Parse the compressed data as loaded from local storage using the algorithm desribed
* at the top of the page.
ret += UINT32_MAX;
*
}
* @param data The data to parse.
*
* @return the parsed data.
*/
parse: function (data) {
var ret = {};


// 32-bit hex string, padded on the left
Object.keys(data).forEach(function (pageKey) {
ret = '0000000' + ret.toString(16).toUpperCase();
var pageData = data[pageKey].split(PAGE_SEPARATOR);
ret = ret.substr(ret.length - 8);

return ret;
pageData.forEach(function (listData) {
}
var hashedPageName = listData.substr(0, 8);
};

listData = listData.substr(8).split(LIST_SEPARATOR);
ret[hashedPageName] = [];

listData.forEach(function (itemData, index) {
var i, j, k;

ret[hashedPageName].push([]);

for (i = 0; i < itemData.length; i += 1) {
k = BASE_64_URL.indexOf(itemData.charAt(i));

// input validation
if (k < 0) {
k = 0;
}

for (j = 5; j >= 0; j -= 1) {
ret[hashedPageName][index][6 * i + j] = (k & 0x1);
k >>= 1;
}
}
});
});

});

return ret;
},

/*
* Hash a string into a big endian 32 bit hex string. Used to hash page names.
*
* @param input The string to hash.
*
* @return the result of the hash.
*/
hashString: function (input) {
var ret = 0,
table = [],
i, j, k;

// guarantee 8-bit chars
input = window.unescape(window.encodeURI(input));

// calculate the crc (cyclic redundancy check) for all 8-bit data
// bit-wise operations discard anything left of bit 31
for (i = 0; i < 256; i += 1) {
k = (i << 24);

for (j = 0; j < 8; j += 1) {
k = (k << 1) ^ ((k >>> 31) * CASTAGNOLI_POLYNOMIAL);
}
table[i] = k;
}

// the actual calculation
for (i = 0; i < input.length; i += 1) {
ret = (ret << 8) ^ table[(ret >>> 24) ^ input.charCodeAt(i)];
}

// make negative numbers unsigned
if (ret < 0) {
ret += UINT32_MAX;
}

// 32-bit hex string, padded on the left
ret = '0000000' + ret.toString(16).toUpperCase();
ret = ret.substr(ret.length - 8);

return ret;
}
};


// disable for debugging
// disable for debugging
if (!(['User:Cqm/Scrapbook_4'].indexOf(conf.wgPageName) && conf.debug)) {
if (!(['User:Cqm/Scrapbook_4'].indexOf(conf.wgPageName) && conf.debug)) {
$(self.init);
$(self.init);
}
}



Latest revision as of 12:06, 20 October 2024

/**
 * Adds support for checkbox lists ([[Template:Checklist]])
 *
 * Examples/Tests: <https://rs.wiki/User:Cqm/Scrapbook_4>
 *
 * History:
 * - 1.0: Original implementation - Cqm
 */

/*
 * DATA STORAGE STRUCTURE
 * ----------------------
 *
 * In its raw, uncompressed format, the stored data is as follows:
 * {
 *     hashedPageName1: [
 *         [0, 1, 0, 1, 0, 1],
 *         [1, 0, 1, 0, 1, 0],
 *         [0, 0, 0, 0, 0, 0]
 *     ],
 *     hashedPageName2: [
 *         [0, 1, 0, 1, 0, 1],
 *         [1, 0, 1, 0, 1, 0],
 *         [0, 0, 0, 0, 0, 0]
 *     ]
 * }
 *
 * Where `hashedPageNameX` is the value of wgPageName passed through our `hashString` function,
 * the arrays of numbers representing tables on a page (from top to bottom) and the numbers
 * representing whether a row is highlighted or not, depending on if it is 1 or 0 respectively.
 *
 * During compression, these numbers are collected into groups of 6 and converted to base64.
 * For example:
 *
 *   1. [0, 1, 0, 1, 0, 1]
 *   2. 0x010101             (1 + 4 + 16 = 21)
 *   3. BASE_64_URL[21]      (U)
 *
 * Once each table's rows have been compressed into strings, they are concatenated using `.` as a
 * delimiter. The hashed page name (which is guaranteed to be 8 characters long) is then prepended
 * to this string to look something like the following:
 *
 *   XXXXXXXXab.dc.ef
 *
 *
 * The first character of a hashed page name is then used to form the object that is actually
 * stored. As the hashing function uses hexadecimal, this gives us 16 possible characters (0-9A-Z).
 *
 * {
 *     A: ...
 *     B: ...
 *     C: ...
 *     // etc.
 * }
 *
 * The final step of compression is to merge each page's data together under it's respective top
 * level key. this is done by concatenation again, separated by a `!`.
 *
 * The resulting object is then converted to a string and persisted in local storage. When
 * uncompressing data, simply perform the following steps in reverse.
 *
 * For the implementation of this algorithm, see:
 * - `compress`
 * - `parse`
 * - `hashString`
 *
 * Note that while rows could theoretically be compressed further by using all ASCII characters,
 * eventually we'd start using characters outside printable ASCII which makes debugging painful.
 */

/*jshint bitwise:false, camelcase:true, curly:true, eqeqeq:true, es3:false,
    forin:true, immed:true, indent:4, latedef:true, newcap:true,
    noarg:true, noempty:true, nonew:true, plusplus:true, quotmark:single,
    undef:true, unused:true, strict:true, trailing:true,
    browser:true, devel:false, jquery:true,
    onevar:true
*/

'use strict';

// constants
var STORAGE_KEY = 'rs:checkList',
  LIST_CLASS = 'checklist',
  CHECKED_CLASS = 'checked',
  NO_TOGGLE_PARENT_CLASS = 'no-toggle-parent',
  INDEX_ATTRIBUTE = 'data-checklist-index',
  BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
  PAGE_SEPARATOR = '!',
  LIST_SEPARATOR = '.',
  CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
  UINT32_MAX = 0xffffffff,
  conf = mw.config.get(['debug', 'wgPageName']),
  self = {
    /*
     * Stores the current uncompressed data for the current page.
     */
    data: null,
    /*
     * Perform initial checks on the page and browser.
     */
    init: function init() {
      var $lists = $(['ul.' + LIST_CLASS, 'div.' + LIST_CLASS + ' > ul'].join(', ')),
        hashedPageName = self.hashString(mw.config.get('wgPageName'));

      // check we have some tables to interact with
      if (!$lists.length) {
        return;
      }

      // check the browser supports local storage
      if (!rs.hasLocalStorage()) {
        return;
      }
      self.data = self.load(hashedPageName, $lists.length);
      self.initLists(hashedPageName, $lists);
    },
    /*
     * Initialise table highlighting.
     *
     * @param hashedPageName The current page name as a hash.
     * @param $lists A list of checkbox lists on the current page.
     */
    initLists: function initLists(hashedPageName, $lists) {
      $lists.each(function (listIndex) {
        var $this = $(this),
          toggleParent = !($this.hasClass(NO_TOGGLE_PARENT_CLASS) || $this.parent('div.' + LIST_CLASS).hasClass(NO_TOGGLE_PARENT_CLASS)),
          // list items
          $items = $this.find('li'),
          listData = self.data[listIndex];

        // initialise list items if necessary
        while ($items.length > listData.length) {
          listData.push(0);
        }
        $items.each(function (itemIndex) {
          var $this = $(this),
            itemData = listData[itemIndex];

          // initialize checking based on the cookie
          self.setChecked($this, itemData);

          // give the item a unique index in the list
          $this.attr(INDEX_ATTRIBUTE, itemIndex);

          // set mouse events
          $this.click(function (e) {
            var $this = $(this),
              $parent = $this.parent('ul').parent('li'),
              $childItems = $this.children('ul').children('li'),
              isChecked;

            // don't bubble up to parent lists
            e.stopPropagation();
            function checkChildItems() {
              var $this = $(this),
                index = $this.attr(INDEX_ATTRIBUTE),
                $childItems = $this.children('ul').children('li'),
                childIsChecked = $this.hasClass(CHECKED_CLASS);
              if (isChecked && !childIsChecked || !isChecked && childIsChecked) {
                listData[index] = 1 - listData[index];
                self.setChecked($this, listData[index]);
              }
              if ($childItems.length) {
                $childItems.each(checkChildItems);
              }
            }
            function checkParent($parent) {
              var parentIndex = $parent.attr(INDEX_ATTRIBUTE),
                parentIsChecked = $parent.hasClass(CHECKED_CLASS),
                parentShouldBeChecked = true,
                $myParent = $parent.parent('ul').parent('li');
              $parent.children('ul').children('li').each(function () {
                var $child = $(this),
                  childIsChecked = $child.hasClass(CHECKED_CLASS);
                if (!childIsChecked) {
                  parentShouldBeChecked = false;
                }
              });
              if (parentShouldBeChecked && !parentIsChecked && toggleParent || !parentShouldBeChecked && parentIsChecked) {
                listData[parentIndex] = 1 - listData[parentIndex];
                self.setChecked($parent, listData[parentIndex]);
              }
              if ($myParent.length) {
                checkParent($myParent);
              }
            }

            // don't toggle highlight when clicking links
            if (e.target.tagName !== 'A' && e.target.tagName !== 'IMG') {
              // 1 -> 0
              // 0 -> 1
              listData[itemIndex] = 1 - listData[itemIndex];
              self.setChecked($this, listData[itemIndex]);
              isChecked = $this.hasClass(CHECKED_CLASS);
              if ($childItems.length) {
                $childItems.each(checkChildItems);
              }

              // if the list has a parent
              // check if all the children are checked and uncheck the parent if not
              if ($parent.length) {
                checkParent($parent);
              }
              self.save(hashedPageName);
            }
          });
        });

        // add a button for reset
        var reset = $('<div>').append($('<sup>').append('[').append($('<a>').append('uncheck all')).append(']')).addClass('sl-reset');
        reset.first('sup').click(function () {
          $items.each(function (itemIndex) {
            listData[itemIndex] = 0;
            self.setChecked($(this), 0);
          });
          self.save(hashedPageName, $lists.length);
        });
        $this.append(reset);
      });
    },
    /*
     * Change the list item checkbox based on mouse events.
     *
     * @param $item The list item element.
     * @param val The value to control what class to add (if any).
     *            0 -> unchecked (no class)
     *            1 -> light on
     *            2 -> mouse over
     */
    setChecked: function setChecked($item, val) {
      $item.removeClass(CHECKED_CLASS);
      switch (val) {
        // checked
        case 1:
          $item.addClass(CHECKED_CLASS);
          break;
      }
    },
    /*
     * Merge the updated data for the current page into the data for other pages into local storage.
     *
     * @param hashedPageName A hash of the current page name.
     */
    save: function save(hashedPageName) {
      // load the existing data so we know where to save it
      var curData = localStorage.getItem(STORAGE_KEY),
        compressedData;
      if (curData === null) {
        curData = {};
      } else {
        curData = JSON.parse(curData);
        curData = self.parse(curData);
      }

      // merge in our updated data and compress it
      curData[hashedPageName] = self.data;
      compressedData = self.compress(curData);

      // convert to a string and save to localStorage
      compressedData = JSON.stringify(compressedData);
      localStorage.setItem(STORAGE_KEY, compressedData);
    },
    /*
     * Compress the entire data set using tha algoritm documented at the top of the page.
     *
     * @param data The data to compress.
     *
     * @return the compressed data.
     */
    compress: function compress(data) {
      var ret = {};
      Object.keys(data).forEach(function (hashedPageName) {
        var pageData = data[hashedPageName],
          pageKey = hashedPageName.charAt(0);
        if (!ret.hasOwnProperty(pageKey)) {
          ret[pageKey] = {};
        }
        ret[pageKey][hashedPageName] = [];
        pageData.forEach(function (tableData) {
          var compressedListData = '',
            i,
            j,
            k;
          for (i = 0; i < Math.ceil(tableData.length / 6); i += 1) {
            k = tableData[6 * i];
            for (j = 1; j < 6; j += 1) {
              k = 2 * k + (6 * i + j < tableData.length ? tableData[6 * i + j] : 0);
            }
            compressedListData += BASE_64_URL.charAt(k);
          }
          ret[pageKey][hashedPageName].push(compressedListData);
        });
        ret[pageKey][hashedPageName] = ret[pageKey][hashedPageName].join(LIST_SEPARATOR);
      });
      Object.keys(ret).forEach(function (pageKey) {
        var hashKeys = Object.keys(ret[pageKey]),
          hashedData = [];
        hashKeys.forEach(function (key) {
          var pageData = ret[pageKey][key];
          hashedData.push(key + pageData);
        });
        hashedData = hashedData.join(PAGE_SEPARATOR);
        ret[pageKey] = hashedData;
      });
      return ret;
    },
    /*
     * Get the existing data for the current page.
     *
     * @param hashedPageName A hash of the current page name.
     * @param numLists The number of lists on the current page. Used to ensure the loaded
     *                 data matches the number of lists on the page thus handling cases
     *                 where lists have been added or removed. This does not check the
     *                 amount of items in the given lists.
     *
     * @return The data for the current page.
     */
    load: function load(hashedPageName, numLists) {
      var data = localStorage.getItem(STORAGE_KEY),
        pageData;
      if (data === null) {
        pageData = [];
      } else {
        data = JSON.parse(data);
        data = self.parse(data);
        if (data.hasOwnProperty(hashedPageName)) {
          pageData = data[hashedPageName];
        } else {
          pageData = [];
        }
      }

      // if more lists were added
      // add extra arrays to store the data in
      // also populates if no existing data was found
      while (numLists > pageData.length) {
        pageData.push([]);
      }

      // if lists were removed, remove data from the end of the list
      // as there's no way to tell which was removed
      while (numLists < pageData.length) {
        pageData.pop();
      }
      return pageData;
    },
    /*
     * Parse the compressed data as loaded from local storage using the algorithm desribed
     * at the top of the page.
     *
     * @param data The data to parse.
     *
     * @return the parsed data.
     */
    parse: function parse(data) {
      var ret = {};
      Object.keys(data).forEach(function (pageKey) {
        var pageData = data[pageKey].split(PAGE_SEPARATOR);
        pageData.forEach(function (listData) {
          var hashedPageName = listData.substr(0, 8);
          listData = listData.substr(8).split(LIST_SEPARATOR);
          ret[hashedPageName] = [];
          listData.forEach(function (itemData, index) {
            var i, j, k;
            ret[hashedPageName].push([]);
            for (i = 0; i < itemData.length; i += 1) {
              k = BASE_64_URL.indexOf(itemData.charAt(i));

              // input validation
              if (k < 0) {
                k = 0;
              }
              for (j = 5; j >= 0; j -= 1) {
                ret[hashedPageName][index][6 * i + j] = k & 0x1;
                k >>= 1;
              }
            }
          });
        });
      });
      return ret;
    },
    /*
     * Hash a string into a big endian 32 bit hex string. Used to hash page names.
     *
     * @param input The string to hash.
     *
     * @return the result of the hash.
     */
    hashString: function hashString(input) {
      var ret = 0,
        table = [],
        i,
        j,
        k;

      // guarantee 8-bit chars
      input = window.unescape(window.encodeURI(input));

      // calculate the crc (cyclic redundancy check) for all 8-bit data
      // bit-wise operations discard anything left of bit 31
      for (i = 0; i < 256; i += 1) {
        k = i << 24;
        for (j = 0; j < 8; j += 1) {
          k = k << 1 ^ (k >>> 31) * CASTAGNOLI_POLYNOMIAL;
        }
        table[i] = k;
      }

      // the actual calculation
      for (i = 0; i < input.length; i += 1) {
        ret = ret << 8 ^ table[ret >>> 24 ^ input.charCodeAt(i)];
      }

      // make negative numbers unsigned
      if (ret < 0) {
        ret += UINT32_MAX;
      }

      // 32-bit hex string, padded on the left
      ret = '0000000' + ret.toString(16).toUpperCase();
      ret = ret.substr(ret.length - 8);
      return ret;
    }
  };

// disable for debugging
if (!(['User:Cqm/Scrapbook_4'].indexOf(conf.wgPageName) && conf.debug)) {
  $(self.init);
}

/*
// sample data for testing the algorithm used
var data = {
    // page1
    '0FF47C63': [
        [0, 1, 1, 0, 1, 0],
        [0, 1, 1, 0, 1, 0, 1, 1, 1],
        [0, 0, 0, 0, 1, 1, 0, 0]
    ],
    // page2
    '02B75ABA': [
        [0, 1, 0, 1, 1, 0],
        [1, 1, 1, 0, 1, 0, 1, 1, 0],
        [0, 0, 1, 1, 0, 0, 0, 0]
    ],
    // page3
    '0676470D': [
        [1, 0, 0, 1, 0, 1],
        [1, 0, 0, 1, 0, 1, 0, 0, 0],
        [1, 1, 1, 1, 0, 0, 1, 1]
    ]
};

console.log('input', data);

var compressedData = self.compress(data);
console.log('compressed', compressedData);

var parsedData = self.parse(compressedData);
console.log(parsedData);
*/