MediaWiki:Gadget-checkboxList-core.js

From Old School Near-Reality Wiki
Jump to navigation Jump to search

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
  1 /** <pre>
  2  * Adds support for checkbox lists ([[Template:Checklist]])
  3  *
  4  * Examples/Tests: <https://rs.wiki/User:Cqm/Scrapbook_4>
  5  *
  6  * History:
  7  * - 1.0: Original implementation - Cqm
  8  */
  9 
 10 /*
 11  * DATA STORAGE STRUCTURE
 12  * ----------------------
 13  *
 14  * In its raw, uncompressed format, the stored data is as follows:
 15  * {
 16  *     hashedPageName1: [
 17  *         [0, 1, 0, 1, 0, 1],
 18  *         [1, 0, 1, 0, 1, 0],
 19  *         [0, 0, 0, 0, 0, 0]
 20  *     ],
 21  *     hashedPageName2: [
 22  *         [0, 1, 0, 1, 0, 1],
 23  *         [1, 0, 1, 0, 1, 0],
 24  *         [0, 0, 0, 0, 0, 0]
 25  *     ]
 26  * }
 27  *
 28  * Where `hashedPageNameX` is the value of wgPageName passed through our `hashString` function,
 29  * the arrays of numbers representing tables on a page (from top to bottom) and the numbers
 30  * representing whether a row is highlighted or not, depending on if it is 1 or 0 respectively.
 31  *
 32  * During compression, these numbers are collected into groups of 6 and converted to base64.
 33  * For example:
 34  *
 35  *   1. [0, 1, 0, 1, 0, 1]
 36  *   2. 0x010101             (1 + 4 + 16 = 21)
 37  *   3. BASE_64_URL[21]      (U)
 38  *
 39  * Once each table's rows have been compressed into strings, they are concatenated using `.` as a
 40  * delimiter. The hashed page name (which is guaranteed to be 8 characters long) is then prepended
 41  * to this string to look something like the following:
 42  *
 43  *   XXXXXXXXab.dc.ef
 44  *
 45  *
 46  * The first character of a hashed page name is then used to form the object that is actually
 47  * stored. As the hashing function uses hexadecimal, this gives us 16 possible characters (0-9A-Z).
 48  *
 49  * {
 50  *     A: ...
 51  *     B: ...
 52  *     C: ...
 53  *     // etc.
 54  * }
 55  *
 56  * The final step of compression is to merge each page's data together under it's respective top
 57  * level key. this is done by concatenation again, separated by a `!`.
 58  *
 59  * The resulting object is then converted to a string and persisted in local storage. When
 60  * uncompressing data, simply perform the following steps in reverse.
 61  *
 62  * For the implementation of this algorithm, see:
 63  * - `compress`
 64  * - `parse`
 65  * - `hashString`
 66  *
 67  * Note that while rows could theoretically be compressed further by using all ASCII characters,
 68  * eventually we'd start using characters outside printable ASCII which makes debugging painful.
 69  */
 70 
 71 /*jshint bitwise:false, camelcase:true, curly:true, eqeqeq:true, es3:false,
 72     forin:true, immed:true, indent:4, latedef:true, newcap:true,
 73     noarg:true, noempty:true, nonew:true, plusplus:true, quotmark:single,
 74     undef:true, unused:true, strict:true, trailing:true,
 75     browser:true, devel:false, jquery:true,
 76     onevar:true
 77 */
 78 
 79 'use strict';
 80 
 81     // constants
 82 var STORAGE_KEY = 'rs:checkList',
 83     LIST_CLASS = 'checklist',
 84     CHECKED_CLASS = 'checked',
 85     NO_TOGGLE_PARENT_CLASS = 'no-toggle-parent',
 86     INDEX_ATTRIBUTE = 'data-checklist-index',
 87     BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
 88     PAGE_SEPARATOR = '!',
 89     LIST_SEPARATOR = '.',
 90     CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
 91     UINT32_MAX = 0xffffffff,
 92 
 93     conf = mw.config.get([
 94         'debug',
 95         'wgPageName'
 96     ]),
 97 
 98 
 99     self = {
100         /*
101          * Stores the current uncompressed data for the current page.
102          */
103         data: null,
104 
105         /*
106          * Perform initial checks on the page and browser.
107          */
108         init: function () {
109             var $lists = $(['ul.' + LIST_CLASS,
110                             'div.' + LIST_CLASS + ' > ul'].join(', ')),
111                 hashedPageName = self.hashString(mw.config.get('wgPageName'));
112 
113             // check we have some tables to interact with
114             if (!$lists.length) {
115                 return;
116             }
117 
118             // check the browser supports local storage
119             if (!rs.hasLocalStorage()) {
120                 return;
121             }
122 
123             self.data = self.load(hashedPageName, $lists.length);
124             self.initLists(hashedPageName, $lists);
125         },
126 
127         /*
128          * Initialise table highlighting.
129          *
130          * @param hashedPageName The current page name as a hash.
131          * @param $lists A list of checkbox lists on the current page.
132          */
133         initLists: function (hashedPageName, $lists) {
134             $lists.each(function (listIndex) {
135                 var $this = $(this),
136                     toggleParent = !(
137                         $this.hasClass(NO_TOGGLE_PARENT_CLASS) ||
138                         $this.parent('div.' + LIST_CLASS).hasClass(NO_TOGGLE_PARENT_CLASS)
139                     ),
140                     // list items
141                     $items = $this.find('li'),
142                     listData = self.data[listIndex];
143 
144                 // initialise list items if necessary
145                 while ($items.length > listData.length) {
146                     listData.push(0);
147                 }
148 
149                 $items.each(function (itemIndex) {
150                     var $this = $(this),
151                         itemData = listData[itemIndex];
152 
153                     // initialize checking based on the cookie
154                     self.setChecked($this, itemData);
155 
156                     // give the item a unique index in the list
157                     $this.attr(INDEX_ATTRIBUTE, itemIndex);
158 
159                     // set mouse events
160                     $this
161                         .click(function (e) {
162                             var $this = $(this),
163                                 $parent = $this.parent('ul').parent('li'),
164                                 $childItems = $this.children('ul').children('li'),
165                                 isChecked;
166 
167                             // don't bubble up to parent lists
168                             e.stopPropagation();
169 
170                             function checkChildItems() {
171                                 var $this = $(this),
172                                     index = $this.attr(INDEX_ATTRIBUTE),
173                                     $childItems = $this.children('ul').children('li'),
174                                     childIsChecked = $this.hasClass(CHECKED_CLASS);
175 
176                                 if (
177                                     (isChecked && !childIsChecked) ||
178                                     (!isChecked && childIsChecked)
179                                 ) {
180                                     listData[index] = 1 - listData[index];
181                                     self.setChecked($this, listData[index]);
182                                 }
183 
184                                 if ($childItems.length) {
185                                     $childItems.each(checkChildItems);
186                                 }
187                             }
188 
189                             function checkParent($parent) {
190                                 var parentIndex = $parent.attr(INDEX_ATTRIBUTE),
191                                     parentIsChecked = $parent.hasClass(CHECKED_CLASS),
192                                     parentShouldBeChecked = true,
193                                     $myParent = $parent.parent('ul').parent('li');
194 
195                                 $parent.children('ul').children('li').each(function () {
196                                     var $child = $(this),
197                                         childIsChecked = $child.hasClass(CHECKED_CLASS);
198 
199                                     if (!childIsChecked) {
200                                         parentShouldBeChecked = false;
201                                     }
202                                 });
203 
204                                 if (
205                                     (parentShouldBeChecked && !parentIsChecked && toggleParent) ||
206                                     (!parentShouldBeChecked && parentIsChecked)
207                                 ) {
208                                     listData[parentIndex] = 1 - listData[parentIndex];
209                                     self.setChecked($parent, listData[parentIndex]);
210                                 }
211 
212                                 if ($myParent.length) {
213                                     checkParent($myParent);
214                                 }
215                             }
216 
217                             // don't toggle highlight when clicking links
218                             if ((e.target.tagName !== 'A') && (e.target.tagName !== 'IMG')) {
219                                 // 1 -> 0
220                                 // 0 -> 1
221                                 listData[itemIndex] = 1 - listData[itemIndex];
222 
223                                 self.setChecked($this, listData[itemIndex]);
224                                 isChecked = $this.hasClass(CHECKED_CLASS);
225 
226                                 if ($childItems.length) {
227                                     $childItems.each(checkChildItems);
228                                 }
229 
230                                 // if the list has a parent
231                                 // check if all the children are checked and uncheck the parent if not
232                                 if ($parent.length) {
233                                     checkParent($parent);
234                                 }
235 
236                                 self.save(hashedPageName);
237                             }
238                         });
239                 });
240                 
241                 // add a button for reset
242                 var reset = $('<div>').append(
243                 	$('<sup>').append(
244                 		$('<a>').append(
245                 			"[uncheck all]"
246             			)
247             		)
248                 ).addClass('sl-reset');
249 
250                 reset.first('sup').click(function () {
251                     $items.each(function (itemIndex) {
252                         listData[itemIndex] = 0;
253                         self.setChecked($(this), 0);
254                     });
255 
256                     self.save(hashedPageName, $lists.length);
257                 });
258                 
259                 $this.append(reset);
260             });
261         },
262 
263         /*
264          * Change the list item checkbox based on mouse events.
265          *
266          * @param $item The list item element.
267          * @param val The value to control what class to add (if any).
268          *            0 -> unchecked (no class)
269          *            1 -> light on
270          *            2 -> mouse over
271          */
272         setChecked: function ($item, val) {
273             $item.removeClass(CHECKED_CLASS);
274 
275             switch (val) {
276                 // checked
277                 case 1:
278                     $item.addClass(CHECKED_CLASS);
279                     break;
280             }
281         },
282 
283         /*
284          * Merge the updated data for the current page into the data for other pages into local storage.
285          *
286          * @param hashedPageName A hash of the current page name.
287          */
288         save: function (hashedPageName) {
289                 // load the existing data so we know where to save it
290             var curData = localStorage.getItem(STORAGE_KEY),
291                 compressedData;
292 
293             if (curData === null) {
294                 curData = {};
295             } else {
296                 curData = JSON.parse(curData);
297                 curData = self.parse(curData);
298             }
299 
300             // merge in our updated data and compress it
301             curData[hashedPageName] = self.data;
302             compressedData = self.compress(curData);
303 
304             // convert to a string and save to localStorage
305             compressedData = JSON.stringify(compressedData);
306             localStorage.setItem(STORAGE_KEY, compressedData);
307         },
308 
309         /*
310          * Compress the entire data set using tha algoritm documented at the top of the page.
311          *
312          * @param data The data to compress.
313          *
314          * @return the compressed data.
315          */
316         compress: function (data) {
317             var ret = {};
318             
319             Object.keys(data).forEach(function (hashedPageName) {
320                 var pageData = data[hashedPageName],
321                     pageKey = hashedPageName.charAt(0);
322 
323                 if (!ret.hasOwnProperty(pageKey)) {
324                     ret[pageKey] = {};
325                 }
326 
327                 ret[pageKey][hashedPageName] = [];
328 
329                 pageData.forEach(function (tableData) {
330                     var compressedListData = '',
331                         i, j, k;
332 
333                     for (i = 0; i < Math.ceil(tableData.length / 6); i += 1) {
334                         k = tableData[6 * i];
335 
336                         for (j = 1; j < 6; j += 1) {
337                             k = 2 * k + ((6 * i + j < tableData.length) ? tableData[6 * i + j] : 0);
338                         }
339 
340                         compressedListData += BASE_64_URL.charAt(k);
341                     }
342 
343                     ret[pageKey][hashedPageName].push(compressedListData);
344                 });
345 
346                 ret[pageKey][hashedPageName] = ret[pageKey][hashedPageName].join(LIST_SEPARATOR);
347             });
348 
349             Object.keys(ret).forEach(function (pageKey) {
350                 var hashKeys = Object.keys(ret[pageKey]),
351                     hashedData = [];
352 
353                 hashKeys.forEach(function (key) {
354                     var pageData = ret[pageKey][key];
355                     hashedData.push(key + pageData);
356                 });
357 
358                 hashedData = hashedData.join(PAGE_SEPARATOR);
359                 ret[pageKey] = hashedData;
360             });
361 
362             return ret;
363         },
364 
365         /*
366          * Get the existing data for the current page.
367          *
368          * @param hashedPageName A hash of the current page name.
369          * @param numLists The number of lists on the current page. Used to ensure the loaded
370          *                 data matches the number of lists on the page thus handling cases
371          *                 where lists have been added or removed. This does not check the
372          *                 amount of items in the given lists.
373          *
374          * @return The data for the current page.
375          */
376         load: function (hashedPageName, numLists) {
377             var data = localStorage.getItem(STORAGE_KEY),
378                 pageData;
379 
380             if (data === null) {
381                 pageData = [];
382             } else {
383                 data = JSON.parse(data);
384                 data = self.parse(data);
385 
386                 if (data.hasOwnProperty(hashedPageName)) {
387                     pageData = data[hashedPageName];
388                 } else {
389                     pageData = [];
390                 }
391             }
392 
393             // if more lists were added
394             // add extra arrays to store the data in
395             // also populates if no existing data was found
396             while (numLists > pageData.length) {
397                 pageData.push([]);
398             }
399 
400             // if lists were removed, remove data from the end of the list
401             // as there's no way to tell which was removed
402             while (numLists < pageData.length) {
403                 pageData.pop();
404             }
405 
406             return pageData;
407         },
408 
409         /*
410          * Parse the compressed data as loaded from local storage using the algorithm desribed
411          * at the top of the page.
412          *
413          * @param data The data to parse.
414          *
415          * @return the parsed data.
416          */
417         parse: function (data) {
418             var ret = {};
419 
420             Object.keys(data).forEach(function (pageKey) {
421                 var pageData = data[pageKey].split(PAGE_SEPARATOR);
422 
423                 pageData.forEach(function (listData) {
424                     var hashedPageName = listData.substr(0, 8);
425 
426                     listData = listData.substr(8).split(LIST_SEPARATOR);
427                     ret[hashedPageName] = [];
428 
429                     listData.forEach(function (itemData, index) {
430                         var i, j, k;
431 
432                         ret[hashedPageName].push([]);
433 
434                         for (i = 0; i < itemData.length; i += 1) {
435                             k = BASE_64_URL.indexOf(itemData.charAt(i));
436 
437                             // input validation
438                             if (k < 0) {
439                                 k = 0;
440                             }
441 
442                             for (j = 5; j >= 0; j -= 1) {
443                                 ret[hashedPageName][index][6 * i + j] = (k & 0x1);
444                                 k >>= 1;
445                             }
446                         }
447                     });
448                 });
449 
450             });
451 
452             return ret;
453         },
454 
455         /*
456          * Hash a string into a big endian 32 bit hex string. Used to hash page names.
457          *
458          * @param input The string to hash.
459          *
460          * @return the result of the hash.
461          */
462         hashString: function (input) {
463             var ret = 0,
464                 table = [],
465                 i, j, k;
466 
467             // guarantee 8-bit chars
468             input = window.unescape(window.encodeURI(input));
469 
470             // calculate the crc (cyclic redundancy check) for all 8-bit data
471             // bit-wise operations discard anything left of bit 31
472             for (i = 0; i < 256; i += 1) {
473                 k = (i << 24);
474 
475                 for (j = 0; j < 8; j += 1) {
476                     k = (k << 1) ^ ((k >>> 31) * CASTAGNOLI_POLYNOMIAL);
477                 }
478                 table[i] = k;
479             }
480 
481             // the actual calculation
482             for (i = 0; i < input.length; i += 1) {
483                 ret = (ret << 8) ^ table[(ret >>> 24) ^ input.charCodeAt(i)];
484             }
485 
486             // make negative numbers unsigned
487             if (ret < 0) {
488                 ret += UINT32_MAX;
489             }
490 
491             // 32-bit hex string, padded on the left
492             ret = '0000000' + ret.toString(16).toUpperCase();
493             ret = ret.substr(ret.length - 8);
494 
495             return ret;
496         }
497     };
498 
499 // disable for debugging
500 if (!(['User:Cqm/Scrapbook_4'].indexOf(conf.wgPageName) && conf.debug)) {
501     $(self.init);
502 }
503 
504 /*
505 // sample data for testing the algorithm used
506 var data = {
507     // page1
508     '0FF47C63': [
509         [0, 1, 1, 0, 1, 0],
510         [0, 1, 1, 0, 1, 0, 1, 1, 1],
511         [0, 0, 0, 0, 1, 1, 0, 0]
512     ],
513     // page2
514     '02B75ABA': [
515         [0, 1, 0, 1, 1, 0],
516         [1, 1, 1, 0, 1, 0, 1, 1, 0],
517         [0, 0, 1, 1, 0, 0, 0, 0]
518     ],
519     // page3
520     '0676470D': [
521         [1, 0, 0, 1, 0, 1],
522         [1, 0, 0, 1, 0, 1, 0, 0, 0],
523         [1, 1, 1, 1, 0, 0, 1, 1]
524     ]
525 };
526 
527 console.log('input', data);
528 
529 var compressedData = self.compress(data);
530 console.log('compressed', compressedData);
531 
532 var parsedData = self.parse(compressedData);
533 console.log(parsedData);
534 */
535 /* </pre> */