MediaWiki:Gadget-highlightTable-core.js: Difference between revisions

From RuneRealm Wiki
Jump to navigation Jump to search
Content added Content deleted
No edit summary
Tag: Manual revert
No edit summary
 
Line 1: Line 1:
"use strict";

/**
/**
* highlightTable.js
* highlightTable.js
Line 89: Line 91:
*/
*/


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


// constants
// constants
var STORAGE_KEY = 'rs:lightTable',
var STORAGE_KEY = 'rs:lightTable',
TABLE_CLASS = 'lighttable',
TABLE_CLASS = 'lighttable',
TBLID = 'tableid',
TBLID = 'tableid',
ROWID = 'rowid',
ROWID = 'rowid',
LIGHT_ON_CLASS = 'highlight-on',
LIGHT_ON_CLASS = 'highlight-on',
MOUSE_OVER_CLASS = 'highlight-over',
MOUSE_OVER_CLASS = 'highlight-over',
BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
BASE_64_URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
PAGE_SEPARATOR = '!',
PAGE_SEPARATOR = '!',
TABLE_SEPARATOR = '.',
TABLE_SEPARATOR = '.',
CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
CASTAGNOLI_POLYNOMIAL = 0x04c11db7,
UINT32_MAX = 0xffffffff,
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 init() {
var $tables = $('table.' + TABLE_CLASS),
hashedPageName = self.hashString(mw.config.get('wgPageName'));


// check we have some tables to interact with
self = {
/*
if (!$tables.length) {
return;
* Stores the current uncompressed data for the current page.
*/
}
data: null,
// 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 initTables(hashedPageName, $tables) {
$tables.each(function (tIndex) {
var $this = $(this),
$table = $this,
tblid = $this.data(TBLID),
// 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',
initialised = false;
if (tblid) {
initialised = self.initNamed(tblid);
tableData = self.data[tblid] || {};
// initialise rows if necessary
var row_ids = $this.find('[data-rowid]').map(function () {
return $(this).data(ROWID);
});
row_ids.each(function (index, id) {
if (!tableData.hasOwnProperty(id)) {
tableData[id] = 0;
}
});
}


// Switching between either highlighting rows or cells
/*
if (!$this.hasClass('individual')) {
* Perform initial checks on the page and browser.
*/
mode = 'rows';
init: function() {
$cells = $rows;
}
var $tables = $('table.' + TABLE_CLASS),
hashedPageName = self.hashString(mw.config.get('wgPageName'));


// check we have some tables to interact with
// initialise rows if necessary
if (!$tables.length) {
if (!tblid) {
return;
while ($cells.length > tableData.length) {
}
tableData.push(0);
// check the browser supports local storage
}
if (!rs.hasLocalStorage()) {
}
return;
}


// counting the column count
self.data = self.load(hashedPageName, $tables.length);
// necessary to determine colspan of reset button
self.initTables(hashedPageName, $tables);
},
$rows.each(function () {
var $this = $(this);
columns = Math.max(columns, $this.children('th,td').length);
});
$cells.each(function (cIndex) {
var $this = $(this);
if (tblid) cIndex = $this.data(ROWID);
var cellData = tableData[cIndex];


// forbid highlighting any cells/rows that have class nohighlight
/*
if (!$this.hasClass('nohighlight')) {
* Initialise table highlighting.
// initialize highlighting based on localStorage, unless namedInit already did that
*
* @param hashedPageName The current page name as a hash.
if (!initialised) {
self.setHighlight($this, cellData);
* @param $tables A list of highlightable tables on the current page.
*/
}
initTables: function(hashedPageName, $tables) {
$tables.each(function(tIndex) {
var $this = $(this),
$table = $this,
tblid = $this.data(TBLID),
// 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',
initialised = false;
if (tblid) {
initialised = self.initNamed(tblid);
tableData = self.data[tblid] || {};
// initialise rows if necessary
var row_ids = $this.find('[data-rowid]').map(function(){
return $(this).data(ROWID);
});
row_ids.each(function(index, id) {
if (!tableData.hasOwnProperty(id)) {
tableData[id] = 0;
}
});
}


// Switching between either highlighting rows or cells
// set mouse events
if (!$this.hasClass('individual')) {
$this.mouseover(function () {
mode = 'rows';
self.setHighlight($this, 2);
$cells = $rows;
}).mouseout(function () {
}
self.setHighlight($this, 3);
}).click(function (e) {

// don't toggle highlight when clicking links
// initialise rows if necessary
if (e.target.tagName !== 'A' && e.target.tagName !== 'IMG') {
if (!tblid) {
// 1 -> 0
while ($cells.length > tableData.length) {
// 0 -> 1
tableData.push(0);
tableData[cIndex] = 1 - tableData[cIndex];
}
self.setHighlight($this, tableData[cIndex]);
}
if (tblid) {

// counting the column count
self.saveNamed($table.data(TBLID));
// necessary to determine colspan of reset button
} else {
$rows.each(function() {
self.save(hashedPageName);
var $this = $(this);
}
columns = Math.max(columns, $this.children('th,td').length);
e.stopPropagation();
});

$cells.each(function(cIndex) {
var $this = $(this);
if (tblid)
cIndex = $this.data(ROWID);
var cellData = tableData[cIndex];

// forbid highlighting any cells/rows that have class nohighlight
if (!$this.hasClass('nohighlight')) {
// initialize highlighting based on localStorage, unless namedInit already did that
if (!initialised) {
self.setHighlight($this, cellData);
}

// set mouse events
$this
.mouseover(function() {
self.setHighlight($this, 2);
})
.mouseout(function() {
self.setHighlight($this, 3);
})
.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]);
if (tblid) {
self.saveNamed($table.data(TBLID));
} else {
self.save(hashedPageName);
}
e.stopPropagation();
}
});
}
});
// if this is a named table, which wasn't initialised yet, make sure to save data to the named system
if (tblid && !initialised) {
self.saveNamed($table.data(TBLID));
}

// add a button for reset
var button = new OO.ui.ButtonWidget({
label: 'Clear selection',
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) {
if (tblid)
cIndex = $(this).data(ROWID);
tableData[cIndex] = 0;
self.setHighlight($(this), 0);
});

if (tblid) {
self.saveNamed($table.data(TBLID));
} else {
self.save(hashedPageName, $tables.length);
}
});

// selected counter
var $label = $( "<span>" )
.addClass( "ht-reset-counter" )
.appendTo(
button.$element.find( ".oo-ui-labelElement-label" )
);

var $selectables = self.getSelectables($table);
const updtLabelFunc = function() { self.updateLabel( $selectables, $label ); };
$selectables.click( updtLabelFunc );
button.$element.click( updtLabelFunc );
self.updateLabel( $selectables, $label );

$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 without hover
* 2 -> mouse over
*/
setHighlight: function($cell, val) {
switch (val) {
// no highlighting
case 0:
$cell.removeClass(MOUSE_OVER_CLASS);
$cell.removeClass(LIGHT_ON_CLASS);
break;

// light on
case 1:
$cell.removeClass(MOUSE_OVER_CLASS);
$cell.addClass(LIGHT_ON_CLASS);
break;

// mouse-over
case 2:
$cell.addClass(MOUSE_OVER_CLASS);
break;
// mouse-out without affecting highlights
case 3:
$cell.removeClass(MOUSE_OVER_CLASS);
break;
}
}
},
});
}
});


