MediaWiki:Gadget-checkboxList-core.js

/** * Adds support for checkbox lists (Template:Checklist) * * 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 = 'rts: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 (!rts.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 = $(' ').append(               	$(' ').append( $('').append(               			"[uncheck all]"            			) )               ).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); /* */