MediaWiki:Gadget-checkboxList-core.js: Difference between revisions
Jump to navigation
Jump to search
Content added Content deleted
No edit summary Tag: Reverted |
No edit summary Tag: Reverted |
||
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 mw.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:12, 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'; // 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);*/