// if this is a named table, which wasn't initialised yet, make sure to save data to the named system
/*
if (tblid && !initialised) {
* Find selectable elements within the table.
*
self.saveNamed($table.data(TBLID));
* @param $table Table.
}
*/
getSelectables: function($table) {
var selector;
if( $table.hasClass( "individual" ) )
selector = "> tbody > tr > td:not(.nohighlight)";
else
selector = "> tbody > tr:not(.nohighlight):has(td:not(.nohighlight))";
return $table.find( selector );
},


/*
// add a button for reset
var button = new OO.ui.ButtonWidget({
* Update the selected elements counter.
*
label: 'Clear selection',
* @param $eles Selectable elements.
icon: 'clear',
* @param $label Selected elements counter element.
title: 'Removes all highlights from the table',
classes: ['ht-reset'] // this class is targeted by other gadgets, be careful removing it
*/
updateLabel: function($eles, $label) {
});
if( $eles.hasClass( "highlight-over" ) )
button.$element.click(function () {
return;
$cells.each(function (cIndex) {
$label.text(
if (tblid) cIndex = $(this).data(ROWID);
tableData[cIndex] = 0;
" (" + $eles.filter( ".highlight-on" ).length + "/" + $eles.length + ")"
);
self.setHighlight($(this), 0);
},
});
if (tblid) {
self.saveNamed($table.data(TBLID));
} else {
self.save(hashedPageName, $tables.length);
}
});


