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

From RuneRealm Wiki
Jump to navigation Jump to search
Content added Content deleted
No edit summary
Tag: Reverted
No edit summary
Tag: Manual revert
Line 1: Line 1:
/**
/** * 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'; // constantsvar 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 () { 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 (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 ($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 (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 (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 (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 (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 (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 debuggingif (!(['User:Cqm/Scrapbook_4'].indexOf(conf.wgPageName) && conf.debug)) { $(self.init);}/*// sample data for testing the algorithm usedvar 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);*/
* 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 () {
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 (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 ($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 (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 (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 (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 (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 (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);
*/

Revision as of 17:14, 17 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 () {
            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 (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 ($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 (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 (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 (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 (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 (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);
*/