MediaWiki:Gadget-checkboxList-core.js

Z Kiwiki
Skočit na navigaci Skočit na vyhledávání

Poznámka: Aby sa zmeny prejavili, po uložení musíte vymazať vyrovnávaciu pamäť vášho prehliadača.

  • Mozilla Firefox / Safari: Držte stlačený Shift a kliknite na Reload alebo stlačte buď Ctrl-F5 alebo Ctrl-R (⌘-R na Mac)
  • Google Chrome: Stlačte Ctrl-Shift-R (⌘-Shift-R na Mac)
  • Internet Explorer: Držte Ctrl a kliknite na Refresh alebo stlačte Ctrl-F5
  • Opera: Prejdite do Menu → Settings (Opera → Preferences on a Mac) a potom do Privacy & security → Clear browsing data → Cached images and files.
/** <pre>
 * Adds support for checkbox lists ([[Template:Checklist]])
 *
 * Examples/Tests: <https://rs.wiki/User:Cqm/Scrapbook_4>
 *
 * History:
 * - 1.0: Original implementation - Cqm
 */

/*
 * 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
*/
    'use strict';

        // constants
    var STORAGE_KEY = 'rs:checkList',
        LIST_CLASS = 'checklist',
        CHECKED_CLASS = 'checked',
        NO_TOGGLE_PARENT_CLASS = 'no-toggle-parent',
        INDEX_ATTRIBUTE = 'data-checklist-index',
        BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
        PAGE_SEPARATOR = '!',
        LIST_SEPARATOR = '.',
        CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
        UINT32_MAX = 0xffffffff,

        conf = mw.config.get([
            'debug',
            'wgPageName'
        ]),


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

            /*
             * Perform initial checks on the page and browser.
             */
            init: function () {
                var $lists = $(['ul.' + LIST_CLASS,
                                'div.' + LIST_CLASS + ' > ul'].join(', ')),
                    hashedPageName = self.hashString(mw.config.get('wgPageName'));

                // check we have some tables to interact with
                if (!$lists.length) {
                    return;
                }

                // check the browser supports local storage
                if (!rs.hasLocalStorage()) {
                    return;
                }

                self.data = self.load(hashedPageName, $lists.length);
                self.initLists(hashedPageName, $lists);
            },

            /*
             * Initialise table highlighting.
             *
             * @param hashedPageName The current page name as a hash.
             * @param $lists A list of checkbox lists on the current page.
             */
            initLists: function (hashedPageName, $lists) {
                $lists.each(function (listIndex) {
                    var $this = $(this),
                        toggleParent = !(
                            $this.hasClass(NO_TOGGLE_PARENT_CLASS) ||
                            $this.parent('div.' + LIST_CLASS).hasClass(NO_TOGGLE_PARENT_CLASS)
                        ),
                        // list items
                        $items = $this.find('li'),
                        listData = self.data[listIndex];

                    // initialise list items if necessary
                    while ($items.length > listData.length) {
                        listData.push(0);
                    }

                    $items.each(function (itemIndex) {
                        var $this = $(this),
                            itemData = listData[itemIndex];

                        // initialize checking based on the cookie
                        self.setChecked($this, itemData);

                        // give the item a unique index in the list
                        $this.attr(INDEX_ATTRIBUTE, itemIndex);

                        // set mouse events
                        $this
                            .click(function (e) {
                                var $this = $(this),
                                    $parent = $this.parent('ul').parent('li'),
                                    $childItems = $this.children('ul').children('li'),
                                    isChecked;

                                // don't bubble up to parent lists
                                e.stopPropagation();

                                function checkChildItems() {
                                    var $this = $(this),
                                        index = $this.attr(INDEX_ATTRIBUTE),
                                        $childItems = $this.children('ul').children('li'),
                                        childIsChecked = $this.hasClass(CHECKED_CLASS);

                                    if (
                                        (isChecked && !childIsChecked) ||
                                        (!isChecked && childIsChecked)
                                    ) {
                                        listData[index] = 1 - listData[index];
                                        self.setChecked($this, listData[index]);
                                    }

                                    if ($childItems.length) {
                                        $childItems.each(checkChildItems);
                                    }
                                }

                                function checkParent($parent) {
                                    var parentIndex = $parent.attr(INDEX_ATTRIBUTE),
                                        parentIsChecked = $parent.hasClass(CHECKED_CLASS),
                                        parentShouldBeChecked = true,
                                        $myParent = $parent.parent('ul').parent('li');

                                    $parent.children('ul').children('li').each(function () {
                                        var $child = $(this),
                                            childIsChecked = $child.hasClass(CHECKED_CLASS);

                                        if (!childIsChecked) {
                                            parentShouldBeChecked = false;
                                        }
                                    });

                                    if (
                                        (parentShouldBeChecked && !parentIsChecked && toggleParent) ||
                                        (!parentShouldBeChecked && parentIsChecked)
                                    ) {
                                        listData[parentIndex] = 1 - listData[parentIndex];
                                        self.setChecked($parent, listData[parentIndex]);
                                    }

                                    if ($myParent.length) {
                                        checkParent($myParent);
                                    }
                                }

                                // don't toggle highlight when clicking links
                                if ((e.target.tagName !== 'A') && (e.target.tagName !== 'IMG')) {
                                    // 1 -> 0
                                    // 0 -> 1
                                    listData[itemIndex] = 1 - listData[itemIndex];

                                    self.setChecked($this, listData[itemIndex]);
                                    isChecked = $this.hasClass(CHECKED_CLASS);

                                    if ($childItems.length) {
                                        $childItems.each(checkChildItems);
                                    }

                                    // if the list has a parent
                                    // check if all the children are checked and uncheck the parent if not
                                    if ($parent.length) {
                                        checkParent($parent);
                                    }

                                    self.save(hashedPageName);
                                }
                            });
                    });

                    // TODO: figure out where to put this
                    /*
                    // add a button for reset
                    var button = new OO.ui.ButtonWidget( {
                        label: 'Reset',
                        icon: 'cancel',
                        title: 'Resets all checkboxes in the list'
                    });


                    button.$element.click(function () {
                        $items.each(function (itemIndex) {
                            listData[itemIndex] = 0;
                            self.setChecked($(this), 0);
                        });

                        self.save(hashedPageName, $lists.length);
                    });
                    */
                });
            },

            /*
             * Change the list item checkbox based on mouse events.
             *
             * @param $item The list item element.
             * @param val The value to control what class to add (if any).
             *            0 -> unchecked (no class)
             *            1 -> light on
             *            2 -> mouse over
             */
            setChecked: function ($item, val) {
                $item.removeClass(CHECKED_CLASS);

                switch (val) {
                    // checked
                    case 1:
                        $item.addClass(CHECKED_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 compressedListData = '',
                            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);
                            }

                            compressedListData += BASE_64_URL.charAt(k);
                        }

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

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

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

                        listData = listData.substr(8).split(LIST_SEPARATOR);
                        ret[hashedPageName] = [];

                        listData.forEach(function (itemData, index) {
                            var i, j, k;

                            ret[hashedPageName].push([]);

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

    // disable for debugging
    if (!(['User:Cqm/Scrapbook_4'].indexOf(conf.wgPageName) && conf.debug)) {
        $(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);
    */
/* </pre> */