MediaWiki:Gadget-highlightTable-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 * highlightTable.js
3 *
4 * Description:
5 * Adds highlighting to tables
6 *
7 * History:
8 * - 1.0: Row highlighting - Quarenon
9 * - 1.1: Update from pengLocations.js v1.0 - Quarenon
10 * - 2.0: pengLocations v2.1, Granular cookie - Saftzie
11 * - 2.1: Made compatible with jquery.tablesorter - Cqm
12 * - 2.2: Switch to localStorage - Cqm
13 * - 3.0: Allow cell highlighting - mejrs
14 * - 4.0: Labelled highlighting, not page-specific - Joeytje50
15 *
16 * @todo Allow the stored data to be coupled to the table in question. Currently the data is stored
17 * on the page itself, so if any tables are shuffled, the highlighting doesn't follow. For
18 * the same reason tables hosted on other pages are not synchronized.
19 */
20
21 /**
22 * DATA STORAGE STRUCTURE
23 * ----------------------
24 *
25 * In its raw, uncompressed format, the stored data is as follows:
26 * {
27 * hashedPageName1: [
28 * [0, 1, 0, 1, 0, 1],
29 * [1, 0, 1, 0, 1, 0],
30 * [0, 0, 0, 0, 0, 0]
31 * ],
32 * hashedPageName2: [
33 * [0, 1, 0, 1, 0, 1],
34 * [1, 0, 1, 0, 1, 0],
35 * [0, 0, 0, 0, 0, 0]
36 * ]
37 * }
38 *
39 * Where `hashedPageNameX` is the value of wgPageName passed through our `hashString` function,
40 * the arrays of numbers representing tables on a page (from top to bottom) and the numbers
41 * representing whether a row is highlighted or not, depending on if it is 1 or 0 respectively.
42 *
43 * During compression, these numbers are collected into groups of 6 and converted to base64.
44 * For example:
45 *
46 * 1. [0, 1, 0, 1, 0, 1]
47 * 2. 0x010101 (1 + 4 + 16 = 21)
48 * 3. BASE_64_URL[21] (U)
49 *
50 * Once each table's rows have been compressed into strings, they are concatenated using `.` as a
51 * delimiter. The hashed page name (which is guaranteed to be 8 characters long) is then prepended
52 * to this string to look something like the following:
53 *
54 * XXXXXXXXab.dc.ef
55 *
56 *
57 * The first character of a hashed page name is then used to form the object that is actually
58 * stored. As the hashing function uses hexadecimal, this gives us 16 possible characters (0-9A-Z).
59 *
60 * {
61 * A: ...
62 * B: ...
63 * C: ...
64 * // etc.
65 * }
66 *
67 * The final step of compression is to merge each page's data together under it's respective top
68 * level key. this is done by concatenation again, separated by a `!`.
69 *
70 * The resulting object is then converted to a string and persisted in local storage. When
71 * uncompressing data, simply perform the following steps in reverse.
72 *
73 * For the implementation of this algorithm, see:
74 * - `compress`
75 * - `parse`
76 * - `hashString`
77 *
78 * Note that while rows could theoretically be compressed further by using all ASCII characters,
79 * eventually we'd start using characters outside printable ASCII which makes debugging painful.
80 */
81
82 /*jshint bitwise:false, camelcase:true, curly:true, eqeqeq:true, es3:false,
83 forin:true, immed:true, indent:4, latedef:true, newcap:true,
84 noarg:true, noempty:true, nonew:true, plusplus:true, quotmark:single,
85 undef:true, unused:true, strict:true, trailing:true,
86 browser:true, devel:false, jquery:true,
87 onevar:true
88 */
89
90 (function($, mw, OO, rs) {
91 'use strict';
92
93 // constants
94 var STORAGE_KEY = 'rs:lightTable',
95 TABLE_CLASS = 'lighttable',
96 TBLID = 'tableid',
97 ROWID = 'rowid',
98 LIGHT_ON_CLASS = 'highlight-on',
99 MOUSE_OVER_CLASS = 'highlight-over',
100 BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
101 PAGE_SEPARATOR = '!',
102 TABLE_SEPARATOR = '.',
103 CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
104 UINT32_MAX = 0xffffffff,
105
106 self = {
107 /*
108 * Stores the current uncompressed data for the current page.
109 */
110 data: null,
111
112 /*
113 * Perform initial checks on the page and browser.
114 */
115 init: function() {
116 var $tables = $('table.' + TABLE_CLASS),
117 hashedPageName = self.hashString(mw.config.get('wgPageName'));
118
119 // check we have some tables to interact with
120 if (!$tables.length) {
121 return;
122 }
123 // check the browser supports local storage
124 if (!rs.hasLocalStorage()) {
125 return;
126 }
127
128 self.data = self.load(hashedPageName, $tables.length);
129 self.initTables(hashedPageName, $tables);
130 },
131
132 /*
133 * Initialise table highlighting.
134 *
135 * @param hashedPageName The current page name as a hash.
136 * @param $tables A list of highlightable tables on the current page.
137 */
138 initTables: function(hashedPageName, $tables) {
139 $tables.each(function(tIndex) {
140 var $this = $(this),
141 $table = $this,
142 tblid = $this.data(TBLID),
143 // data cells
144 $cells = $this.find('td'),
145 $rows = $this.find('tr:has(td)'),
146 // don't rely on headers to find number of columns
147 // count them dynamically
148 columns = 1,
149 tableData = self.data[tIndex],
150 mode = 'cells',
151 initialised = false;
152
153 if (tblid) {
154 initialised = self.initNamed(tblid);
155 }
156
157 // Switching between either highlighting rows or cells
158 if (!$this.hasClass('individual')) {
159 mode = 'rows';
160 $cells = $rows;
161 }
162
163 // initialise rows if necessary
164 while ($cells.length > tableData.length) {
165 tableData.push(0);
166 }
167
168 // counting the column count
169 // necessary to determine colspan of reset button
170 $rows.each(function() {
171 var $this = $(this);
172 columns = Math.max(columns, $this.children('th,td').length);
173 });
174
175 $cells.each(function(cIndex) {
176 var $this = $(this),
177 cellData = tableData[cIndex];
178
179 // forbid highlighting any cells/rows that have class nohighlight
180 if (!$this.hasClass('nohighlight')) {
181 // initialize highlighting based on localStorage, unless namedInit already did that
182 if (!initialised) {
183 self.setHighlight($this, cellData);
184 }
185
186 // set mouse events
187 $this
188 .mouseover(function() {
189 self.setHighlight($this, 2);
190 })
191 .mouseout(function() {
192 self.setHighlight($this, 3);
193 })
194 .click(function(e) {
195 // don't toggle highlight when clicking links
196 if ((e.target.tagName !== 'A') && (e.target.tagName !== 'IMG')) {
197 // 1 -> 0
198 // 0 -> 1
199 tableData[cIndex] = 1 - tableData[cIndex];
200
201 self.setHighlight($this, tableData[cIndex]);
202
203 if (tblid) {
204 self.saveNamed($table.data(TBLID));
205 } else {
206 self.save(hashedPageName);
207 }
208
209 e.stopPropagation();
210 }
211 });
212 }
213 });
214
215 // if this is a named table, which wasn't initialised yet, make sure to save data to the named system
216 if (tblid && !initialised) {
217 self.saveNamed($table.data(TBLID));
218 }
219
220 // add a button for reset
221 var button = new OO.ui.ButtonWidget({
222 label: 'Clear selection',
223 icon: 'clear',
224 title: 'Removes all highlights from the table',
225 classes: ['ht-reset'] // this class is targeted by other gadgets, be careful removing it
226 });
227
228 button.$element.click(function() {
229 $cells.each(function(cIndex) {
230 tableData[cIndex] = 0;
231 self.setHighlight($(this), 0);
232 });
233
234 if (tblid) {
235 self.saveNamed($table.data(TBLID));
236 } else {
237 self.save(hashedPageName, $tables.length);
238 }
239 });
240
241 $this.append(
242 $('<tfoot>')
243 .append(
244 $('<tr>')
245 .append(
246 $('<th>')
247 .attr('colspan', columns)
248 .append(button.$element)
249 )
250 )
251 );
252 });
253 },
254
255 /*
256 * Change the cell background color based on mouse events.
257 *
258 * @param $cell The cell element.
259 * @param val The value to control what class to add (if any).
260 * 0 -> light off (no class)
261 * 1 -> light on without hover
262 * 2 -> mouse over
263 */
264 setHighlight: function($cell, val) {
265 switch (val) {
266 // no highlighting
267 case 0:
268 $cell.removeClass(MOUSE_OVER_CLASS);
269 $cell.removeClass(LIGHT_ON_CLASS);
270 break;
271
272 // light on
273 case 1:
274 $cell.removeClass(MOUSE_OVER_CLASS);
275 $cell.addClass(LIGHT_ON_CLASS);
276 break;
277
278 // mouse-over
279 case 2:
280 $cell.addClass(MOUSE_OVER_CLASS);
281 break;
282
283 // mouse-out without affecting highlights
284 case 3:
285 $cell.removeClass(MOUSE_OVER_CLASS);
286 break;
287 }
288 },
289
290 /*
291 * Merge the updated data for the current page into the data for other pages into local storage.
292 *
293 * @param hashedPageName A hash of the current page name.
294 */
295 save: function(hashedPageName) {
296 // load the existing data so we know where to save it
297 var curData = localStorage.getItem(STORAGE_KEY),
298 compressedData;
299
300 if (curData === null) {
301 curData = {};
302 } else {
303 curData = JSON.parse(curData);
304 curData = self.parse(curData);
305 }
306
307 // merge in our updated data and compress it
308 curData[hashedPageName] = self.data;
309 compressedData = self.compress(curData);
310
311 // convert to a string and save to localStorage
312 compressedData = JSON.stringify(compressedData);
313 localStorage.setItem(STORAGE_KEY, compressedData);
314 },
315
316 /*
317 * Compress the entire data set using tha algoritm documented at the top of the page.
318 *
319 * @param data The data to compress.
320 *
321 * @return the compressed data.
322 */
323 compress: function(data) {
324 var ret = {};
325
326 Object.keys(data).forEach(function(hashedPageName) {
327 var pageData = data[hashedPageName],
328 pageKey = hashedPageName.charAt(0);
329
330 if (!ret.hasOwnProperty(pageKey)) {
331 ret[pageKey] = {};
332 }
333
334 ret[pageKey][hashedPageName] = [];
335
336 pageData.forEach(function(tableData) {
337 var compressedTableData = '',
338 i, j, k;
339
340 for (i = 0; i < Math.ceil(tableData.length / 6); i += 1) {
341 k = tableData[6 * i];
342
343 for (j = 1; j < 6; j += 1) {
344 k = 2 * k + ((6 * i + j < tableData.length) ? tableData[6 * i + j] : 0);
345 }
346
347 compressedTableData += BASE_64_URL.charAt(k);
348 }
349
350 ret[pageKey][hashedPageName].push(compressedTableData);
351 });
352
353 ret[pageKey][hashedPageName] = ret[pageKey][hashedPageName].join(TABLE_SEPARATOR);
354 });
355
356 Object.keys(ret).forEach(function(pageKey) {
357 var hashKeys = Object.keys(ret[pageKey]),
358 hashedData = [];
359
360 hashKeys.forEach(function(key) {
361 var pageData = ret[pageKey][key];
362 hashedData.push(key + pageData);
363 });
364
365 hashedData = hashedData.join(PAGE_SEPARATOR);
366 ret[pageKey] = hashedData;
367 });
368
369 return ret;
370 },
371
372 /*
373 * Get the existing data for the current page.
374 *
375 * @param hashedPageName A hash of the current page name.
376 * @param numTables The number of tables on the current page. Used to ensure the loaded
377 * data matches the number of tables on the page thus handling cases
378 * where tables have been added or removed. This does not check the
379 * amount of rows in the given tables.
380 *
381 * @return The data for the current page.
382 */
383 load: function(hashedPageName, numTables) {
384 var data = localStorage.getItem(STORAGE_KEY),
385 pageData;
386
387 if (data === null) {
388 pageData = [];
389 } else {
390 data = JSON.parse(data);
391 data = self.parse(data);
392
393 if (data.hasOwnProperty(hashedPageName)) {
394 pageData = data[hashedPageName];
395 } else {
396 pageData = [];
397 }
398 }
399
400 // if more tables were added
401 // add extra arrays to store the data in
402 // also populates if no existing data was found
403 while (numTables > pageData.length) {
404 pageData.push([]);
405 }
406
407 // if tables were removed, remove data from the end of the list
408 // as there's no way to tell which was removed
409 while (numTables < pageData.length) {
410 pageData.pop();
411 }
412
413 return pageData;
414 },
415
416 /*
417 * Parse the compressed data as loaded from local storage using the algorithm desribed
418 * at the top of the page.
419 *
420 * @param data The data to parse.
421 *
422 * @return the parsed data.
423 */
424 parse: function(data) {
425 var ret = {};
426
427 Object.keys(data).forEach(function(pageKey) {
428 var pageData = data[pageKey].split(PAGE_SEPARATOR);
429
430 pageData.forEach(function(tableData) {
431 var hashedPageName = tableData.substr(0, 8);
432
433 tableData = tableData.substr(8).split(TABLE_SEPARATOR);
434 ret[hashedPageName] = [];
435
436 tableData.forEach(function(rowData, index) {
437 var i, j, k;
438
439 ret[hashedPageName].push([]);
440
441 for (i = 0; i < rowData.length; i += 1) {
442 k = BASE_64_URL.indexOf(rowData.charAt(i));
443
444 // input validation
445 if (k < 0) {
446 k = 0;
447 }
448
449 for (j = 5; j >= 0; j -= 1) {
450 ret[hashedPageName][index][6 * i + j] = (k & 0x1);
451 k >>= 1;
452 }
453 }
454 });
455 });
456
457 });
458
459 return ret;
460 },
461
462 /*
463 * Hash a string into a big endian 32 bit hex string. Used to hash page names.
464 *
465 * @param input The string to hash.
466 *
467 * @return the result of the hash.
468 */
469 hashString: function(input) {
470 var ret = 0,
471 table = [],
472 i, j, k;
473
474 // guarantee 8-bit chars
475 input = window.unescape(window.encodeURI(input));
476
477 // calculate the crc (cyclic redundancy check) for all 8-bit data
478 // bit-wise operations discard anything left of bit 31
479 for (i = 0; i < 256; i += 1) {
480 k = (i << 24);
481
482 for (j = 0; j < 8; j += 1) {
483 k = (k << 1) ^ ((k >>> 31) * CASTAGNOLI_POLYNOMIAL);
484 }
485 table[i] = k;
486 }
487
488 // the actual calculation
489 for (i = 0; i < input.length; i += 1) {
490 ret = (ret << 8) ^ table[(ret >>> 24) ^ input.charCodeAt(i)];
491 }
492
493 // make negative numbers unsigned
494 if (ret < 0) {
495 ret += UINT32_MAX;
496 }
497
498 // 32-bit hex string, padded on the left
499 ret = '0000000' + ret.toString(16).toUpperCase();
500 ret = ret.substr(ret.length - 8);
501
502 return ret;
503 },
504
505 /*
506 * Save highlighted rows for named tables, using the data-tableid attribute.
507 * Does not override values that are not present in the current table. This allows usethe use of a single
508 * table ID on pages like [[Music]]
509 *
510 * @param tblid The table id for the table to initialise
511 */
512 saveNamed: function(tblid) {
513 // local storage key is prefixed by the generic storage key, to avoid local storage naming conflicts.
514 var lsKey = STORAGE_KEY + ':' + tblid,
515 data = localStorage.getItem(lsKey);
516 var $tbls = $('table.lighttable[data-tableid="'+tblid+'"]')
517
518 if (data === null) {
519 data = {};
520 } else {
521 data = JSON.parse(data);
522 }
523
524 $tbls.find('[data-rowid]').each(function() {
525 var id = $(this).data('rowid');
526 if (!id) return;
527 data[id] = Number($(this).hasClass(LIGHT_ON_CLASS));
528 });
529
530 localStorage.setItem(lsKey, JSON.stringify(data));
531 },
532
533 /*
534 * Initialise a named table that uses data-tableid
535 *
536 * @param tblid The table id for the table to initialise
537 * @return Boolean true if successfully initialised, false if no named highlight data was available
538 */
539 initNamed: function(tblid) {
540 var lsKey = STORAGE_KEY + ':' + tblid;
541 var data = localStorage.getItem(lsKey);
542 var $tbls = $('table.lighttable[data-tableid="'+tblid+'"]')
543 if (data === null) {
544 // no data stored yet, so fall back to unnamed init
545 return false;
546 }
547 var data = JSON.parse(data);
548
549 $tbls.find('[data-rowid]').each(function() {
550 var id = $(this).data('rowid')
551 if (!id) return;
552 if ($('[data-rowid="'+id+'"]').length > 1) {
553 mw.log.warn('Reused rowid detected in named lighttable:', id, $('[data-rowid="'+id+'"]'));
554 }
555 self.setHighlight($(this), Number(data[id]))
556 });
557 return true;
558 }
559 };
560
561 $(self.init);
562
563 /*
564 // sample data for testing the algorithm used
565 var data = {
566 // page1
567 '0FF47C63': [
568 [0, 1, 1, 0, 1, 0],
569 [0, 1, 1, 0, 1, 0, 1, 1, 1],
570 [0, 0, 0, 0, 1, 1, 0, 0]
571 ],
572 // page2
573 '02B75ABA': [
574 [0, 1, 0, 1, 1, 0],
575 [1, 1, 1, 0, 1, 0, 1, 1, 0],
576 [0, 0, 1, 1, 0, 0, 0, 0]
577 ],
578 // page3
579 '0676470D': [
580 [1, 0, 0, 1, 0, 1],
581 [1, 0, 0, 1, 0, 1, 0, 0, 0],
582 [1, 1, 1, 1, 0, 0, 1, 1]
583 ]
584 };
585
586 console.log('input', data);
587
588 var compressedData = self.compress(data);
589 console.log('compressed', compressedData);
590
591 var parsedData = self.parse(compressedData);
592 console.log(parsedData);
593 */
594
595 }(this.jQuery, this.mediaWiki, this.OO, this.rswiki));
596
597 // </pre>