/*
// selected counter
var $label = $("<span>").addClass("ht-reset-counter").appendTo(button.$element.find(".oo-ui-labelElement-label"));
* Merge the updated data for the current page into the data for other pages into local storage.
*
var $selectables = self.getSelectables($table);
var updtLabelFunc = function updtLabelFunc() {
* @param hashedPageName A hash of the current page name.
*/
self.updateLabel($selectables, $label);
save: function(hashedPageName) {
};
$selectables.click(updtLabelFunc);
// load the existing data so we know where to save it
button.$element.click(updtLabelFunc);
var curData = localStorage.getItem(STORAGE_KEY),
self.updateLabel($selectables, $label);
compressedData;
$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 without hover
* 2 -> mouse over
*/
setHighlight: function setHighlight($cell, val) {
switch (val) {
// no highlighting
case 0:
$cell.removeClass(MOUSE_OVER_CLASS);
$cell.removeClass(LIGHT_ON_CLASS);
break;


if (curData === null) {
// light on
curData = {};
case 1:
} else {
$cell.removeClass(MOUSE_OVER_CLASS);
curData = JSON.parse(curData);
$cell.addClass(LIGHT_ON_CLASS);
curData = self.parse(curData);
break;
}


// merge in our updated data and compress it
// mouse-over
case 2:
curData[hashedPageName] = self.data;
compressedData = self.compress(curData);
$cell.addClass(MOUSE_OVER_CLASS);
break;


// convert to a string and save to localStorage
// mouse-out without affecting highlights
case 3:
compressedData = JSON.stringify(compressedData);
localStorage.setItem(STORAGE_KEY, compressedData);
$cell.removeClass(MOUSE_OVER_CLASS);
},
break;
}
},
/*
* Find selectable elements within the table.
*
* @param $table Table.
*/
getSelectables: function getSelectables($table) {
var selector;
if ($table.hasClass("individual")) selector = "> tbody > tr > td:not(.nohighlight)";else selector = "> tbody > tr:not(.nohighlight):has(td:not(.nohighlight))";
return $table.find(selector);
},
/*
* Update the selected elements counter.
*
* @param $eles Selectable elements.
* @param $label Selected elements counter element.
*/
updateLabel: function updateLabel($eles, $label) {
if ($eles.hasClass("highlight-over")) return;
$label.text(" (" + $eles.filter(".highlight-on").length + "/" + $eles.length + ")");
},
/*
* 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 save(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;
* Compress the entire data set using tha algoritm documented at the top of the page.
*
compressedData = self.compress(curData);
* @param data The data to compress.
*
* @return the compressed data.
*/
compress: function(data) {
var ret = {};


// convert to a string and save to localStorage
Object.keys(data).forEach(function(hashedPageName) {
compressedData = JSON.stringify(compressedData);
var pageData = data[hashedPageName],
localStorage.setItem(STORAGE_KEY, compressedData);
pageKey = hashedPageName.charAt(0);
},
/*
* 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 compress(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 load(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
if (!ret.hasOwnProperty(pageKey)) {
ret[pageKey] = {};
// 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
ret[pageKey][hashedPageName] = [];
// 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 parse(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));


pageData.forEach(function(tableData) {
// input validation
var compressedTableData = '',
if (k < 0) {
i, j, k;
k = 0;

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 = [];
}
}
}
for (j = 5; j >= 0; j -= 1) {

// if more tables were added
ret[hashedPageName][index][6 * i + j] = k & 0x1;
// add extra arrays to store the data in
k >>= 1;
// also populates if no existing data was found
while (numTables > pageData.length) {
pageData.push([]);
}
}
}
});
});
});
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 hashString(input) {
var ret = 0,
table = [],
i,
j,
k;


// guarantee 8-bit chars
// if tables were removed, remove data from the end of the list
input = window.unescape(window.encodeURI(input));
// as there's no way to tell which was removed
while (numTables < pageData.length) {
pageData.pop();
}


return pageData;
// 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) {
* Parse the compressed data as loaded from local storage using the algorithm desribed
* at the top of the page.
ret = ret << 8 ^ table[ret >>> 24 ^ input.charCodeAt(i)];
*
}
* @param data The data to parse.
*
* @return the parsed data.
*/
parse: function(data) {
var ret = {};


// make negative numbers unsigned
Object.keys(data).forEach(function(pageKey) {
if (ret < 0) {
var pageData = data[pageKey].split(PAGE_SEPARATOR);
ret += UINT32_MAX;
}


// 32-bit hex string, padded on the left
pageData.forEach(function(tableData) {
ret = '0000000' + ret.toString(16).toUpperCase();
var hashedPageName = tableData.substr(0, 8);
ret = ret.substr(ret.length - 8);

return ret;
tableData = tableData.substr(8).split(TABLE_SEPARATOR);
},
ret[hashedPageName] = [];
/*

* Save highlighted rows for named tables, using the data-tableid attribute.
tableData.forEach(function(rowData, index) {
var i, j, k;
* Does not override values that are not present in the current table. This allows usethe use of a single
* table ID on pages like [[Music]]

*
ret[hashedPageName].push([]);
* @param tblid The table id for the table to initialise

*/
for (i = 0; i < rowData.length; i += 1) {
saveNamed: function saveNamed(tblid) {
k = BASE_64_URL.indexOf(rowData.charAt(i));
// local storage key is prefixed by the generic storage key, to avoid local storage naming conflicts.

var lsKey = STORAGE_KEY + ':' + tblid,
// input validation
if (k < 0) {
data = localStorage.getItem(lsKey);
var $tbls = $('table.lighttable[data-tableid="' + tblid + '"]');
k = 0;
}
if (data === null) {
data = {};

} else {
for (j = 5; j >= 0; j -= 1) {
data = JSON.parse(data);
ret[hashedPageName][index][6 * i + j] = (k & 0x1);
}
k >>= 1;
$tbls.find('[data-rowid]').each(function () {
}
}
var id = $(this).data(ROWID);
});
if (!id) return;
});
data[id] = Number($(this).hasClass(LIGHT_ON_CLASS));
});

localStorage.setItem(lsKey, JSON.stringify(data));
});
},

