• Welcome to the Kancolle Wiki!
  • If you have any questions regarding site content, account registration, etc., please visit the KanColle Wiki Discord

Difference between revisions of "User:Lrb/common.js"

From Kancolle Wiki
Jump to navigation Jump to search
Line 3: Line 3:
 
  *
 
  *
 
  * Description:
 
  * Description:
  * Adds row highlighting to tables
+
  * Adds highlighting to tables
 
  *
 
  *
  * Version 1.0: Row highlighting                        - Quarenon
+
  * History:
  * Version 1.1: Update from pengLocations.js v1.0      - Quarenon
+
* - 1.0: Row highlighting                        - Quarenon
  * Version 2.0: pengLocations v2.1, Granular cookie    - Saftzie
+
  * - 1.1: Update from pengLocations.js v1.0      - Quarenon
  * Version 2.1: Made compatible with jquery.tablesorter - Cqm
+
  * - 2.0: pengLocations v2.1, Granular cookie    - Saftzie
 +
  * - 2.1: Made compatible with jquery.tablesorter - Cqm
 +
* - 2.2: Switch to localStorage                  - Cqm
 +
* - 3.0: Allow cell highlighting                - mejrs
 +
*
 +
* @todo Allow the stored data to be coupled to the table in question. Currently the data is stored
 +
*      on the page itself, so if any tables are shuffled, the highlighting doesn't follow. For
 +
*      the same reason tables hosted on other pages are not synchronized.
 +
*/
 +
 
 +
/**
 +
* 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.
 
  */
 
  */
  
