MediaWiki:Gadget-checkboxList-core.js
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> */