return ret;
/*
},
* Initialise a named table that uses data-tableid
*

* @param tblid The table id for the table to initialise
/*
* @return Boolean true if successfully initialised, false if no named highlight data was available
* Hash a string into a big endian 32 bit hex string. Used to hash page names.
*
*/
initNamed: function initNamed(tblid) {
* @param input The string to hash.
*
var lsKey = STORAGE_KEY + ':' + tblid;
var data = localStorage.getItem(lsKey);
* @return the result of the hash.
var $tbls = $('table.lighttable[data-tableid="' + tblid + '"]');
*/
hashString: function(input) {
if (data === null) {
var ret = 0,
// no data stored yet, so fall back to unnamed init
table = [],
return false;
i, j, k;
}
var data = JSON.parse(data);

self.data[tblid] = data;
// guarantee 8-bit chars
$tbls.find('[data-rowid]').each(function () {
input = window.unescape(window.encodeURI(input));
var id = $(this).data(ROWID);

if (!id) return;
// calculate the crc (cyclic redundancy check) for all 8-bit data
// bit-wise operations discard anything left of bit 31
if ($('[data-rowid="' + id + '"]').length > 1) {
for (i = 0; i < 256; i += 1) {
mw.log.warn('Reused rowid detected in named lighttable:', id, $('[data-rowid="' + id + '"]'));
k = (i << 24);
}
self.setHighlight($(this), Number(data[id]));

});
for (j = 0; j < 8; j += 1) {
return true;
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;
},
/*
* Save highlighted rows for named tables, using the data-tableid attribute.
* Does not override values that are not present in the current table. This allows usethe use of a single
* table ID on pages like [[Music]]
*
* @param tblid The table id for the table to initialise
*/
saveNamed: function(tblid) {
// local storage key is prefixed by the generic storage key, to avoid local storage naming conflicts.
var lsKey = STORAGE_KEY + ':' + tblid,
data = localStorage.getItem(lsKey);
var $tbls = $('table.lighttable[data-tableid="'+tblid+'"]')

if (data === null) {
data = {};
} else {
data = JSON.parse(data);
}
$tbls.find('[data-rowid]').each(function() {
var id = $(this).data(ROWID);
if (!id) return;
data[id] = Number($(this).hasClass(LIGHT_ON_CLASS));
});

localStorage.setItem(lsKey, JSON.stringify(data));
},
/*
* Initialise a named table that uses data-tableid
*
* @param tblid The table id for the table to initialise
* @return Boolean true if successfully initialised, false if no named highlight data was available
*/
initNamed: function(tblid) {
var lsKey = STORAGE_KEY + ':' + tblid;
var data = localStorage.getItem(lsKey);
var $tbls = $('table.lighttable[data-tableid="'+tblid+'"]')
if (data === null) {
// no data stored yet, so fall back to unnamed init
return false;
}
var data = JSON.parse(data);
self.data[tblid] = data;

$tbls.find('[data-rowid]').each(function() {
var id = $(this).data(ROWID);
if (!id) return;
if ($('[data-rowid="'+id+'"]').length > 1) {
mw.log.warn('Reused rowid detected in named lighttable:', id, $('[data-rowid="'+id+'"]'));
}
self.setHighlight($(this), Number(data[id]))
});
return true;
}
};

$(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]
]
};
};
$(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);
console.log('input', data);