;(function ($, mw) {
+
/*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
 +
*/
  
 +
(function($, mw, OO, rs) {
 
     'use strict';
 
     'use strict';
  
     function highlightTable() {
+
     // constants
 +
    var STORAGE_KEY = 'rs:lightTable',
 +
        TABLE_CLASS = 'lighttable',
 +
        LIGHT_ON_CLASS = 'highlight-on',
 +
        MOUSE_OVER_CLASS = 'highlight-over',
 +
        BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
 +
        PAGE_SEPARATOR = '!',
 +
        TABLE_SEPARATOR = '.',
 +
        CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
 +
        UINT32_MAX = 0xffffffff,
 +
 
 +
        self = {
 +
            /*
 +
            * Stores the current uncompressed data for the current page.
 +
            */
 +
            data: null,
 +
 
 +
            /*
 +
            * Perform initial checks on the page and browser.
 +
            */
 +
            init: function() {
 +
                var $tables = $('table.' + TABLE_CLASS),
 +
                    hashedPageName = self.hashString(mw.config.get('wgPageName'));
 +
 
 +
                // check we have some tables to interact with
 +
                if (!$tables.length) {
 +
                    return;
 +
                }
 +
                // check the browser supports local storage
 +
                if (!rs.hasLocalStorage()) {
 +
                    return;
 +
                }
 +
 
 +
                self.data = self.load(hashedPageName, $tables.length);
 +
                self.initTables(hashedPageName, $tables);
 +
            },
  
        // requires CSS classes named in lightOnClass and mouseOverClass
+
            /*
        var wgPageName = mw.config.get('wgPageName'),
+
            * Initialise table highlighting.
            cookiePrefix = 'lightTable',
+
            *
            tableClass = 'lighttable',
+
            * @param hashedPageName The current page name as a hash.
            lightOnClass = 'highlight-on',
+
            * @param $tables A list of highlightable tables on the current page.
            mouseOverClass = 'highlight-over',
+
            */
            base64url = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
+
            initTables: function(hashedPageName, $tables) {
            pageSeparator = '!',
+
                $tables.each(function(tIndex) {
            tableSeparator = '.',
+
                    var $this = $(this),
            hashPageName,
+
                        // data cells
            rows = [],
+
                        $cells = $this.find('td'),
            columns,
+
                        $rows = $this.find('tr:has(td)'),
            tables,
+
                        // don't rely on headers to find number of columns     
            cookie;
+
                        // count them dynamically
 +
                        columns = 1,
 +
                        tableData = self.data[tIndex],
 +
                        mode = 'cells';
  
        // hash a string into a 32-bit hex string, msb first
+
                    // Switching between either highlighting rows or cells
        function crc32c(s) {
+
                    if (!$this.hasClass('individual')) {
            var polynomial = 0x04C11DB7, // Castagnoli polynomial
+
                        mode = 'rows';
                retVal,
+
                        $cells = $rows;
                table = [],
+
                    }
                i,
 
                j,
 
                k;
 
  
            // guarantee 8-bit chars
+
                    // initialise rows if necessary
            s = window.unescape(window.encodeURI(s));
+
                    while ($cells.length > tableData.length) {
 +
                        tableData.push(0);
 +
                    }
  
            // calculate the crc for all 8-bit data
+
                    // counting the column count
            // bit-wise operations discard anything left of bit 31
+
                    // necessary to determine colspan of reset button
            for (i = 0; i < 256; i += 1) {
+
                    $rows.each(function() {
                k = (i << 24);
+
                        var $this = $(this);
                for (j = 0; j < 8; j += 1) {
+
                        columns = Math.max(columns, $this.children('th,td').length);
                     k = (k << 1) ^ ((k >>> 31) * polynomial);
+
                    });
 +
 
 +
                    $cells.each(function(cIndex) {
 +
                        var $this = $(this),
 +
                            cellData = tableData[cIndex];
 +
 
 +
                        // forbid highlighting any cells/rows that have class nohighlight
 +
                        if (!$this.hasClass('nohighlight')) {
 +
                            // initialize highlighting based on the cookie
 +
                            self.setHighlight($this, cellData);
 +
 
 +
                            // set mouse events
 +
                            $this
 +
                                .mouseover(function() {
 +
                                    self.setHighlight($this, 2);
 +
                                })
 +
                                .mouseout(function() {
 +
                                    self.setHighlight($this, tableData[cIndex]);
 +
                                })
 +
                                .click(function(e) {
 +
                                    // don't toggle highlight when clicking links
 +
                                    if ((e.target.tagName !== 'A') && (e.target.tagName !== 'IMG')) {
 +
                                        // 1 -> 0
 +
                                        // 0 -> 1
 +
                                        tableData[cIndex] = 1 - tableData[cIndex];
 +
 
 +
                                        self.setHighlight($this, tableData[cIndex]);
 +
                                        self.save(hashedPageName);
 +
                                    }
 +
                                });
 +
                        }
 +
                    });
 +
 
 +
                    // add a button for reset
 +
                    var button = new OO.ui.ButtonWidget({
 +
                        label: (mode === 'rows') ?
 +
                      'Clear highlighted rows' :
 +
                            'Clear highlighted cells',
 +
                        icon: 'clear',
 +
                        title: 'Removes all highlights from the table',
 +
                        classes: ['ht-reset'] // this class is targeted by other gadgets, be careful removing it
 +
                    });
 +
 
 +
 
 +
                    button.$element.click(function() {
 +
                        $cells.each(function(cIndex) {
 +
                            tableData[cIndex] = 0;
 +
                            self.setHighlight($(this), 0);
 +
                        });
 +
 
 +
                        self.save(hashedPageName, $tables.length);
 +
                    });
 +
 
 +
                     $this.append(
 +
                        $('<tfoot>')
 +
                            .append(
 +
                                $('<tr>')
 +
                                    .append(
 +
                                        $('<th>')
 +
                                            .attr('colspan', columns)
 +
                                            .append(button.$element)
 +
                                    )
 +
                            )
 +
                    );
 +
                });
 +
            },
 +
 
 +
            /*
 +
            * Change the cell background color based on mouse events.
 +
            *
 +
            * @param $cell The cell element.
 +
            * @param val The value to control what class to add (if any).
 +
            *            0 -> light off (no class)
 +
            *            1 -> light on
 +
            *            2 -> mouse over
 +
            */
 +
            setHighlight: function($cell, val) {
 +
                $cell.removeClass(MOUSE_OVER_CLASS);
 +
                $cell.removeClass(LIGHT_ON_CLASS);
 +
 
 +
                switch (val) {
 +
                    // light on
 +
                    case 1:
 +
                        $cell.addClass(LIGHT_ON_CLASS);
 +
                        break;
 +
 
 +
                    // mouse-over
 +
                    case 2:
 +
                        $cell.addClass(MOUSE_OVER_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);
 
                 }
 
                 }
                table[i] = k;
 
            }
 
  
            // the actual calculation
+
                // merge in our updated data and compress it
            retVal = 0;
+
                curData[hashedPageName] = self.data;
            for (i = 0; i < s.length; i += 1) {
+
                compressedData = self.compress(curData);
                 retVal = (retVal << 8) ^ table[(retVal >>> 24) ^ s.charCodeAt(i)];
+
 
            }
+
                // 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] = {};
 +
                    }
  
            // make negative numbers unsigned
+
                    ret[pageKey][hashedPageName] = [];
            if (retVal < 0) {
 
                retVal += 4294967296;
 
            }
 
            // 32-bit hex string, padded on the left
 
            retVal = '0000000' + retVal.toString(16).toUpperCase();
 
            retVal = retVal.substr(retVal.length - 8);
 
  
            return retVal;
+
                    pageData.forEach(function(tableData) {
        }
+
                        var compressedTableData = '',
 +
                            i, j, k;
  
        // change the row bg color based on mouse events
+
                        for (i = 0; i < Math.ceil(tableData.length / 6); i += 1) {
        function setHighlight(el, val) {
+
                            k = tableData[6 * i];
            $(el).removeClass(mouseOverClass).removeClass(lightOnClass);
 
            switch (val) {
 
            case 1:  // light on
 
                $(el).addClass(lightOnClass);
 
                break;
 
            case 2:  // mouse-over
 
                $(el).addClass(mouseOverClass);
 
                break;
 
            default: // same as case 0, light off
 
            }
 
        }
 
  
        // load the cookie and parse it for the page
+
                            for (j = 1; j < 6; j += 1) {
        // cookie info is saved in 1 of 16 browser cookies, based on page name hash
+
                                k = 2 * k + ((6 * i + j < tableData.length) ? tableData[6 * i + j] : 0);
        // global cookie[][] supports multiple tables and multiple pages
+
                            }
        // uses global hashPageName
 
        function loadCookie(numTables) {
 
            var cookieName = cookiePrefix + '-' + hashPageName.charAt(0),
 
                pageCookies,
 
                tableCookies,
 
                iPage,
 
                iTable,
 
                i,
 
                j,
 
                k;
 
  
            cookie = [];
+
                            compressedTableData += BASE_64_URL.charAt(k);
            if ($.cookie(cookieName) !== null) {
 
                pageCookies = $.cookie(cookieName).split(pageSeparator);
 
                for (iPage = 0; iPage < pageCookies.length; iPage += 1) {
 
                    if (hashPageName === pageCookies[iPage].substr(0, 8)) {
 
                        tableCookies = pageCookies[iPage].substr(8).split(tableSeparator);
 
                        // trim the cookie array of arrays, if needed
 
                        while (tableCookies.length > numTables) {
 
                            tableCookies.pop();
 
 
                         }
 
                         }
                         // extract the row info per table
+
 
                        // use Base64url to compress 6 rows to 1 character
+
                        ret[pageKey][hashedPageName].push(compressedTableData);
                         for (iTable = 0; iTable < tableCookies.length; iTable += 1) {
+
                    });
                             cookie[iTable] = [];
+
 
                             for (i = 0; i < tableCookies[iTable].length; i += 1) {
+
                    ret[pageKey][hashedPageName] = ret[pageKey][hashedPageName].join(TABLE_SEPARATOR);
                                 k = base64url.indexOf(tableCookies[iTable].charAt(i));
+
                });
                                 if (k < 0) { // input validation
+
 
 +
                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 numTables The number of tables on the current page. Used to ensure the loaded
 +
            *                  data matches the number of tables on the page thus handling cases
 +
            *                  where tables have been added or removed. This does not check the
 +
            *                  amount of rows in the given tables.
 +
            *
 +
            * @return The data for the current page.
 +
            */
 +
            load: function(hashedPageName, numTables) {
 +
                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 tables were added
 +
                // add extra arrays to store the data in
 +
                // also populates if no existing data was found
 +
                while (numTables > pageData.length) {
 +
                    pageData.push([]);
 +
                }
 +
 
 +
                // if tables were removed, remove data from the end of the list
 +
                // as there's no way to tell which was removed
 +
                while (numTables < 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(tableData) {
 +
                         var hashedPageName = tableData.substr(0, 8);
 +
 
 +
                        tableData = tableData.substr(8).split(TABLE_SEPARATOR);
 +
                        ret[hashedPageName] = [];
 +
 
 +
                        tableData.forEach(function(rowData, index) {
 +
                             var i, j, k;
 +
 
 +
                            ret[hashedPageName].push([]);
 +
 
 +
                             for (i = 0; i < rowData.length; i += 1) {
 +
                                 k = BASE_64_URL.indexOf(rowData.charAt(i));
 +
 
 +
                                // input validation
 +
                                 if (k < 0) {
 
                                     k = 0;
 
                                     k = 0;
 
                                 }
 
                                 }
 +
 
                                 for (j = 5; j >= 0; j -= 1) {
 
                                 for (j = 5; j >= 0; j -= 1) {
                                     cookie[iTable][6 * i + j] = (k & 0x1);
+
                                     ret[hashedPageName][index][6 * i + j] = (k & 0x1);
 
                                     k >>= 1;
 
                                     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;
  
            // initialize the cookie array of arrays, if needed
+
                // guarantee 8-bit chars
            while (cookie.length < numTables) {
+
                input = window.unescape(window.encodeURI(input));
                cookie.push([]);
 
            }
 
        }
 
  
        // save/update the cookie for page reloads
+
                // calculate the crc (cyclic redundancy check) for all 8-bit data
        // cookie info is saved in 1 of 16 browser cookies, based on page name hash
+
                // bit-wise operations discard anything left of bit 31
        // global cookie[][] supports multiple tables and multiple pages
+
                for (i = 0; i < 256; i += 1) {
        // uses global hashPageName
+
                    k = (i << 24);
        function saveCookie() {
 
            var cookieName = cookiePrefix + '-' + hashPageName.charAt(0),
 
                pageCookies,
 
                tableCookies,
 
                iPage,
 
                iTable,
 
                i,
 
                j,
 
                k,
 
                updated;
 
  
            // create the cookie for the tables on the current page
+
                     for (j = 0; j < 8; j += 1) {
            // use Base64url to compress 6 rows to 1 character
+
                        k = (k << 1) ^ ((k >>> 31) * CASTAGNOLI_POLYNOMIAL);
            tableCookies = hashPageName;
 
            for (iTable = 0; iTable < cookie.length; iTable += 1) {
 
                if (iTable > 0) {
 
                     tableCookies += tableSeparator;
 
                }
 
                for (i = 0; i < Math.ceil(cookie[iTable].length / 6); i += 1) {
 
                    k = cookie[iTable][6 * i];
 
                    for (j = 1; j < 6; j += 1) {
 
                        k = 2 * k + ((6 * i + j < cookie[iTable].length) ? cookie[iTable][6 * i + j] : 0);
 
 
                     }
 
                     }
                     tableCookies += base64url.charAt(k);
+
                     table[i] = k;
 
                 }
 
                 }
            }
 
  
            updated = 0;
+
                 // the actual calculation
            pageCookies = [];
+
                 for (i = 0; i < input.length; i += 1) {
            if ($.cookie(cookieName) !== null) {
+
                     ret = (ret << 8) ^ table[(ret >>> 24) ^ input.charCodeAt(i)];
                 // get all the page cookies
 
                // another page might have updated them since this page
 
                pageCookies = $.cookie(cookieName).split(pageSeparator);
 
                // update the page cookie if it already exists
 
                 for (iPage = 0; iPage < pageCookies.length; iPage += 1) {
 
                     if (hashPageName === pageCookies[iPage].substr(0, 8)) {
 
                        updated = 1;
 
                        pageCookies[iPage] = tableCookies;
 
                    }
 
 
                 }
 
                 }
            }
 
            // add the page cookie if it doesn't exist yet
 
            if (updated === 0) {
 
                pageCookies.push(tableCookies);
 
            }
 
  
            // set path to / so it works for /wiki/, /index.php, etc
+
                // make negative numbers unsigned
            $.cookie(cookieName, pageCookies.join(pageSeparator), {
+
                if (ret < 0) {
                expires: 7,
+
                    ret += UINT32_MAX;
                 path: '/'
+
                 }
            });
 
        }
 
  
        tables = $('table.' + tableClass);
+
                // 32-bit hex string, padded on the left
        // don't bother doing anything unless there's really something to do
+
                ret = '0000000' + ret.toString(16).toUpperCase();
        if (tables.length > 0) {
+
                ret = ret.substr(ret.length - 8);
            // hash the page name to an 8-char hex string
 
            hashPageName = crc32c(wgPageName);
 
            loadCookie(tables.length);
 
  
             tables.each(function (iTable) {
+
                return ret;
                rows[iTable] = $(this).find('tr:has(td)'); // data rows
+
             }
 +
        };
  
                // init or trim the cookie array of rows, if needed
+
    $(self.init);
                while (cookie[iTable].length < rows[iTable].length) {
 
                    cookie[iTable].push(0);
 
                }
 
                while (cookie[iTable].length > rows[iTable].length) {
 
                    cookie[iTable].pop();
 
                }
 
  
                // don't rely on headers to find # of columns
+
    /*
                // count them dynamically
+
    // sample data for testing the algorithm used
                columns = 1;
+
    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]
 +
        ]
 +
    };
  
                rows[iTable].each(function (iRow) {
+
    console.log('input', data);
                    // update column count as we go
 
                    // a smarter approach would count colspans, but this is good for now
 
                    columns = Math.max(columns, $(this).children('th,td').length);
 
  
                    // initialize highlighting based on the cookie
+
    var compressedData = self.compress(data);
                    setHighlight(this, cookie[iTable][iRow]);
+
    console.log('compressed', compressedData);
 
 
                    // set mouse events
 
                    $(this).mouseover(function () {
 
                        setHighlight(this, 2);
 
                    }).mouseout(function () {
 
                        setHighlight(this, cookie[iTable][iRow]);
 
                    }).click(function (e) {
 
                        // don't toggle highlight when clicking links
 
                        if ((e.target.tagName !== 'A') && (e.target.tagName !== 'IMG')) {
 
                            cookie[iTable][iRow] = 1 - cookie[iTable][iRow];
 
                            setHighlight(this, cookie[iTable][iRow]);
 
                            saveCookie();
 
                        }
 
                    });
 
                });
 
 
 
                // add a button for reset
 
                $(this).append(
 
                    $('<tfoot/>')
 
                        .append(
 
                            $('<tr/>')
 
                                .append(
 
                                    $('<th/>')
 
                                        .attr('colspan', columns)
 
                                        .append(
 
                                            $('<input>')
 
                                                .attr({
 
                                                    'type': 'button',
 
                                                    'value': 'Reset'
 
                                                })
 
                                                .click(function () {
 
                                                    rows[iTable].each(function (iRow) {
 
                                                        cookie[iTable][iRow] = 0;
 
                                                        setHighlight(this, 0);
 
                                                    });
 
                                                    saveCookie();
 
                                                })
 
                                        )
 
                                )
 
                        )
 
                    );
 
            });
 
        }
 
    }
 
  
     $(highlightTable);
+
     var parsedData = self.parse(compressedData);
 +
    console.log(parsedData);
 +
    */
  
}(this.jQuery, this.mediaWiki));
+
}(this.jQuery, this.mediaWiki, this.OO, this.rswiki));
  
/* </pre> */
+
// </pre>

Revision as of 00:07, 8 January 2021

/** <pre>
 * highlightTable.js
 *
 * Description:
 * Adds highlighting to tables
 *
 * History:
 * - 1.0: Row highlighting                        - Quarenon
 * - 1.1: Update from pengLocations.js v1.0       - Quarenon
 * - 2.0: pengLocations v2.1, Granular cookie     - Saftzie
 * - 2.1: Made compatible with jquery.tablesorter - Cqm
 * - 2.2: Switch to localStorage                  - Cqm
 * - 3.0: Allow cell highlighting                 - mejrs
 *
 * @todo Allow the stored data to be coupled to the table in question. Currently the data is stored
 *       on the page itself, so if any tables are shuffled, the highlighting doesn't follow. For
 *       the same reason tables hosted on other pages are not synchronized.
 */

/**
 * 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
*/

(function($, mw, OO, rs) {
    'use strict';

    // constants
    var STORAGE_KEY = 'rs:lightTable',
        TABLE_CLASS = 'lighttable',
        LIGHT_ON_CLASS = 'highlight-on',
        MOUSE_OVER_CLASS = 'highlight-over',
        BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
        PAGE_SEPARATOR = '!',
        TABLE_SEPARATOR = '.',
        CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
        UINT32_MAX = 0xffffffff,

        self = {
            /*
             * Stores the current uncompressed data for the current page.
             */
            data: null,

            /*
             * Perform initial checks on the page and browser.
             */
            init: function() {
                var $tables = $('table.' + TABLE_CLASS),
                    hashedPageName = self.hashString(mw.config.get('wgPageName'));

                // check we have some tables to interact with
                if (!$tables.length) {
                    return;
                }
                // check the browser supports local storage
                if (!rs.hasLocalStorage()) {
                    return;
                }

                self.data = self.load(hashedPageName, $tables.length);
                self.initTables(hashedPageName, $tables);
            },

            /*
             * Initialise table highlighting.
             *
             * @param hashedPageName The current page name as a hash.
             * @param $tables A list of highlightable tables on the current page.
             */
            initTables: function(hashedPageName, $tables) {
                $tables.each(function(tIndex) {
                    var $this = $(this),
                        // data cells
                        $cells = $this.find('td'),
                        $rows = $this.find('tr:has(td)'),
                        // don't rely on headers to find number of columns      
                        // count them dynamically
                        columns = 1,
                        tableData = self.data[tIndex],
                        mode = 'cells';

                    // Switching between either highlighting rows or cells
                    if (!$this.hasClass('individual')) {
                        mode = 'rows';
                        $cells = $rows;
                    }

                    // initialise rows if necessary
                    while ($cells.length > tableData.length) {
                        tableData.push(0);
                    }

                    // counting the column count
                    // necessary to determine colspan of reset button
                    $rows.each(function() {
                        var $this = $(this);
                        columns = Math.max(columns, $this.children('th,td').length);
                    });

                    $cells.each(function(cIndex) {
                        var $this = $(this),
                            cellData = tableData[cIndex];

                        // forbid highlighting any cells/rows that have class nohighlight
                        if (!$this.hasClass('nohighlight')) {
                            // initialize highlighting based on the cookie
                            self.setHighlight($this, cellData);

                            // set mouse events
                            $this
                                .mouseover(function() {
                                    self.setHighlight($this, 2);
                                })
                                .mouseout(function() {
                                    self.setHighlight($this, tableData[cIndex]);
                                })
                                .click(function(e) {
                                    // don't toggle highlight when clicking links
                                    if ((e.target.tagName !== 'A') && (e.target.tagName !== 'IMG')) {
                                        // 1 -> 0
                                        // 0 -> 1
                                        tableData[cIndex] = 1 - tableData[cIndex];

                                        self.setHighlight($this, tableData[cIndex]);
                                        self.save(hashedPageName);
                                    }
                                });
                        }
                    });

                    // add a button for reset
                    var button = new OO.ui.ButtonWidget({
                        label: (mode === 'rows') ?
                      'Clear highlighted rows' :
                            'Clear highlighted cells',
                        icon: 'clear',
                        title: 'Removes all highlights from the table',
                        classes: ['ht-reset'] // this class is targeted by other gadgets, be careful removing it
                    });


                    button.$element.click(function() {
                        $cells.each(function(cIndex) {
                            tableData[cIndex] = 0;
                            self.setHighlight($(this), 0);
                        });

                        self.save(hashedPageName, $tables.length);
                    });

                    $this.append(
                        $('<tfoot>')
                            .append(
                                $('<tr>')
                                    .append(
                                        $('<th>')
                                            .attr('colspan', columns)
                                            .append(button.$element)
                                    )
                            )
                    );
                });
            },

            /*
             * Change the cell background color based on mouse events.
             *
             * @param $cell The cell element.
             * @param val The value to control what class to add (if any).
             *            0 -> light off (no class)
             *            1 -> light on
             *            2 -> mouse over
             */
            setHighlight: function($cell, val) {
                $cell.removeClass(MOUSE_OVER_CLASS);
                $cell.removeClass(LIGHT_ON_CLASS);

                switch (val) {
                    // light on
                    case 1:
                        $cell.addClass(LIGHT_ON_CLASS);
                        break;

                    // mouse-over
                    case 2:
                        $cell.addClass(MOUSE_OVER_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 compressedTableData = '',
                            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);
                            }

                            compressedTableData += BASE_64_URL.charAt(k);
                        }

                        ret[pageKey][hashedPageName].push(compressedTableData);
                    });

                    ret[pageKey][hashedPageName] = ret[pageKey][hashedPageName].join(TABLE_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 numTables The number of tables on the current page. Used to ensure the loaded
             *                  data matches the number of tables on the page thus handling cases
             *                  where tables have been added or removed. This does not check the
             *                  amount of rows in the given tables.
             *
             * @return The data for the current page.
             */
            load: function(hashedPageName, numTables) {
                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 tables were added
                // add extra arrays to store the data in
                // also populates if no existing data was found
                while (numTables > pageData.length) {
                    pageData.push([]);
                }

                // if tables were removed, remove data from the end of the list
                // as there's no way to tell which was removed
                while (numTables < 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(tableData) {
                        var hashedPageName = tableData.substr(0, 8);

                        tableData = tableData.substr(8).split(TABLE_SEPARATOR);
                        ret[hashedPageName] = [];

                        tableData.forEach(function(rowData, index) {
                            var i, j, k;

                            ret[hashedPageName].push([]);

                            for (i = 0; i < rowData.length; i += 1) {
                                k = BASE_64_URL.indexOf(rowData.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;
            }
        };

    $(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);
    */

}(this.jQuery, this.mediaWiki, this.OO, this.rswiki));

// </pre>