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>