There are 10 previous versions of this script.
// ==UserScript==
// @name US.o script author helper
// @namespace http://userscripts.org/scripts/show/36353
// @description Shows how many new reviews, comments, installs and fans your scripts have got since you last checked
// @include http://userscripts.org/home/scripts
// @include http://userscripts.org/home/scripts?*
// ==/UserScript==
////////////////////////// START OF HELPER FUNCTIONS //////////////////////////
// Runs a particular XPath expression p against the context node context (or the document, if not provided)
// If a document (docObj) is provided, its evaluate method is used instead of document.evaluate (and it's also the default context)
// Returns the results as an array
function $x(p, context, docObj) {
if (!docObj) docObj = document;
if (!context) context = docObj;
var arr = [], xpr = docObj.evaluate(p, context, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
for (var i = 0, l = xpr.snapshotLength; i < l; i++) arr.push(xpr.snapshotItem(i));
return arr;
}
// Returns only the first element of the array returned by $x (or null if the array was empty)
function $x1(p, context, docObj) {
var nodeArray = $x(p, context, docObj);
return (nodeArray.length > 0) ? nodeArray[0] : null;
}
// Creates a new node with the given attributes, properties and event listeners
function createNode(type, attributes, props, evls) {
var node = document.createElement(type);
if (attributes) {
for (var attr in attributes) {
node.setAttribute(attr, attributes[attr]);
}
}
if (props) {
for (var prop in props) {
if (prop in node) node[prop] = props[prop];
}
}
if (evls instanceof Array) {
evls.forEach(function(evl) {node.addEventListener.apply(node, evl);});
}
return node;
}
// Returns the "pluralized" form of singularWord if it's necessary to express a timesNumber quantity
function adjustPlural(singularWord, timesNumber) {
return singularWord + ((Math.abs(timesNumber) != 1) ? "s" : "");
}
// Creates a span with the difference number as its text and a CSS class according to its sign
// If formatString is provided, the span will present it as its text (with each %s replaced with the difference number)
function createDiffSpan(diffNumber, formatString) {
if (!formatString) formatString = " (%s)"; // Default span format
return createNode("span", {class: ((diffNumber > 0) ? "gsPosDiff" : "gsNegDiff")},
{textContent: formatString.replace(/%s/g, diffNumber.toCustomString(true))});
}
// Calculates the difference between two dates and returns it as a "humanized" string
// It uses UTC functions because the timezone offset can affect the calculations
function getDateDiffString(dateNew, dateOld) {
// Creates a date object with the difference between the two passed dates
var dateDiff = new Date(dateNew.getTime() - dateOld.getTime());
dateDiff.setUTCFullYear(dateDiff.getUTCFullYear() - 1970); // Substracts 1970 years to compensate Date.getTime's (Unix) epoch (1 Jan 1970 00:00:00 UTC)
// Initializes the variables (timeunitsHash correlates the time units names with its corresponding method of the Date object)
var strDateDiff = "", timeunitValue = 0;
var timeunitsHash = {year: "getUTCFullYear", month: "getUTCMonth", day: "getUTCDate",
hour: "getUTCHours", minute: "getUTCMinutes", second: "getUTCSeconds", millisecond: "getUTCMilliseconds"};
// Appends the time units values and its names to construct the string
for (var timeunitName in timeunitsHash) {
// Gets the time unit value by calling the corresponding method of the Date object
// It substracts 1 for the days because they begin with 1 (the other time units begin with 0)
timeunitValue = dateDiff[timeunitsHash[timeunitName]]() - ((timeunitName == "day") ? 1 : 0);
// If the value isn't 0, appends this time unit to the string
if (timeunitValue !== 0) {
if ((timeunitName == "millisecond") && (strDateDiff.length !== 0)) continue; // Milliseconds won't be added unless the difference is less than 1 second
strDateDiff += ((strDateDiff.length === 0) ? "" : ", ") + // Adds a comma as separator if another time unit has already been added
timeunitValue.toCustomString(false) + " " +
adjustPlural(timeunitName, timeunitValue);
}
}
// Replaces the last comma with an "and" to humanize the string
strDateDiff = strDateDiff.replace(/,([^,]*)$/, " and$1");
return strDateDiff;
}
// Reverses a string
String.prototype.reverse = function() {
return this.split("").reverse().join("");
};
// Transforms a number into a string with a coma as thousands separator
// If showSign is true, the plus (+) sign is appended to the positive numbers
// The double reverse is to workaround Javascript lack of lookbehind in regular expressions
Number.prototype.toCustomString = function(showSign) {
var strNumber = this.toString();
strNumber = strNumber.reverse().replace(/\d{3}(?!,|$|-)/g, "$&,").reverse();
if (showSign) strNumber = ((this > 0) ? "+" : "") + strNumber;
return strNumber;
};
// Native JSON will be used to parse and stringify data from/to a string if available, eval/uneval if not
var jParse = (window.JSON && window.JSON.parse) ? window.JSON.parse : eval;
var jStringify = (window.JSON && window.JSON.stringify) ? window.JSON.stringify : uneval;
// "Constant" with the key name to save the scripts data
var DATA_KEY = "scriptData";
/////////////////////////// END OF HELPER FUNCTIONS ///////////////////////////
/////////////////////////// START OF OBJECT CLASSES ///////////////////////////
// Class for a base object with all the tracked properties of the scripts
function GmProps(baseReviews, baseComments, baseFans, baseInstalls) {
// Makes sure that the passed argument is a number. If it can't be parsed as a number, 0 will be used
function numerizeArg(baseArg) {
var numArg = parseInt(baseArg, 10);
return ((isNaN(numArg)) ? 0 : numArg);
}
this.reviews = numerizeArg(baseReviews); // Reviews
this.comments = numerizeArg(baseComments); // Comments
this.fans = numerizeArg(baseFans); // Fans
this.installs = numerizeArg(baseInstalls); // Installs
}
// Class for the script object
// Each script row will be parsed to an object of this class and added to a SOA
function GmScript(scriptId, scriptName, scriptBase) {
this.id = scriptId; // Script id
this.name = scriptName; //Script name
this.props = (scriptBase instanceof GmProps) ? scriptBase : new GmProps(); // Script's properties object
}
// Class for the scripts' container object
function GmScriptContainer(dataKeyName) {
// Script objects array (SOA). The data will be retrieved from storage if a key name is provided
this.scripts = [];
if (dataKeyName) this.getScriptsData(dataKeyName);
}
// Returns the script object from the container's SOA with the passed id
// If no script is found, it returns null
GmScriptContainer.prototype.getScriptById = function(scriptId) {
var scriptArrayFiltered = this.scripts.filter(function(s) {return (s.id == scriptId);});
return (scriptArrayFiltered.length > 0) ? scriptArrayFiltered[0]: null;
};
// Returns the total of the passed script property in the container's SOA
GmScriptContainer.prototype.getTotal = function(propName) {
var totalItems = 0;
this.scripts.forEach(function(s) {totalItems += s.props[propName]});
return totalItems;
};
// Saves the SOA to storage in JSON format. It also saves the scripts in containerOld's SOA (if passed) which don't exist in the SOA
GmScriptContainer.prototype.saveScriptsData = function(dataKeyName, containerOld) {
var scriptsArray = this.scripts, oldScriptsArray = (containerOld) ? containerOld.scripts : [];
oldScriptsArray.forEach(function(s) {
if (this.getScriptById(s.id) === null) scriptsArray.push(s); // Adds the script to the array if it doesn't exist in this container's SOA
}, this);
GM_setValue(dataKeyName, jStringify(scriptsArray));
};
// Populates the SOA from the data stored in storage, converting it from JSON format
// If storage is empty or its content isn't an array, then it doesn't do anything
GmScriptContainer.prototype.getScriptsData = function(dataKeyName) {
// Loads the JSON string from storage and parses it. If it isn't an array, exits
var loadArray = jParse(GM_getValue(dataKeyName, "null"));
if (!(loadArray instanceof Array)) return;
// Validates each script object and, if it's valid, adds it to the SOA
loadArray.forEach(function(s) {
// Makes sure the script name is a string and the script id a number
var scriptName = String(s.name), scriptId = parseInt(s.id, 10);
if (isNaN(scriptId)) return;
// Makes sure it has a "props" object property
if (typeof(s.props) != "object") return;
// Makes sure each script property is a number. If it can't be parsed as a number, 0 will be used
var parseObj = new GmProps();
for (var propName in parseObj) {
parseObj[propName] = parseInt(s.props[propName]);
if (isNaN(parseObj[propName])) parseObj[propName] = 0; // New properties not supported in previous script versions (s.props[propName] === undefined) will be loaded as 0
}
// Creates a new GmScript object with the validated properties and adds it to the SOA
var scriptObj = new GmScript(scriptId, scriptName, parseObj);
this.scripts.push(scriptObj);
}, this);
};
//////////////////////////// END OF OBJECT CLASSES ////////////////////////////
// Adds classes to colorize positive and negative differences
GM_addStyle(".gsPosDiff {color: green !important}");
GM_addStyle("th > .gsPosDiff {color: lime !important}"); // For better contrast with the th black background
GM_addStyle(".gsNegDiff {color: red !important}");
// Associative array used to match each script property name with its corresponding column number (cells collection) in a script row
var columnsHash = {reviews: 2, comments: 3, fans: 4, installs: 5};
// Gets the scripts table (tbody)
var scriptTable = $x1("id('content')//table/tbody");
if (!scriptTable) return;
// Gets an array with all the rows that correspond to scripts
var scriptRows = $x("./tr[starts-with(@id, 'scripts-')]", scriptTable);
if (scriptRows.length === 0) return;
// Parses the script rows into script objects (GmScript class) and adds them to the SOA of a GmScriptContainer object. The old SOA is loaded into a container as well
// Then it compares each script object from the SOA with the corresponding script object from the old SOA, presenting the differences in the document
var containerNew = new GmScriptContainer(), containerOld = new GmScriptContainer(DATA_KEY);
scriptRows.forEach(function(scriptRow) {
// Finds the link to the script homepage
var scriptLink = $x1("./td[@class='script-meat']/a", scriptRow);
if (!scriptLink) return;
// Extracts the script name and id (parsed to an integer) from the link
var scriptName = scriptLink.textContent;
var scriptId = scriptLink.href.match(/\/(\d+)$/);
if (scriptId === null) {
return;
}
else {
scriptId = parseInt(scriptId[1], 10);
if (isNaN(scriptId)) return;
}
// Extracts and parses each property number from the apropiate cell of the script row
var parseObj = new GmProps();
for (var propName in parseObj) {
// Finds the appropiate cell for this property using the scriptRow object (row) and the columnsHash information (column)
var scriptPropCell = scriptRow.cells[columnsHash[propName]];
if (!scriptPropCell) return;
// Extracts the property value from its cell
switch(propName) {
case "reviews":
var scriptReviewsLink = $x1("./a[contains(@href, '/reviews/')]", scriptPropCell);
parseObj[propName] = (scriptReviewsLink) ? parseInt(scriptReviewsLink.textContent, 10) : 0;
break;
default:
parseObj[propName] = parseInt(scriptPropCell.textContent, 10);
break;
}
// If the property couldn't be parsed as a number, skips this script
if (isNaN(parseObj[propName])) return;
}
// Creates a script object with the extracted data and adds it to the SOA
var scriptObj = new GmScript(scriptId, scriptName, parseObj);
containerNew.scripts.push(scriptObj);
// Gets the corresponding script object from the old SOA. If it doesn't exists there (the script is new), it uses a new script object with 0 in all its properties
var scriptObjOld = ((containerOld.getScriptById(scriptObj.id)) || (new GmScript(scriptObj.id, scriptObj.name, new GmProps())));
// Compares each property of the script objects
for (var propName in scriptObj.props) {
// Calculates the difference between properties. If it's zero, there's nothing to indicate
var propDiff = scriptObj.props[propName] - scriptObjOld.props[propName];
if (propDiff === 0) continue;
// Creates a difference span with the difference number
var spanDiff = createDiffSpan(propDiff);
// Finds the appropiate cell for this property using the scriptRow object (row) and the columnsHash information (column)
var propCell = scriptRow.cells[columnsHash[propName]];
if (!propCell) continue;
// Appends the span to the cell
propCell.appendChild(spanDiff);
}
});
if (containerNew.scripts.length === 0) return; // If no script object has been added to the SOA, the script returns
// Saves the new SOA to storage, merging it with the old SOA's not seen scripts
containerNew.saveScriptsData(DATA_KEY, containerOld);
// Gets the header row of the script table
var scriptHeaderRow = $x1("./tr[th]", scriptTable);
if (!scriptHeaderRow) return;
// Calculates the total for each script property in the new container and its difference with the total of the old container
var totalPropNew, totalPropOld, totalPropDiff;
var totalsText = containerNew.scripts.length.toCustomString(false) + " " + adjustPlural("script", containerNew.scripts.length); // Builds a totals string with the number of scripts in the new container
for (var propName in new GmProps()) {
// Gets the total of the property in both containers
totalPropNew = containerNew.getTotal(propName);
totalPropOld = containerOld.getTotal(propName);
// If there is a difference between the totals, presents it in the appropiate cell of the header row
totalPropDiff = totalPropNew - totalPropOld;
if (totalPropDiff != 0) scriptHeaderRow.cells[columnsHash[propName]].appendChild(createDiffSpan(totalPropDiff));
// Adds to the totals string the total of the property
totalsText += " / " + totalPropNew.toCustomString(false) + " " + adjustPlural(propName.slice(0, -1), totalPropNew);
}
// Creates a paragraph with the totals string and inserts it into the document before the script table
var totalsP = createNode("p", {class: "subtitle"}, {textContent: totalsText});
var scriptTableT = scriptTable.parentNode; // This is the "table" element
scriptTableT.parentNode.insertBefore(totalsP, scriptTableT);
// Gets the last check date (LCD) and saves the current one
var checkDate = new Date(), checkDateOld = parseInt(GM_getValue("lastCheck", null), 10);
checkDateOld = (isNaN(checkDateOld)) ? null : (new Date(checkDateOld));
GM_setValue("lastCheck", checkDate.getTime().toString());
// If there's a saved LCD, creates a paragraph with it (and its "humanized" difference with the current date) and insert it into the document before the script table
if (checkDateOld !== null) {
var datesP = createNode("p", {class: "subtitle"},
{textContent: "Last check was " + getDateDiffString(checkDate, checkDateOld) + " ago (" +
checkDateOld.toUTCString() + ")"});
scriptTableT.parentNode.insertBefore(datesP, scriptTableT);
}