var compressedData = self.compress(data);
var compressedData = self.compress(data);
console.log('compressed', compressedData);
console.log('compressed', compressedData);

var parsedData = self.parse(compressedData);
var parsedData = self.parse(compressedData);
console.log(parsedData);
console.log(parsedData);
*/
*/
})((void 0).jQuery, (void 0).mediaWiki, (void 0).OO, (void 0).rswiki);

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

Latest revision as of 12:06, 20 October 2024

"use strict";

/**
 * 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
 * - 4.0: Labelled highlighting, not page-specific - Joeytje50
 * - 5.0: Selected elements counter                - MrDew & Habblet
 *
 * @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',
    TBLID = 'tableid',
    ROWID = 'rowid',
    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 init() {
        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 initTables(hashedPageName, $tables) {
        $tables.each(function (tIndex) {
          var $this = $(this),
            $table = $this,
            tblid = $this.data(TBLID),
            // 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',
            initialised = false;
          if (tblid) {
            initialised = self.initNamed(tblid);
            tableData = self.data[tblid] || {};
            // initialise rows if necessary
            var row_ids = $this.find('[data-rowid]').map(function () {
              return $(this).data(ROWID);
            });
            row_ids.each(function (index, id) {
              if (!tableData.hasOwnProperty(id)) {
                tableData[id] = 0;
              }
            });
          }

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

          // initialise rows if necessary
          if (!tblid) {
            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);
            if (tblid) cIndex = $this.data(ROWID);
            var cellData = tableData[cIndex];

            // forbid highlighting any cells/rows that have class nohighlight
            if (!$this.hasClass('nohighlight')) {
              // initialize highlighting based on localStorage, unless namedInit already did that
              if (!initialised) {
                self.setHighlight($this, cellData);
              }

              // set mouse events
              $this.mouseover(function () {
                self.setHighlight($this, 2);
              }).mouseout(function () {
                self.setHighlight($this, 3);
              }).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]);
                  if (tblid) {
                    self.saveNamed($table.data(TBLID));
                  } else {
                    self.save(hashedPageName);
                  }
                  e.stopPropagation();
                }
              });
            }
          });

          // if this is a named table, which wasn't initialised yet, make sure to save data to the named system
          if (tblid && !initialised) {
            self.saveNamed($table.data(TBLID));
          }

          // add a button for reset
          var button = new OO.ui.ButtonWidget({
            label: 'Clear selection',
            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) {
              if (tblid) cIndex = $(this).data(ROWID);
              tableData[cIndex] = 0;
              self.setHighlight($(this), 0);
            });
            if (tblid) {
              self.saveNamed($table.data(TBLID));
            } else {
              self.save(hashedPageName, $tables.length);
            }
          });

          // selected counter
          var $label = $("<span>").addClass("ht-reset-counter").appendTo(button.$element.find(".oo-ui-labelElement-label"));
          var $selectables = self.getSelectables($table);
          var updtLabelFunc = function updtLabelFunc() {
            self.updateLabel($selectables, $label);
          };
          $selectables.click(updtLabelFunc);
          button.$element.click(updtLabelFunc);
          self.updateLabel($selectables, $label);
          $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 without hover
       *            2 -> mouse over
       */
      setHighlight: function setHighlight($cell, val) {
        switch (val) {
          // no highlighting
          case 0:
            $cell.removeClass(MOUSE_OVER_CLASS);
            $cell.removeClass(LIGHT_ON_CLASS);
            break;

          // light on
          case 1:
            $cell.removeClass(MOUSE_OVER_CLASS);
            $cell.addClass(LIGHT_ON_CLASS);
            break;

          // mouse-over
          case 2:
            $cell.addClass(MOUSE_OVER_CLASS);
            break;

          // mouse-out without affecting highlights
          case 3:
            $cell.removeClass(MOUSE_OVER_CLASS);
            break;
        }
      },
      /*
       * Find selectable elements within the table.
       *
       * @param $table Table.
       */
      getSelectables: function getSelectables($table) {
        var selector;
        if ($table.hasClass("individual")) selector = "> tbody > tr > td:not(.nohighlight)";else selector = "> tbody > tr:not(.nohighlight):has(td:not(.nohighlight))";
        return $table.find(selector);
      },
      /*
       * Update the selected elements counter.
       *
       * @param $eles Selectable elements.
       * @param $label Selected elements counter element.
       */
      updateLabel: function updateLabel($eles, $label) {
        if ($eles.hasClass("highlight-over")) return;
        $label.text(" (" + $eles.filter(".highlight-on").length + "/" + $eles.length + ")");
      },
      /*
       * 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 save(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 compress(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 load(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 parse(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 hashString(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;
      },
      /*
       * Save highlighted rows for named tables, using the data-tableid attribute.
       * Does not override values that are not present in the current table. This allows usethe use of a single
       * table ID on pages like [[Music]]
       *
       * @param tblid The table id for the table to initialise
       */
      saveNamed: function saveNamed(tblid) {
        // local storage key is prefixed by the generic storage key, to avoid local storage naming conflicts.
        var lsKey = STORAGE_KEY + ':' + tblid,
          data = localStorage.getItem(lsKey);
        var $tbls = $('table.lighttable[data-tableid="' + tblid + '"]');
        if (data === null) {
          data = {};
        } else {
          data = JSON.parse(data);
        }
        $tbls.find('[data-rowid]').each(function () {
          var id = $(this).data(ROWID);
          if (!id) return;
          data[id] = Number($(this).hasClass(LIGHT_ON_CLASS));
        });
        localStorage.setItem(lsKey, JSON.stringify(data));
      },
      /*
       * Initialise a named table that uses data-tableid
       *
       * @param tblid The table id for the table to initialise
       * @return Boolean true if successfully initialised, false if no named highlight data was available
       */
      initNamed: function initNamed(tblid) {
        var lsKey = STORAGE_KEY + ':' + tblid;
        var data = localStorage.getItem(lsKey);
        var $tbls = $('table.lighttable[data-tableid="' + tblid + '"]');
        if (data === null) {
          // no data stored yet, so fall back to unnamed init
          return false;
        }
        var data = JSON.parse(data);
        self.data[tblid] = data;
        $tbls.find('[data-rowid]').each(function () {
          var id = $(this).data(ROWID);
          if (!id) return;
          if ($('[data-rowid="' + id + '"]').length > 1) {
            mw.log.warn('Reused rowid detected in named lighttable:', id, $('[data-rowid="' + id + '"]'));
          }
          self.setHighlight($(this), Number(data[id]));
        });
        return true;
      }
    };
  $(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);
  */
})((void 0).jQuery, (void 0).mediaWiki, (void 0).OO, (void 0).rswiki);