diff --git a/README.md b/README.md index 92642e3..50e016c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Features * Audible alarm on combat and user-configurable game events * Desktop notifications on combat and user-configurable game events + * Minimap of current sector in the nav screen. * Robot autofill in NPC combat screen * Missile and rounds autoselect in combat screens * Stored personal and alliance quicklists diff --git a/alarm.js b/alarm.js deleted file mode 100644 index 526685a..0000000 --- a/alarm.js +++ /dev/null @@ -1,61 +0,0 @@ -function Alarm(sound) { - - this.soundk = new Object(); - for(var i = 0; i < this.sounds.length; i++) { - var s = this.sounds[i]; - this.soundk[s.id] = s; - } - - // Select the current sound. If there's no such thing, - // pick the first on our list. - - if(!sound) - sound = this.sounds[0].id; - this.selectSample(sound); -} - -Alarm.prototype = { - sounds: [ - { id: 'buzz', name: 'Buzzer' }, - { id: 'dive', name: 'Dive horn' }, - { id: 'missile', name: 'Missile alert' }, - { id: 'power', name: 'Power plant' }, - { id: 'timex', name: 'Timex' } - ], - - selectSample: function(id, ready_callback) { - this.switchOff(); - this.sound_id = id; - this.sound_ready = false; - this.sound = new Audio(); - this.sound.src = 'sounds/' + id + '.ogg'; - var self = this; - this.sound.addEventListener('canplaythrough', - function() { - self.sound_ready = true; - if(ready_callback) - ready_callback(); - }); - this.sound.load(); - }, - - switchOff: function() { - if(this.sound_timer) { - clearTimeout(this.sound_timer); - this.sound.pause(); - delete this.sound_timer; - } - }, - - switchOn: function() { - if(this.sound_ready) { - this.switchOff(); - this.sound.currentTime = 0; - this.sound.loop = true; - this.sound.play(); - // it runs for 50 seconds before turning itself off - var self = this; - this.sound_timer = setTimeout(function() { self.switchOff(); }, 50000); - } - } -}; diff --git a/ambush.js b/ambush.js index 9214822..fcdccd3 100644 --- a/ambush.js +++ b/ambush.js @@ -1,291 +1,374 @@ -function PSWAmbushScreenDriver(doc) { - this.doc = doc; +'use strict'; + +(function( doc, ConfigurationSet, Universe ) { + +var + // The current configuration + config, + // The elements of the page that we care about: + form, container, readListTextarea, applyQLButton, roundsSelect, + confirmButton, + // A flag that we found all the above: + pageScanned, + // Stuff we add to the page, and may need to remove: + addedElements; + +function start() { + var cs = new ConfigurationSet(), + universeName = Universe.getName( doc ); + + cs.addKey( 'allianceQLs' + universeName, 'allianceQLs' ); + cs.addKey( 'allianceQLs' + universeName + 'Enabled', 'allianceQLsEnabled' ); + cs.addKey( 'allianceQLs' + universeName + 'MTime', 'allianceQLsMTime' ); + cs.addKey( 'personalQL' + universeName, 'personalQL' ); + cs.addKey( 'personalQL' + universeName + 'Enabled', 'personalQLEnabled' ); + cs.addKey( 'fitAmbushRounds' ); + cs.addKey( 'overrideAmbushRounds' ); + + config = cs.makeTracker( applyConfiguration ); +} - var universe = universeName(); - this.configmap = { overrideAmbushRounds: 'overrideAmbushRounds' }; - this.configmap[ 'allianceQLs' + universe + 'Enabled' ] = 'allianceQLsEnabled'; - this.configmap[ 'allianceQLs' + universe ] = 'allianceQLs'; - this.configmap[ 'allianceQLs' + universe + 'MTime' ] = 'allianceQLsMTime'; - this.configmap[ 'personalQL' + universe + 'Enabled' ] = 'personalQLEnabled'; - this.configmap[ 'personalQL' + universe ] = 'personalQL'; +function applyConfiguration() { + if ( !pageScanned && + ( config.allianceQLsEnabled || config.personalQL || + config.fitAmbushRounds || config.overrideAmbushRounds ) ) { + scanPage(); + } - var keys = Object.keys(this.configmap); + if ( addedElements ) { + removeUI(); + } - this.parameter_count = keys.length; - this.config = new Object(); - this.addedElements = new Array(); + if ( config.allianceQLsEnabled || config.personalQLEnabled ) { + addQLsUI(); + } - this.port = chrome.extension.connect(); + if ( config.fitAmbushRounds ) { + setupFitRounds(); + } - var self = this; + if ( config.overrideAmbushRounds ) { + selectHighestRounds(); + } +} - this.port.onMessage.addListener(function(msg) { self[ msg.op + 'MessageHandler' ](msg); }); - this.port.postMessage({ op: 'subscribe', keys: keys }); +// Finds elements we're interested in this page: +// * the TBODY of the TABLE containing the legend ('Ambush mode'), +// which is where we do all our stuff (we call this 'container') +// * the 'readlist' textarea +// * the 'apply_ql' input +// * the 'rounds' input +function scanPage() { + if ( pageScanned ) { + // only run once + throw new Error( 'scanPage running twice' ); + } + + form = doc.forms.modes; + // sanity check + if ( !form || form.children.length < 1 || + form.children[0].children.length < 1 ) { + return; + } + + container = form.children[0].children[0]; + // sanity check + if ( container.tagName.toLowerCase() != 'tbody' ) { + return; + } + + readListTextarea = form.elements['readlist']; + roundsSelect = form.elements['rounds']; + applyQLButton = form.elements['apply_ql']; + confirmButton = form.elements['confirm']; + + // sanity check + if ( !readListTextarea || !roundsSelect || + !applyQLButton || !confirmButton ) { + return; + } + + // all is well + pageScanned = true; } -PSWAmbushScreenDriver.prototype = { - updateValueMessageHandler: function(msg) { - var key = this.configmap[msg.key]; - if(key) - this.config[key] = msg.value; - if(Object.keys(this.config).length >= this.parameter_count) - this.configure(); - }, - - // called when configuration is complete - configure: function() { - this.removeUI(); - - if(this.config.allianceQLsEnabled || this.config.personalQLEnabled) { - this.scanPage(); - if(this.ready) - this.setupQLsUI(this.config.allianceQLsEnabled, - JSON.parse(this.config.allianceQLs), - parseInt(this.config.allianceQLsMTime), - this.config.personalQLEnabled, - this.config.personalQL); - } +function addQLsUI() { + var first, tr, th, td, img, font, div, b, a, span, text, input, br, + allianceQLs, ql, age, i, end; + + if ( addedElements ) { + throw new Error( 'addQLsUI running twice without removing UI first' ); + } + + addedElements = []; + + first = container.firstChild; + + tr = doc.createElement( 'tr' ); + th = doc.createElement( 'th' ); + img = doc.createElement( 'img' ); + img.src = chrome.extension.getURL( 'icons/16.png' ); + img.style.verticalAlign = 'middle'; + img.style.position = 'relative'; + img.style.top = '-2px'; + // Yes, an ugly deprecated below. But this is what Pardus + // does, and we care more about blending in nicely, than we do + // about correctness. + font = doc.createElement( 'font' ); + font.size = 3; + font.appendChild( img ); + text = doc.createTextNode( ' Fast ambush options' ); + font.appendChild( text ); + th.appendChild( font ); + tr.appendChild( th ); + container.insertBefore( tr, first ); + addedElements.push( tr ); + + tr = doc.createElement( 'tr' ); + td = doc.createElement( 'td' ); + // more ugliness + td.align = 'center'; + // td.style.padding = '17px'; + + // If alliance QLs are enabled, add them. + if ( config.allianceQLsEnabled ) { + allianceQLs = config.allianceQLs; + div = doc.createElement( 'div' ); + div.style.margin = '17px'; + + if ( allianceQLs.length > 0 ) { + b = doc.createElement( 'b' ); + span = doc.createElement( 'span' ); + age = Math.floor( Date.now() / 1000 ) - config.allianceQLsMTime; + if ( age < 0 ) { + age = 0; + } + text = doc.createTextNode( 'last updated ' + timeAgo(age) ); + span.appendChild( text ); + if ( age > 84600 ) { + span.style.color = 'red'; + } + text = doc.createTextNode( 'Alliance quick lists ' ); + b.appendChild( text ); + b.appendChild( span ); + text = doc.createTextNode( ':' ); + b.appendChild( text ); + div.appendChild( b ); + br = doc.createElement( 'br' ); + div.appendChild( br ); + + for ( i = 0, end = allianceQLs.length; i < end; i++) { + ql = allianceQLs[ i ]; + addQLUI( div, ql.name, ql.ql ); + } + } + else { + // Alliance QLs on, but no QLs + a = doc.createElement( 'a' ); + a.href = '/myalliance.php'; + a.textContent = 'My Alliance'; + b = doc.createElement( 'b' ); + text = doc.createTextNode( + 'No alliance quick lists on record. ' + + 'You may try and load some from ' ); + b.appendChild( text ); + b.appendChild( a ); + b.appendChild( doc.createTextNode('.') ); + div.appendChild( b ); + } + + td.appendChild( div ); + } + + // If personal QLs is enabled, add it. + if ( config.personalQLEnabled ) { + div = doc.createElement( 'div' ); + div.style.margin = '17px'; + ql = config.personalQL; + if ( ql && ql.length > 0 ) { + b = doc.createElement( 'b' ); + b.textContent = 'Personal quick list:'; + div.appendChild( b ); + br = doc.createElement( 'br' ); + div.appendChild( br ); + addQLUI( div, 'Personal QL', ql ); + } + else { + b = doc.createElement( 'b' ); + b.textContent = + 'No personal quick list defined. ' + + 'Set one in the Pardus Sweetener options.'; + div.appendChild( b ); + } + + td.appendChild( div ); + } + + tr.appendChild( td ); + container.insertBefore( tr, first ); + addedElements.push( tr ); + + tr = doc.createElement( 'tr' ); + td = doc.createElement( 'td' ); + td.align = 'center'; + td.style.paddingBottom = '17px'; + input = doc.createElement( 'input' ); + input.type = 'submit'; + input.name = 'confirm'; + input.value = 'Lay Ambush'; + input.style.backgroundColor = '#600'; + input.style.color = '#fff'; + input.style.fontWeight = 'bold'; + input.style.padding = '3px'; + td.appendChild( input ); + tr.appendChild( td ); + container.insertBefore( tr, first ); + addedElements.push( tr ); + + // And while we're at this, lets make the other "lay ambush" + // button red, too. + confirmButton.style.backgroundColor = '#600'; + confirmButton.style.color = '#fff'; +} - if(this.config.overrideAmbushRounds) { - this.scanPage(); - if(this.ready) - this.selectHighestRounds(); - } - }, +function addQLUI( container, qlname, ql ) { + var input, applyListener, copyListener, img, rows; + + input = doc.createElement( 'input' ); + input.type = 'button'; + input.name = 'apply' + qlname.replace( /\s/g, '-' ); + input.value = 'Apply ' + qlname; + applyListener = function() { + readListTextarea.value = ql; + applyQLButton.click(); + }; + + input.addEventListener( 'click', applyListener ); + container.appendChild( input ); + + img = doc.createElement( 'img' ); + img.src = chrome.extension.getURL( 'icons/down.png' ); + img.alt = 'view'; + img.title = 'Copy ' + qlname + ' to quicklist field below'; + img.style.verticalAlign = 'middle'; + rows = 2 + Math.floor( ql.length / 80 ); + copyListener = function() { + readListTextarea.value = ql; + readListTextarea.cols = 80; + readListTextarea.rows = rows; + scrollTo( readListTextarea ); + }; + + img.addEventListener( 'click', copyListener ); + container.appendChild( img ); + container.appendChild( doc.createTextNode("\n") ); +} - removeUI: function() { - while(this.addedElements.length > 0) { - var elt = this.addedElements.pop(); - elt.parentNode.removeChild(elt); - } - }, - - // Finds elements we're interested in this page: - // * the TBODY of the TABLE containing the legend ('Ambush mode'), - // which is where we do all our stuff (we call this 'container') - // * the 'readlist' textarea - // * the 'apply_ql' input - // * the 'rounds' input - scanPage: function(msg) { - var elts = this.elements; - if(elts) - // only run once - return; - else - elts = this.elements = new Object(); - - var form = this.doc.forms.modes; - // sanity check - if(!form || form.children.length < 1 || - form.children[0].children.length < 1) - return; - - elts.container = form.children[0].children[0]; - // sanity check - if(elts.container.tagName.toLowerCase() != 'tbody') - return; - - elts.ta = form.elements['readlist']; - elts.rounds = form.elements['rounds']; - elts.apply = form.elements['apply_ql']; - elts.confirm = form.elements['confirm']; - // sanity check - if(!elts.ta || !elts.rounds || !elts.apply || !elts.confirm) - return; - - // all is good - this.ready = true; - }, - - setupQLsUI: function(aqls_enabled, aqls, mtime, pql_enabled, pql) { - var container = this.elements.container; - var first = this.elements.container.firstChild; - - var tr = this.doc.createElement('tr'); - var th = this.doc.createElement('th'); - var img = this.doc.createElement('img'); - img.src = chrome.extension.getURL('icons/16.png'); // XXX not very self-contained, this - img.style.verticalAlign = 'middle'; - img.style.position = 'relative'; - img.style.top = '-2px'; - var font = this.doc.createElement('font'); - font.size = 3; - font.appendChild(img); - font.appendChild(this.doc.createTextNode(' Fast ambush options')); - th.appendChild(font); - tr.appendChild(th); - container.insertBefore(tr, first); - this.addedElements.push(tr); - - tr = this.doc.createElement('tr'); - var td = this.doc.createElement('td'); - td.align = 'center'; - //td.style.padding = '17px'; - - var age = Math.floor(Date.now() / 1000) - mtime; - if(age < 0) - age = 0; - - if(aqls_enabled) { - var div = this.doc.createElement('div'); - div.style.margin = '17px'; - - if(aqls && aqls.length > 0 && mtime > 0) { - var b = this.doc.createElement('b'); - var span = this.doc.createElement('span'); - span.appendChild(this.doc.createTextNode('last updated ' + this.timeAgo(age))); - if(age > 84600) - span.style.color = 'red'; - b.appendChild(this.doc.createTextNode('Alliance quick lists ')); - b.appendChild(span); - b.appendChild(this.doc.createTextNode(':')); - div.appendChild(b); - div.appendChild(this.doc.createElement('br')); - - for(var i = 0; i < aqls.length; i++) { - var ql = aqls[i]; - this.addQL(div, ql.name, ql.ql); - } - } - else { - var a = this.doc.createElement('a'); - a.href = '/myalliance.php'; - var b = this.doc.createElement('b'); - a.appendChild(this.doc.createTextNode('My Alliance')); - b.appendChild(this.doc.createTextNode('No alliance quick lists on record. ' - + 'You may try and load some from ')); - b.appendChild(a); - b.appendChild(this.doc.createTextNode('.')); - div.appendChild(b); - } - - td.appendChild(div); - } +function removeUI() { + var elt; - if(pql_enabled) { - var div = this.doc.createElement('div'); - div.style.margin = '17px'; - - if(pql && pql.length > 0) { - var b = this.doc.createElement('b'); - b.appendChild(this.doc.createTextNode('Personal quick list:')); - div.appendChild(b); - div.appendChild(this.doc.createElement('br')); - this.addQL(div, 'Personal QL', pql); - } - else { - var b = this.doc.createElement('b'); - b.appendChild(this.doc.createTextNode('No personal quick list defined. ' - + 'Please set one in the Pardus Sweetener options.')); - div.appendChild(b); - } - - td.appendChild(div); - } + while ( addedElements.length > 0 ) { + elt = addedElements.pop(); + elt.parentNode.removeChild( elt ); + } - tr.appendChild(td); - container.insertBefore(tr, first); - this.addedElements.push(tr); - - tr = this.doc.createElement('tr'); - td = this.doc.createElement('td'); - td.align = 'center'; - td.style.paddingBottom = '17px'; - var input = this.doc.createElement('input'); - input.type = 'submit'; - input.name = 'confirm'; - input.value = 'Lay Ambush'; - input.style.backgroundColor = '#600'; - input.style.color = '#fff'; - input.style.fontWeight = 'bold'; - input.style.padding = '3px'; - td.appendChild(input); - tr.appendChild(td); - container.insertBefore(tr, first); - this.addedElements.push(tr); - - // and while we're at this, lets make the other lay ambush button red too - this.elements.confirm.style.backgroundColor = '#600'; - this.elements.confirm.style.color = '#fff'; - }, - - addQL: function(container, qlname, ql) { - var input = this.doc.createElement('input'); - input.type = 'button'; - input.name = 'apply' + qlname.replace(/\s/g, '-'); - input.value = 'Apply ' + qlname; - var ta = this.elements.ta; - var submit = this.elements.apply; - input.addEventListener('click', function() { ta.value = ql; submit.click(); }, false); - container.appendChild(input); - - var img = this.doc.createElement('img'); - img.src = chrome.extension.getURL('icons/down.png'); // XXX not very self-contained, this - img.alt = 'view'; - img.title = 'Copy ' + qlname + ' to quicklist field below'; - img.style.verticalAlign = 'middle'; - var rows = 2 + Math.floor(ql.length / 80); - var scrollTo = this.scrollTo; - img.addEventListener('click', function() { - ta.value = ql; - // XXX - maybe we should make this enlargement configurable - ta.cols = 80; - ta.rows = rows; - scrollTo(ta); - }, false); - container.appendChild(img); - - container.appendChild(this.doc.createTextNode("\n")); - }, - - timeAgo: function(seconds) { - if(seconds < 60) - return 'just now'; - - var n; - if(seconds < 3600) { - n = Math.round(seconds/60); - return (n == 1 ? 'a minute' : String(n) + ' minutes') + ' ago'; - } + addedElements = undefined; - if(seconds < 86400) { - n = Math.round(seconds/3600); - return (n == 1 ? 'an hour' : String(n) + ' hours') + ' ago'; - } + form.removeEventListener( 'submit', onFormSubmit ); +} - n = Math.round(seconds/86400); - return (n == 1 ? 'yesterday' : String(n) + ' days ago'); - }, +function timeAgo( seconds ) { + var n; - // utility method, may move elsewhere - scrollTo: function (element) { - var x = 0, y = 0; + if ( seconds < 60 ) { + return 'just now'; + } - while(element) { - x += element.offsetLeft; - y += element.offsetTop; - element = element.offsetParent; - } + if ( seconds < 3600 ) { + n = Math.round( seconds / 60 ); + return ( n == 1 ? 'a minute' : n + ' minutes' ) + ' ago'; + } - window.scrollTo(x,y); - }, - - // this could be merged with the rounds function in combat.js - selectHighestRounds: function () { - var elt = this.elements.rounds; - var highest = 0, highestElt = null; - var opts = elt.getElementsByTagName('option'); - for(var j = 0; j < opts.length; j++) { - var opt = opts[j]; - var n = parseInt(opt.value); - if(n > highest) { - highest = n; - highestElt = opt; - } - } - if(highestElt) - highestElt.selected = true; - } -}; + if ( seconds < 86400 ) { + n = Math.round( seconds/ 3600 ); + return ( n == 1 ? 'an hour' : n + ' hours' ) + ' ago'; + } + + n = Math.round( seconds/86400 ); + return ( n == 1 ? 'yesterday' : n + ' days ago' ); +} + +// utility method, may move elsewhere +function scrollTo( element ) { + var x = 0, y = 0; + + while ( element ) { + x += element.offsetLeft; + y += element.offsetTop; + element = element.offsetParent; + } + + doc.defaultView.scrollTo( x, y ); +} + +function getHighestRoundsOption() { + var elt, highest, highestElt, opts, i, end, opt, n; + + elt = roundsSelect; + highest = -1; + highestElt = null; + opts = elt.options; + for ( i = 0, end = opts.length; i < end; i++) { + opt = opts[ i ]; + n = parseInt( opt.value ); + if ( n > highest) { + highest = n; + highestElt = opt; + } + } + + return highestElt; +} + +// Perhaps this could be merged with the rounds function in combat.js. +function selectHighestRounds() { + getHighestRoundsOption().selected = true; +} + +function setupFitRounds() { + form.addEventListener( 'submit', onFormSubmit ); +} + +function onFormSubmit( event ) { + var ql, newQL, match, option, rounds, maxRounds; + + if ( !pageScanned ) { + console.warn( 'Trapping submits before page scan, this is a bug' ); + return; + } + + // Figure out if the readListTextarea value ends with a number of + // rounds, and optionally a QL exclude mode. + ql = readListTextarea.value; + match = /;\s*([0-9][0-9\s]*)(?:;\s*([es])\s*)?$/.exec( ql ); + + if ( match ) { + // Ok, now see if the number of rounds is larger than the + // maximum option in the rounds select. + rounds = parseInt( match[ 1 ].replace( /\s+/g, '' ) ); + maxRounds = parseInt ( getHighestRoundsOption().value ); + + if ( rounds > maxRounds ) { + // Yup. We need to fix this and replace the textarea value. + newQL = ql.substr( 0, match.index ) + ';' + maxRounds; + if ( match[ 2 ] ) { + newQL += ( ';' + match[ 2 ] ); + } + readListTextarea.value = newQL; + } + } +} + +start(); -var pswAmbushScreenDriver = new PSWAmbushScreenDriver(document); +})( document, ConfigurationSet, Universe ); diff --git a/bg.js b/bg.js index c9b4013..fa12af6 100644 --- a/bg.js +++ b/bg.js @@ -1,409 +1,537 @@ -// Pardus Sweetener -// The guts. - -// Include Alarm and Notifier before this. - -// The following objects have a simple duty: they convert values -// stored in localStorage to and from values we can use in the rest of -// the extension. The method 'parse' assumes the value supplied comes -// from localStorage, so null will be assumed to mean the option -// wasn't saved yet, and the default value will be supplied. The -// objects don't access localStorage directly, because this -// translation is also useful for storage events, in which case we get -// the values through other means. - -function BooleanOption(defaultValue) { this.defaultValue = defaultValue; } -BooleanOption.prototype = { - DICT: { 'true': true, 'false': false }, - stringify: function(value) { return value ? 'true' : 'false'; }, - parse: function(string_value) { - var r = this.DICT[string_value]; - return r == undefined ? this.defaultValue : r; - } +// This is the extension proper, the event page. We've taken most +// functionality out from it. What remains is code to sound the alarm, +// display notifications, and obtain sector maps from JSON files +// installed with the extension. + +'use strict'; + +(function( window ) { + +// These hold our state +var config, connections, audio, currentSoundId, notificationTimer; + +function start() { + // We set an initial value for config here because, in this + // particular page, we have no guarantee that onConfigurationReady + // will be called with a full config. The very first time the + // extension runs, it'll be called with an empty 'items', then + // onInstall will complete and trigger onConfigurationChange. + // These defaults will then be overwritten with sensible values + // anyway, but we need config to have the properties by the time + // onConfigurationChange is called, because of the logic in + // that function. + config = { muteAlarm: null, alarmSound: null }; + + connections = []; + chrome.storage.onChanged.addListener( onConfigurationChange ); + chrome.runtime.onInstalled.addListener( onInstalled ); + chrome.runtime.onMessage.addListener( onMessage ); + chrome.runtime.onConnect.addListener( onConnect ); + chrome.storage.local.get( [ 'muteAlarm', 'alarmSound' ], + onConfigurationReady ); } -function StringOption(defaultValue) { this.defaultValue = defaultValue; } -StringOption.prototype = { - stringify: function(value) { return value; }, - parse: function(string_value) { - return (string_value == null) ? this.defaultValue : string_value; - } -}; - - -// The main object - -function PardusSweetener() { - this.options = { - muteAlarm: new BooleanOption(false), - alarmSound: new StringOption('timex'), - alarmCombat: new BooleanOption(true), - alarmAlly: new BooleanOption(false), - alarmWarning: new BooleanOption(false), - alarmPM: new BooleanOption(false), - alarmMission: new BooleanOption(false), - alarmTrade: new BooleanOption(false), - alarmPayment: new BooleanOption(false), - alarmInfo: new BooleanOption(false), - - desktopCombat: new BooleanOption(true), - desktopAlly: new BooleanOption(true), - desktopWarning: new BooleanOption(false), - desktopPM: new BooleanOption(true), - desktopMission: new BooleanOption(false), - desktopTrade: new BooleanOption(false), - desktopPayment: new BooleanOption(false), - desktopInfo: new BooleanOption(false), - - clockUTC: new BooleanOption(false), - clockAP: new BooleanOption(true), - clockB: new BooleanOption(true), - clockP: new BooleanOption(true), - clockS: new BooleanOption(true), - clockL: new BooleanOption(false), - clockE: new BooleanOption(false), - clockN: new BooleanOption(false), - clockZ: new BooleanOption(false), - clockR: new BooleanOption(false), - - pvpMissileAutoAll: new BooleanOption(true), - pvpHighestRounds: new BooleanOption(true), - pvmMissileAutoAll: new BooleanOption(false), - pvmHighestRounds: new BooleanOption(false), - pvbMissileAutoAll: new BooleanOption(true), - - autobots: new BooleanOption(false), - autobotsArtemisPreset: new StringOption('0'), - autobotsArtemisPoints: new StringOption('0'), - autobotsArtemisStrength: new StringOption('36'), - autobotsOrionPreset: new StringOption('0'), - autobotsOrionPoints: new StringOption('0'), - autobotsOrionStrength: new StringOption('36'), - autobotsPegasusPreset: new StringOption('0'), - autobotsPegasusPoints: new StringOption('0'), - autobotsPegasusStrength: new StringOption('36'), - - displayDamage: new BooleanOption(true), - previousShipStatus: new StringOption(''), - - navEquipmentLink: new BooleanOption(true), - navPlanetTradeLink: new BooleanOption(true), - navSBTradeLink: new BooleanOption(true), - navBldgTradeLink: new BooleanOption(true), - navBMLink: new BooleanOption(true), - navHackLink: new BooleanOption(true), - navBBLink: new BooleanOption(true), - - navShipLinks: new BooleanOption(true), - - overrideAmbushRounds: new BooleanOption(true), - allianceQLsArtemisEnabled: new BooleanOption(true), - allianceQLsArtemis: new StringOption('[]'), - allianceQLsArtemisMTime: new StringOption('0'), - personalQLArtemisEnabled: new BooleanOption(false), - personalQLArtemis: new StringOption(''), - allianceQLsOrionEnabled: new BooleanOption(true), - allianceQLsOrion: new StringOption('[]'), - allianceQLsOrionMTime: new StringOption('0'), - personalQLOrionEnabled: new BooleanOption(false), - personalQLOrion: new StringOption(''), - allianceQLsPegasusEnabled: new BooleanOption(true), - allianceQLsPegasus: new StringOption('[]'), - allianceQLsPegasusMTime: new StringOption('0'), - personalQLPegasusEnabled: new BooleanOption(false), - personalQLPegasus: new StringOption(''), - - miniMap: new BooleanOption(true), - miniMapPosition: new StringOption('topright'), - sendmsgShowAlliance: new BooleanOption(true) - }; - this.ports = new Array(); - this.alarm = new Alarm(this.options.alarmSound.parse(localStorage['alarmSound'])); - this.mute = this.options.muteAlarm.parse(localStorage['muteAlarm']); - this.notifier = new Notifier(); - - var self = this; - this.storageEventListener = function(e) { self.handleStorage(e); }; - this.onConnectEventListener = function(port) { self.handleConnect(port); }; - - chrome.extension.onConnect.addListener(this.onConnectEventListener); - window.addEventListener('storage', this.storageEventListener, false); +function onConfigurationReady( items ) { + var key; + + for ( key in items ) { + config[ key ] = items [ key ]; + } + + updateAlarmState(); +} + +function onConfigurationChange( changes, area ) { + var updated, key; + + if ( area != 'local' ) { + return; + } + + updated = false; + + for ( key in changes ) { + if ( config.hasOwnProperty( key ) ) { + config[ key ] = changes[ key ].newValue; + updated = true; + } + } + + if ( updated ) { + updateAlarmState(); + } + else { + // We do check for a couple things here. + var items = {}, change, save; + + change = changes[ 'allianceQLsArtemisEnabled' ]; + if ( change && !change.newValue ) { + items.allianceQLsArtemis = []; + items.allianceQLsArtemisMTime = 0; + save = true; + } + + change = changes[ 'allianceQLsOrionEnabled' ]; + if ( change && !change.newValue ) { + items.allianceQLsOrion = []; + items.allianceQLsOrionMTime = 0; + save = true; + } + + change = changes[ 'allianceQLsArtemisEnabled' ]; + if ( change && !change.newValue ) { + items.allianceQLsPegasus = []; + items.allianceQLsPegasusMTime = 0; + save = true; + } + + if ( save ) { + chrome.storage.local.set( items ); + } + } +} + +function onMessage( request, sender, sendResponse ) { + var asyncResponse = false; + + if ( sender.tab ) { + // Show the page action for all tabs sending us messages. This + // is slightly iffy but hey, it works. + showPageAction( sender.tab.id ); + } + + if ( request.hasOwnProperty( 'requestMap' ) ) { + asyncResponse = requestMap( request.requestMap, sendResponse ); + } + else if ( request.hasOwnProperty( 'desktopNotification' )) { + if ( request.desktopNotification ) { + showDesktopNotification( + request.title || 'Meanwhile, in Pardus...', + request.desktopNotification, request.timeout ); + sendResponse( true ); + } + else { + clearDesktopNotification(); + sendResponse( false ); + } + } + + return asyncResponse; +} + +function showPageAction( tabId ) { + var icon = ( config && config.muteAlarm ) ? + 'icons/19mute.png' : 'icons/19.png'; + chrome.pageAction.setIcon({ path: icon, tabId: tabId }); + chrome.pageAction.show( tabId ); +} + +function requestMap( sectorName, callback ) { + var rq = new XMLHttpRequest(), + url = chrome.runtime.getURL( 'map/' + sectorName[0] + '/' + + sectorName.replace( ' ', '_' ) + '.json'), + listener = function() { + onMapReadyStateChange( rq, callback ); + }; + + rq.onreadystatechange = listener; + rq.open( 'get', url ); + rq.send(); + + return true; +} + +function onMapReadyStateChange( rq, callback ) { + if ( rq.readyState != 4 ) { + return; + } + + if ( rq.status == 200 ) { + callback( JSON.parse( rq.responseText ) ); + } + else { + callback({ error: 'No map' }); + } +} + +// Alarm stuff. + +// We use long-lived ports for control of the alarm. This is because +// the alarm is an Audio object created by us, the event page, and so +// we don't want to be unloaded by Chrome while the alarm is ringing, +// because that would stop the sound. However, we also don't want to +// lock the event page in memory whenever a tab is navigating Pardus +// (which was our behaviour on previous versions), and, even more +// importantly, we don't want our racket to go on if the last of the +// tabs that told us to sound the alarm goes away, without telling us +// to shut it. +// +// Ports give us the behaviour we want. When a tab or the page action +// wants us to sound the alarm, we have it connect to us, and we have +// it keep the connection for as long as it wants the alarm ringing. +// While the port is connected, we can't be unloaded by Chrome. When +// the last of the tabs holding us for alarm functionality goes away, +// so does its port, and we get notified and act accordingly. +function onConnect ( port ) { + var connection = { port: port }, + messageListener = function( message ) { + onPortMessage( connection, message ); + }, + disconnectListener = function() { + onPortDisconnect( connection ); + }; + + connections.push( connection ); + port.onMessage.addListener( messageListener ); + port.onDisconnect.addListener( disconnectListener ); +} + +function onPortMessage( connection, message ) { + for ( var key in message ) { + switch ( key ) { + case 'alarm': + connection.alarm = message.alarm; + updateAlarmState(); + break; + case 'watchAlarm': + // assignment, not comparison: + if (( connection.watchAlarm = message.watchAlarm )) { + // and give it an immediate update + var state = + ( audio && audio.readyState == 4 && !audio.paused ) ? + true : false; + connection.port.postMessage({ alarmState: state }); + } + } + } +} + +function onPortDisconnect( connection ) { + var index = connections.indexOf( connection ); + if ( index != -1 ) { + connections.splice( index, 1 ); + } + updateAlarmState(); +} + +// This function turns the alarm on and off as needed. +function updateAlarmState() { + if ( alarmWanted() ) { + // Bring the noise + if ( audio ) { + if ( currentSoundId == config.alarmSound ) { + if ( audio.readyState == 4 ) { + if ( audio.paused ) { + audio.play(); + postAlarmState( true ); + } + // else do nothing, we were already playing + } + // else do nothing, the canplay listener will call us again. + } + else { + // We need to change the sound id + setAlarmSound(); + } + } + else { + // No audio yet, create it + audio = new Audio(); + audio.loop = true; + audio.addEventListener( 'canplaythrough', onAudioCanPlayThrough ); + setAlarmSound(); + // and return now. the canplay listener will call us again. + } + } + else { + // Shut it + if ( audio && audio.readyState == 4 && !audio.paused ) { + audio.pause(); + audio.currentTime = 0; + postAlarmState( false ); + } + // otherwise do nothing, the alarm isn't playing + } } -PardusSweetener.prototype = { - handleConnect: function(port) { - var self = this; - var pi = { port: port, keys: new Object() }; - - pi.messageListener = function(msg) { self.handleMessage(pi, msg); }; - pi.disconnectListener = function(port) { self.handleDisconnect(pi); }; - - this.ports.push(pi); - port.onDisconnect.addListener(pi.disconnectListener); - port.onMessage.addListener(pi.messageListener); - - if(pi.port.sender && pi.port.sender.tab) { - var tab = pi.port.sender.tab.id; - var path = this.mute ? 'icons/19mute.png' : 'icons/19.png'; - chrome.pageAction.setIcon({ path: path, tabId: tab }); - chrome.pageAction.show(tab); - } - - //console.log('connect - have ' + this.ports.length + ' ports'); - }, - - handleDisconnect: function(pi) { - for(var i = this.ports.length - 1; i >= 0; i--) { - if(pi === this.ports[i]) { - this.ports.splice(i, 1); - break; - } - } - - // this is likely not needed but hell, lets help the garbage collector - pi.port.onDisconnect.removeListener(pi.disconnectListener); - pi.port.onMessage.removeListener(pi.messageListener); - delete pi.disconnectListener; - delete pi.messageListener; - - if(this.ports.length < 1) { - this.alarm.switchOff(); - this.notifier.hide(); - } - - //console.log('disconnect - have ' + this.ports.length + ' ports'); - }, - - handleMessage: function(pi, msg) { - if(msg.op) { - var hname = msg.op + "MsgHandler"; - if(hname in this) - this[hname](pi, msg); - } - }, - - subscribeMsgHandler: function(pi, msg) { - var keys = msg.keys; - if(keys && keys.length) { - pi.keys = new Object(); - for(var i = keys.length - 1; i >= 0; i--) { - var key = keys[i]; - var option = this.options[key]; - if(option) { - pi.keys[key] = true; - var v = option.parse(localStorage[key]); - pi.port.postMessage({ op: 'updateValue', key: key, value: v }); - //console.log('subscription added to ' + key); - } - } - } - }, - - setValueMsgHandler: function(pi, msg) { - var option = this.options[msg.key]; - if(option) { - var v = option.stringify(msg.value); - localStorage[msg.key] = v; - - // some specific tweaks we do on special events.. - switch(msg.key) { - case 'alarmSound': - this.alarm.selectSample(v, - function() { - pi.port.postMessage({op: 'sampleReady', sample: v}); - }); - break; - case 'muteAlarm': - this.mute = msg.value; - for(var i = this.ports.length - 1; i >= 0; i--) { - var port = this.ports[i].port; - if(port.sender && port.sender.tab) - chrome.pageAction.setIcon({ path: this.mute ? 'icons/19mute.png' : 'icons/19.png', - tabId: port.sender.tab.id }); - } - } - - // apparently we won't get a storage event to trigger this, - // because that's only for changes from another window (XXX - need - // more research into this) - this.postUpdateValueNotifications(msg.key, msg.value, pi); - } - }, - - requestListMsgHandler: function(pi, msg) { - if(msg.name == 'alarmSound') - pi.port.postMessage({ op: 'updateList', - name: 'alarmSound', - list: Alarm.prototype.sounds }); - }, - - dispatchNotificationsMsgHandler: function(pi, msg) { - //console.log('dispatch ' + JSON.stringify(msg)); - if(!this.options.muteAlarm.parse(localStorage['muteAlarm']) && - this.testIndicators('alarm', msg.indicators)) - this.alarm.switchOn(); - else - this.alarm.switchOff(); - - if(this.testIndicators('desktop', msg.indicators)) - this.notifier.show('Meanwhile, in Pardus...', - this.indicatorsToHuman(msg.character_name, msg.indicators)); - else - this.notifier.hide(); - }, - - soundAlarmMsgHandler: function(pi, msg) { - if(!this.options.muteAlarm.parse(localStorage['muteAlarm'])) - this.alarm.switchOn(); - }, - - stopAlarmMsgHandler: function(pi, msg) { - this.alarm.switchOff(); - }, - - testNotificationMsgHandler: function(pi, msg) { - this.notifier.hide(); - this.notifier.show('Meanwhile, in Pardus...', - 'You requested a sample desktop notification.'); - }, - - showNotificationMsgHandler: function(pi, msg) { - this.notifier.hide(); - this.notifier.show(msg.title, msg.message, msg.duration); - }, - - requestMapMsgHandler: function(pi, msg) { - var sectorName = msg.sector; - var rq = new XMLHttpRequest(), - url = chrome.runtime.getURL('map/' + sectorName[0] + '/' + - sectorName.replace(' ', '_') + '.json'); - rq.onreadystatechange = this.onMapRSC.bind(this, pi); - rq.open('get', url); - rq.send(); - }, - - onMapRSC: function(pi, event) { - var rq = event.target; - if(rq.readyState != 4) - return; - if(rq.status == 200) { - var sector = JSON.parse(rq.responseText); - this.postUpdateMap(pi, sector); - } - else - this.postUpdateMap(pi, null); - }, - - postUpdateMap: function(pi, sector) { - var msg = { op: 'updateMap' }; - if(sector) - msg.sector = sector; - else - msg.error = true; - pi.port.postMessage(msg); - }, - - // This is supposedly called on storage events. We haven't seen one - // yet, we need to research more about this... XXX - handleStorage: function(e) { - console.log("XXX handleStorage", e); - var option = this.options[e.key]; - if(option) - this.postUpdateValueNotifications(e.key, option.parse(e.newValue), null); - }, - - postUpdateValueNotifications: function(key, value, exclude_pi) { - for(var i = this.ports.length - 1; i >= 0; i--) { - var pi = this.ports[i]; - if(pi === exclude_pi) - continue; - - // check if this port requested notification for this key; if so, - // notify them - - if(pi.keys[key]) - pi.port.postMessage({ op: 'updateValue', key: key, value: value }); - } - }, - - testIndicators: function(prefix, indicators) { - var r = false; - for(var suffix in indicators) { - var key = prefix + suffix; - var option = this.options[key]; - if(option) { - if(option.parse(localStorage[key])) { - r = true; - break; - } - } - } - return r; - }, - - indicatorsToHuman: function(character_name, indicators) { - var a = new Array(); - var pendings, warn, notifs, stuff; - - if(indicators['Warning']) - warn = 'There is a game warning you should see in the message frame.'; - else if(indicators['Info']) - warn = 'There is some information for you in the message frame.'; - - if(indicators['Ally']) - a.push('alliance'); - if(indicators['PM']) - a.push('private'); - if(a.length > 0) { - pendings = 'unread ' + a.join(' and ') + ' messages'; - a.length = 0; - } - - if(indicators['Trade'] || indicators['Payment']) - a.push('money'); - if(indicators['Mission']) - a.push('mission'); - if(a.length > 0) { - notifs = a.join(' and ') + ' notifications'; - a.length = 0; - } - - if(pendings) - a.push(pendings); - if(notifs) - a.push(notifs); - if(a.length > 0) { - stuff = a.join(', and ') + '.'; - a.length = 0; - } - - if(warn) - a.push(warn); - - if(indicators['Combat'] || stuff) { - if(character_name) - a.push((warn ? 'And your' : 'Your') + ' character ' + character_name); - else - a.push((warn ? 'And a' : 'A') + ' character of yours'); - - if(indicators['Combat']) { - a.push('has been fighting with someone.'); - if(stuff) { - if(character_name) - a.push(character_name + ' also has'); - else - a.push('You also have'); - a.push(stuff); - } - } - else { - a.push('has'); - a.push(stuff); - } - } - - return a.join(' '); - } -}; - -var sweetener = new PardusSweetener(); +function onAudioCanPlayThrough() { + updateAlarmState(); +} + +// Figure out whether the alarm should be playing now. +function alarmWanted() { + if ( !config.muteAlarm ) { + for ( var i = 0, end = connections.length; i < end; i++ ) + if ( connections[ i ].alarm ) + return true; + } + return false; +} + +// This is always called when audio already exists, we make sure of that. +function setAlarmSound() { + var soundId = config.alarmSound; + + audio.src = 'sounds/' + soundId + '.ogg'; + currentSoundId = soundId; +} + +function postAlarmState( state ) { + var message = { alarmState: state }; + for ( var i = 0, end = connections.length; i < end; i++ ) { + var connection = connections[ i ]; + if ( connection.watchAlarm ) { + connection.port.postMessage( message ); + } + } +} + +function showDesktopNotification( title, text, timeout ) { + var options = { + type: 'basic', + title: title, + message: text, + iconUrl: 'icons/48.png' + }; + + if ( notificationTimer ) { + window.clearTimeout( notificationTimer ); + notificationTimer = undefined; + } + + if ( timeout > 0 && timeout < 20000 ) { + notificationTimer = window.setTimeout( onNotificationExpired, timeout ); + } + + chrome.notifications.clear( 'pardus-sweetener', function(){} ); + chrome.notifications.create( 'pardus-sweetener', options, function(){} ); +} + +function clearDesktopNotification() { + if ( notificationTimer ) { + window.clearTimeout( notificationTimer ); + notificationTimer = undefined; + } + + chrome.notifications.clear( 'pardus-sweetener', function(){} ); +} + +function onNotificationExpired() { + notificationTimer = undefined; + chrome.notifications.clear( 'pardus-sweetener', function(){} ); +} + +// There should be a way to get this out of the way. It's only needed +// when installing/upgrading, yet it's always here making this script +// fat. + +function onInstalled( details ) { + var cfg; + + switch ( details.reason ) { + case 'install': + // default values of all our parameters + cfg = { + alarmAlly: false, + alarmCombat: true, + alarmInfo: false, + alarmMission: false, + alarmPM: false, + alarmPayment: false, + alarmSound: 'timex', + alarmTrade: false, + alarmWarning: false, + allianceQLsArtemis: [], + allianceQLsArtemisEnabled: true, + allianceQLsArtemisMTime: 0, + allianceQLsOrion: [], + allianceQLsOrionEnabled: true, + allianceQLsOrionMTime: 0, + allianceQLsPegasus: [], + allianceQLsPegasusEnabled: true, + allianceQLsPegasusMTime: 0, + autobots: false, + autobotsArtemisPoints: 0, + autobotsArtemisPreset: 0, + autobotsArtemisStrength: 36, + autobotsOrionPoints: 0, + autobotsOrionPreset: 0, + autobotsOrionStrength: 36, + autobotsPegasusPoints: 0, + autobotsPegasusPreset: 0, + autobotsPegasusStrength: 36, + clockAP: true, + clockB: true, + clockE: false, + clockL: false, + clockN: false, + clockP: true, + clockR: false, + clockS: true, + clockUTC: false, + clockZ: false, + desktopAlly: true, + desktopCombat: true, + desktopInfo: false, + desktopMission: false, + desktopPM: true, + desktopPayment: false, + desktopTrade: false, + desktopWarning: false, + displayDamage: true, + fitAmbushRounds: true, + miniMap: true, + miniMapPlacement: 'topright', + muteAlarm: false, + navBlackMarketLink: true, + navBountyBoardLink: false, + navBulletinBoardLink: true, + navCrewQuartersLink: false, + navEquipmentLink: true, + navFlyCloseLink: true, + navHackLink: true, + navShipLinks: true, + navShipyardLink: false, + navTradeLink: true, + overrideAmbushRounds: false, + personalQLArtemis: '', + personalQLArtemisEnabled: false, + personalQLOrion: '', + personalQLOrionEnabled: false, + personalQLPegasus: '', + personalQLPegasusEnabled: false, + pvbMissileAutoAll: true, + pvmHighestRounds: false, + pvmMissileAutoAll: false, + pvpHighestRounds: true, + pvpMissileAutoAll: true, + sendmsgShowAlliance: true + }; + + chrome.storage.local.clear(); + chrome.storage.local.set( cfg, function(){} ); + break; + + case 'update': + // localeCompare, aye. Kinda flaky, this, but we've only + // released public versions 2.2, 2.3, and 2.4. Should be + // fine. + if ( details.previousVersion.localeCompare('2.5') >= 0 ) + return; + + // Here we migrate the previous Sweetener configurations, held + // on the event page's localStorage, to chrome.storage. + + cfg = new Object(); + + // These were strings with the given defaults + cfg[ 'alarmSound' ] = localStorage[ 'alarmSound' ] || 'timex'; + cfg[ 'personalQLArtemis' ] = localStorage[ 'personalQLArtemis' ] || ''; + cfg[ 'personalQLOrion' ] = localStorage[ 'personalQLOrion' ] || ''; + cfg[ 'personalQLPegasus' ] = localStorage[ 'personalQLPegasus' ] || ''; + cfg[ 'miniMapPlacement' ] = + localStorage[ 'miniMapPosition' ] || 'topright'; + + // All these were booleans with default to false + var i, end, key, val, keys = [ + 'muteAlarm', 'alarmAlly', 'alarmWarning', + 'alarmPM', 'alarmMission', 'alarmTrade', 'alarmPayment', + 'alarmInfo', 'desktopWarning', 'desktopMission', + 'desktopTrade', 'desktopPayment', 'desktopInfo', + 'clockUTC', 'clockL', 'clockE', 'clockN', 'clockZ', 'clockR', + 'pvmMissileAutoAll', 'pvmHighestRounds', 'autobots', + 'personalQLArtemisEnabled', 'personalQLOrionEnabled', + 'personalQLPegasusEnabled' + ]; + for ( i = 0, end = keys.length; i < end; i++ ) { + key = keys[ i ]; + cfg[ key ] = ( localStorage[key] == 'true' ); + } + + // These were booleans with default to true + keys = [ + 'alarmCombat', 'desktopCombat', 'desktopAlly', 'desktopPM', + 'clockAP', 'clockB', 'clockP', 'clockS', + 'pvpMissileAutoAll', 'pvpHighestRounds', 'pvbMissileAutoAll', + 'displayDamage', 'navEquipmentLink', 'navHackLink', 'navShipLinks', + 'allianceQLsArtemisEnabled', 'allianceQLsOrionEnabled', + 'allianceQLsPegasusEnabled', 'miniMap', 'sendmsgShowAlliance' + ]; + for ( i = 0, end = keys.length; i < end; i++ ) { + key = keys[ i ]; + cfg[ key ] = ( localStorage[key] != 'false' ); + } + + // These three are now just one. They all defaulted to true. + // If all three were disabled, we disable the new option. + cfg[ 'navTradeLink' ] = + !( localStorage[ 'navPlanetTradeLink' ] == 'false' && + localStorage[ 'navSBTradeLink' ] == 'false' && + localStorage[ 'navBldgTradeLink' ] == 'false' ); + + // These two were renamed. They defaulted to true. + cfg[ 'navBlackMarketLink' ] = localStorage[ 'navBMLink' ] != 'false'; + cfg[ 'navBulletinBoardLink' ] = localStorage[ 'navBBLink' ] != 'false'; + + // These defaulted to '0' and are now integers + keys = [ 'autobotsArtemisPreset', 'autobotsArtemisPoints', + 'autobotsOrionPreset', 'autobotsOrionPoints', + 'autobotsPegasusPreset', 'autobotsPegasusPoints', + 'allianceQLsArtemisMTime', 'allianceQLsOrionMTime', + 'allianceQLsPegasusMTime' ]; + for ( i = 0, end = keys.length; i < end; i++ ) { + key = keys[ i ]; + val = parseInt( localStorage[key] ); + cfg[ key ] = isNaN( val ) ? 0 : val; + } + + // These defaulted to '36' and are now integers + val = parseInt( localStorage[ 'autobotsPegasusStrength' ] ); + cfg[ 'autobotsPegasusStrength' ] = isNaN( val ) ? 36 : val; + val = parseInt( localStorage[ 'autobotsArtemisStrength' ] ); + cfg[ 'autobotsArtemisStrength' ] = isNaN( val ) ? 36 : val; + val = parseInt( localStorage[ 'autobotsOrionStrength' ] ); + cfg[ 'autobotsOrionStrength' ] = isNaN( val ) ? 36 : val; + + // These were JSON stringified arrays, and we haven't changed + // the array contents, so we'll just copy as is if they were set. + keys = [ 'allianceQLsArtemis', 'allianceQLsOrion', + 'allianceQLsPegasus' ]; + for ( i = 0, end = keys.length; i < end; i++ ) { + key = keys[ i ]; + try { val = JSON.parse( localStorage[key] ); } + catch ( x ) { val = null; } + cfg[ key ] = Array.isArray( val ) ? val : []; + } + + // We don't migrate previousShipStatus. It is no longer stored + // in config. + + // And these are new + cfg.navShipyardLink = false; + cfg.navCrewQuartersLink = false; + cfg.navFlyCloseLink = true; + + // Well, overrideAmbushRounds did exist. However, it was a bit + // hamfisted, and now we have fitAmbushRounds, which is + // better. People who had oAR enabled, probably will be + // better served by fAR; people who had it disabled... well it + // probably was because of the hamfist thing, and fAR isn't + // likely to bother them. So we don't migrate and set the + // defaults here instead. + cfg.fitAmbushRounds = true; + cfg.overrideAmbushRounds = false; + + chrome.storage.local.clear(); + chrome.storage.local.set( cfg, onUpgradeComplete ); + } // switch +} + +function onUpgradeComplete() { + localStorage.clear(); +} + +// Start the ball +start(); + +})( window ); diff --git a/building.js b/building.js deleted file mode 100644 index b752616..0000000 --- a/building.js +++ /dev/null @@ -1,49 +0,0 @@ -// Building content script. What you get when you click on "Enter building". -// Load universe.js, combat.js and shiplinks.js before this. - -var port; -var pswCombatScreenDriver; - -function messageHandler(msg) { - if(msg.op == 'updateValue' && msg.key == 'navShipLinks' && msg.value) - showShipLinks(); -} - -var mslrx = /building\.php\?detail_type=([A-Za-z]+)&detail_id=(\d+)$/; -function matchShipLink(url) { - // this could be smarter, doing proper URL-decode of the - // building.php query string... but it isn't likely that'll be - // needed, and it would slow things down.. - var r; - var m = mslrx.exec(url); - if(m) - r = { type: m[1], id: parseInt(m[2]) }; - - return r; -} - -function showShipLinks() { - var ships = getShips(document, - "//table/tbody[tr/th = 'Other Ships']/tr/td/a", - matchShipLink); - addShipLinks(ships); -} - -function run() { - var configmap = { pvbMissileAutoAll: 'missileAutoAll', - autobots: 'autobots', - displayDamage: 'displayDamage', - previousShipStatus: 'previousShipStatus' }; - var universe = universeName(); - configmap[ 'autobots' + universe + 'Points' ] = 'autobotsPoints'; - configmap[ 'autobots' + universe + 'Strength' ] = 'autobotsStrength'; - - port = chrome.extension.connect(); - pswCombatScreenDriver = new PSWCombatScreenDriver(document, port, configmap); - - port.onMessage.addListener(messageHandler); - var keys = pswCombatScreenDriver.configkeys.concat(['navShipLinks']); - port.postMessage({ op: 'subscribe', keys: keys }); -} - -run(); diff --git a/cfgset.js b/cfgset.js new file mode 100644 index 0000000..c9a25c2 --- /dev/null +++ b/cfgset.js @@ -0,0 +1,144 @@ +// This is simplifies our handling of configuration. +// +// There is some magic here, but honest, it's not us complicating +// things gratuitely. We were writing variations of this code in every +// content script. +// +// The way this is used is: create a ConfigurationSet, use addKey to +// tell it which configuration entries you're interested in, and then +// use makeTracker to create another object that actually holds the +// values, keeps itself up to date, and tells you when changes occur. +// +// This may seem needlessly complicated, the two objects, but at some +// point we may create prototypes that persist across frame reloads. +// And then it pays off to define the ConfigurationSet just once, +// store it in some prototype, and then just call makeTracker when +// instantiating. + +'use strict'; + +function ConfigurationSet() { + + // This is a dictionary of configuration keys to property names. + + this.parameters = {}; + +}; + +ConfigurationSet.prototype = { + + // Tells the ConfigurationSet that you want to track a particular + // parameter. Key is the name with which chrome.storage stores + // the parameter. The parameter value will become a property of + // trackers, with name propertyName. If propertyName is null or + // omitted, it defaults to key. + // + // Most times you'll want both to be the same, but you'll use + // different names when you're watching, e.g., + // 'allianceQLsArtemisEnabled', but want to refer to it as + // tracker.allianceQLsEnabled. + + addKey: function( key, propertyName ) { + var reservedPropertyNames = { callback: true, releaseStorage: true }; + + if ( typeof key != 'string' || key.length == 0 ) { + throw new Error( 'bad key' ); + } + + // Deliberate non strict equality; propertyName may be null. + if ( propertyName == undefined ) { + propertyName = key; + } + else if ( typeof propertyName != 'string' || + propertyName.length == 0 ) { + throw new Error( 'bad propertyName' ); + } + + if ( reservedPropertyNames[propertyName] ) { + throw new Error( '"' + propertyName + '" is a reserved name' ); + } + + this.parameters[ key ] = propertyName; + }, + + // Creates an object that serves two purposes. + // + // First, it requests and holds the values of all parameters that + // we're interested in; you can access them as the object's own + // enumerable properties. + // + // Second, it calls the given callback when the requested + // parameters are received from storage. It also listens for + // changes on the specified parameters, and calls the callback + // again whenever those happen. + // + // The callback receives arguments that you can use if you need + // different behaviour on the initial get, than on subsequent + // changes. For most cases, though, you probably can ignore + // arguments and just use the properties on the tracker object. + // + // Be advised: the tracker object registers a listener with + // chrome.storage. You don't usually need to worry about this, as + // Chrome will clean up after you when your window disappears. + // However, if your window outlives the objects that need your + // tracker, then be aware that chrome.storage will keep your + // tracker alive even after you've lost all references to + // it. In such cases, you probably want to explicitly remove the + // listener - just call the tracker's method releaseStorage when + // you know you no longer need it. + + makeTracker: function ( callback ) { + var parameters = this.parameters, + tracker = {}, + onStorageChange = function( changes, area ) { + var updated, propertyName; + + if ( area == 'local' ) { + for ( var key in changes ) { + propertyName = parameters[ key ]; + if ( propertyName ) { + tracker[ propertyName ] = changes[ key ].newValue; + updated = true; + } + } + if ( updated ) { + tracker.callback( 'change', changes ); + } + } + }, + onStorageGet = function( items ) { + for ( var key in items ) { + Object.defineProperty( tracker, parameters[ key ], { + value: items[ key ], + writable: true, + enumerable: true, + configurable: false + } ); + } + tracker.callback( 'get', items ); + chrome.storage.onChanged.addListener(onStorageChange); + }, + releaseStorage = function() { + chrome.storage.onChanged.removeListener(onStorageChange); + }; + + Object.defineProperties( tracker, { + callback: { + value: callback, + writable: true, + enumerable: false, + configurable: false + }, + releaseStorage: { + value: releaseStorage, + writable: false, + enumerable: false, + configurable: false + } + } ); + + chrome.storage.local.get( Object.keys( parameters ), onStorageGet ); + return tracker; + } + +}; diff --git a/clock.js b/clock.js index 183c37e..f6f3e60 100644 --- a/clock.js +++ b/clock.js @@ -1,371 +1,458 @@ -// server reset is at 5.30 UTC every day - -function APTimer(doc) { - this.createNode(doc, 'AP', 'Time to next 24 AP and next shield recharge'); -} - -APTimer.prototype = { - createNode: function(doc, label, title) { - this.element = doc.createElement('span'); - this.element.appendChild(doc.createTextNode(' ' + label + ' ')); - var inner = doc.createElement('span'); - this.textNode = doc.createTextNode('-:--'); - inner.appendChild(this.textNode); - this.element.appendChild(inner); - this.element.appendChild(doc.createTextNode(' ')); - this.element.style.margin = '0 0 0 7px'; - this.element.style.cursor = 'default'; - this.element.title = title; - }, - - // 'now' is the Unix time, an integer, in seconds. - // AP ticks happen every 6 minutes, starting at minute 0. - // So period is 6m (360 s), and offset is zero - update: function(now) { - var next_ap = 359 - now % 360; - this.element.style.color = this.computeColour(next_ap, 10, 30, 60); - this.textNode.data = this.formatTime(next_ap); - }, - - formatTime: function(seconds) { - var hours = Math.floor(seconds / 3600); - seconds -= hours*3600; - var minutes = Math.floor(seconds / 60); - seconds -= minutes*60; - - var s; - if(hours > 0) { - s = hours + ':'; - if(minutes < 10) - s += '0'; - } - else - s = ''; - - s += minutes; - s += ':'; - if(seconds < 10) - s += '0'; - s += seconds; - - return s; - }, - - computeColour: function(now, red_threshold, - yellow_threshold, green_threshold) { - if(now <= red_threshold) - return 'red'; - if(now <= yellow_threshold) - return 'yellow'; - else if(now <= green_threshold) - return 'lime'; - - return 'inherit'; - } -}; - -function BuildingTimer(doc) { - this.createNode(doc, 'B', 'Time to next building tick'); -} - -// Building ticks happen every 6 hours, 25 minutes past the hour, -// starting at 01:00 UTC. -// Period is 6h (21600 s). Offset is 1h 25m (5100 s) -BuildingTimer.prototype = { - update: function(now) { - var next_tick = 21599 - (now-5100) % 21600; - this.element.style.color = this.computeColour(next_tick, 30, 180, 600); - this.textNode.data = this.formatTime(next_tick); - }, - - createNode: APTimer.prototype.createNode, - formatTime: APTimer.prototype.formatTime, - computeColour: APTimer.prototype.computeColour -}; - -function PlanetTimer(doc) { - this.createNode(doc, 'P', 'Time to next planet tick'); -} - -// Planet ticks happen every 3 hours, 25 minutes past the hour, -// starting at 02:00 UTC. -// Period is 3h (10800 s). Offset is 2h 25m (8700 s) -PlanetTimer.prototype = { - update: function(now) { - var next_tick = 10799 - (now-8700) % 10800; - this.element.style.color = this.computeColour(next_tick, 30, 180, 600); - this.textNode.data = this.formatTime(next_tick); - }, - - createNode: APTimer.prototype.createNode, - formatTime: APTimer.prototype.formatTime, - computeColour: APTimer.prototype.computeColour -}; - - -function StarbaseTimer(doc) { - this.createNode(doc, 'S', 'Time to next starbase tick'); -} - -// Starbase ticks happen every 3 hours, 25 minutes past the hour, -// starting at 0:00 UTC. -// Period is 3h (10800 s). Offset is 25m (8700 s) -StarbaseTimer.prototype = { - update: function(now) { - var next_tick = 10799 - (now-1500) % 10800; - this.element.style.color = this.computeColour(next_tick, 30, 180, 600); - this.textNode.data = this.formatTime(next_tick); - }, - - createNode: APTimer.prototype.createNode, - formatTime: APTimer.prototype.formatTime, - computeColour: APTimer.prototype.computeColour -}; - -function LeechTimer(doc) { - this.createNode(doc, 'L', 'Time to next leech armour repair'); -} - -// Leech ticks happen every 20 minutes, starting at 00:00 UTC -// Period is 20m (1200 s), offset is zero. -LeechTimer.prototype = { - update: function(now) { - var next_rep = 1199 - now % 1200; - this.element.style.color = this.computeColour(next_rep, 10, 60, 180); - this.textNode.data = this.formatTime(next_rep); - }, - - createNode: APTimer.prototype.createNode, - formatTime: APTimer.prototype.formatTime, - computeColour: APTimer.prototype.computeColour -}; - - -function EMatterTimer(doc) { - this.createNode(doc, 'E', 'Time to next e-matter regeneration'); -} - -// E-matter ticks happen every hour, starting at 05:31 UTC -// Period is 60m (3600 s), offset is 5h 31m (19860 s). -EMatterTimer.prototype = { - update: function(now) { - var next_rep = 3599 - (now-19860) % 3600; - this.element.style.color = this.computeColour(next_rep, 10, 60, 180); - this.textNode.data = this.formatTime(next_rep); - }, - - createNode: APTimer.prototype.createNode, - formatTime: APTimer.prototype.formatTime, - computeColour: APTimer.prototype.computeColour -}; - - -function NPCTimer(doc) { - this.createNode(doc, 'N', - 'Time to next roaming NPC move (not Z series, Lucidi)'); -} - -// NPCs roam 7 times an hour, at minutes 08, 17, 26, 35, 44, 53, 59. -// 7x period is 1h (3600 s), offset is 8m (480 s). But within that -// period, intervals are irregular... -NPCTimer.prototype = { - crontab: [ 539, // (17-8)*60 - 1 - 1079, // (26-8)*60 - 1 - 1619, // (35-8)*60 - 1 - 2159, // (44-8)*60 - 1 - 2699, // (53-8)*60 - 1 - 3059, // (59-8)*60 - 1 - 3599 // (68-8)*60 - 1 - ], - update: function(now) { - var n = (now-480) % 3600; - var next_rep; - //console.log(n); - for(var i = this.crontab.length - 1; i >= 0; i -= 1) { - var t = this.crontab[i]; - if(t < n) - break; - next_rep = t - n; - } - this.element.style.color = this.computeColour(next_rep, 10, 30, 60); - this.textNode.data = this.formatTime(next_rep); - }, - - createNode: APTimer.prototype.createNode, - formatTime: APTimer.prototype.formatTime, - computeColour: APTimer.prototype.computeColour -}; - - -function ZTimer(doc) { - this.createNode(doc, 'Z', 'Time to next Z series and Lucidi NPC move'); -} - -// Zs and Lucies roam in like fashion as other NPCs, but their timing -// is a bit different, at minutes 08, 17, 26, 33, 41, 51, 59. -ZTimer.prototype = { - crontab: [ 539, // (17-8)*60 - 1 - 1079, // (26-8)*60 - 1 - 1499, // (33-8)*60 - 1 - 1979, // (41-8)*60 - 1 - 2579, // (51-8)*60 - 1 - 3059, // (59-8)*60 - 1 - 3599 // (68-8)*60 - 1 - ], - update: NPCTimer.prototype.update, - createNode: APTimer.prototype.createNode, - formatTime: APTimer.prototype.formatTime, - computeColour: APTimer.prototype.computeColour -}; - - -function ResetTimer(doc) { - this.createNode(doc, 'R', 'Time to next server reset'); -} - -// Server reset occurs at 05:30 UTC every day. -// Period is 1d (86400 s). Offset is 5h 30m (19800 s) -ResetTimer.prototype = { - update: function(now) { - var next_tick = 86399 - (now-19800) % 86400; - this.element.style.color = this.computeColour(next_tick, 30, 180, 600); - this.textNode.data = this.formatTime(next_tick); - }, - - createNode: APTimer.prototype.createNode, - formatTime: APTimer.prototype.formatTime, - computeColour: APTimer.prototype.computeColour -}; - - -// This is not a timer per se, it just displays current UTC -function UTCTimer(doc) { - // ... and we call it "GMT" because people get confused otherwise - this.createNode(doc, 'GMT', 'Greenwich Mean Time'); -} - -UTCTimer.prototype = { - update: function(now) { - var t = now % 86400; - this.textNode.data = this.formatTime(t); - }, - - formatTime: function(seconds) { - var hours = Math.floor(seconds / 3600); - seconds -= hours*3600; - var minutes = Math.floor(seconds / 60); - seconds -= minutes*60; - - var s = ''; - if(hours < 10) - s += '0'; - s += hours; - s += ':'; - if(minutes < 10) - s += '0'; - s += minutes; - s += ':'; - if(seconds < 10) - s += '0'; - s += seconds; - - return s; - }, - - createNode: APTimer.prototype.createNode -}; - - -function VClock(doc) { - this.doc = doc; - var body = doc.querySelector('body'); - this.div = doc.createElement('div'); - //this.div.style.fontSize = '10px'; - this.div.style.position = 'fixed'; - this.div.style.top = 0; - this.div.style.right = '10px'; - this.div.style.width = 'auto'; - this.timers = new Object(); - - body.insertBefore(this.div, body.firstChild); -} - -VClock.prototype = { - timerInfo: { - AP: { tc: APTimer, order: 10 }, - B: { tc: BuildingTimer, order: 20 }, - P: { tc: PlanetTimer, order: 30 }, - S: { tc: StarbaseTimer, order: 40 }, - L: { tc: LeechTimer, order: 50 }, - E: { tc: EMatterTimer, order: 60 }, - N: { tc: NPCTimer, order: 70 }, - Z: { tc: ZTimer, order: 80 }, - R: { tc: ResetTimer, order: 90 }, - UTC: { tc: UTCTimer, order: 100 } - }, - - setEnabled: function(which, enabled) { - var changed; - var t = this.timers[which]; - - if(enabled) { - if(!t) { - t = new Object(); - t.info = this.timerInfo[which]; - if(!t.info) - return; - this.timers[which] = t; - } - - if(!t.enabled) { - if(!t.instance) - t.instance = new t.info.tc(this.doc); - changed = t.enabled = true; - t.instance.update(Math.floor(Date.now() / 1000)); - } - } - else { - if(t && t.enabled) { - t.enabled = false; - changed = true; - } - // otherwise we don't care - timer wasn't created yet, or was disabled - } - - if(changed) { - // rebuild the div - var ta = new Array(); - for(t in this.timers) - if(this.timers[t].enabled) - ta.push(this.timers[t]); - ta.sort(function(a,b) { return a.info.order - b.info.order; }); - - while(this.div.hasChildNodes()) - this.div.removeChild(this.div.firstChild); - - for(var i = 0; i < ta.length; i++) - this.div.appendChild(ta[i].instance.element); - } - }, - - start: function() { - var self = this; - self.update(); - setInterval(function() { self.update(); }, 1000); - }, - - update: function() { - // this is non-standard, but since Chrome and Firefox support - // Date.now()... (should be (new Date).milliseconds(), or some such) - var now = Math.floor(Date.now() / 1000); - for(var k in this.timers) { - var t = this.timers[k]; - if(t.enabled) - t.instance.update(now); - } - }, - - sink: function(sunk) { - this.div.style.zIndex = sunk ? '-1' : 'inherit'; - } -}; +// Clock prototypes + +'use strict'; + +var PSClock = (function() { + + // This is the prototype of the basic timer. The clock creates + // timers by instantiating an object from this prototype, then + // mixing in specific functionality. + + var timerBase = { + createNode: function( doc ) { + var element, inner, textNode; + + element = doc.createElement( 'span' ); + textNode = doc.createTextNode( ' ' + this.label + ' ' ); + element.appendChild( textNode ); + + inner = doc.createElement( 'span' ); + textNode = doc.createTextNode( '-:--' ); + inner.appendChild( textNode ); + element.appendChild( inner ); + element.appendChild( doc.createTextNode( ' ' ) ); + element.style.margin = '0 0 0 7px'; + element.style.cursor = 'default'; + element.title = this.title; + + this.element = element; + this.textNode = textNode; + + return element; + }, + + formatTime: function( seconds ) { + var hours, minutes, s; + + hours = Math.floor( seconds / 3600 ); + seconds -= hours*3600; + minutes = Math.floor( seconds / 60 ); + seconds -= minutes*60; + + if ( hours > 0 ) { + s = hours + ':'; + if ( minutes < 10 ) { + s += '0'; + } + } + else { + s = ''; + } + s += minutes; + s += ':'; + + if ( seconds < 10 ) { + s += '0'; + } + s += seconds; + + return s; + }, + + computeColour: function( now, + red_threshold, + yellow_threshold, + green_threshold ) { + if ( now <= red_threshold ) { + return 'red'; + } + + if ( now <= yellow_threshold ) { + return 'yellow'; + } + + if ( now <= green_threshold ) { + return 'lime'; + } + + return null; + }, + + // This is used by most timers. It returns the number of + // seconds remaining until the next tick, where ticks occur at + // regular intervals. + // + // "now" is the Unix time. + // + // "offset" is the time at which the first tick of the day + // occurs, expressed in seconds after midnight. + // + // (Strictly, it is the time at which the first interval + // occurred in the epoch. But, since all these Pardus + // intervals are defined such that the length of the day is an + // exact multiple of the length of the interval, it doesn't + // matter if you think of it as the first tick on the 1st of + // January 1970, or today, or any other day.) + // + // "period" is the time between ticks, in seconds. + secondsToTick: function( now, offset, period ) { + // It actually breaks if "now" is before the first tick of + // the epoch. Big deal huh. + return period - 1 - ( now - offset ) % period; + }, + + // This is used by NPC and Z timers. It returns the number of + // seconds remaining until the next tick, where ticks occur at + // a fixed number of arbitrary times distributed within a + // regular interval. + // + // The regular interval is defined by offset and period, as + // above. Crontab is an array of integers in ascending order, + // which define the times, in seconds after the start of the + // interval, at which ticks occur. + secondsToCrontabTick: function( now, offset, period, crontab ) { + var n, i, end; + + // This is the number of seconds elapsed since the last + // tick. + n = ( now - offset ) % period; + + // Find the first entry that is larger than n. If n is + // larger than all of them, then this will be the length + // of the interval. + for ( i = 0, end = crontab.length; i < end; i++ ) { + var t = crontab[i]; + if ( t > n ) { + return t - 1 - n; + } + } + + return period - 1 - n; + } + }; + + // This object contains mixins that extend the basic timer to suit + // the specific Pardus clocks. + var timerMixins = { + AP: { + label: 'AP', + title: 'Time to next 24 AP and next shield recharge', + + // AP ticks happen every 6 minutes, starting at minute 0. + update: function( now ) { + var rem = this.secondsToTick( now, 0, 360 ); + this.element.style.color = + this.computeColour( rem, 10, 30, 60 ); + this.textNode.data = this.formatTime( rem ); + } + }, + + B: { + label: 'B', + title: 'Time to next building tick', + + // Building ticks happen every 6 hours, 25 minutes past + // the hour, starting at 01:00 UTC. + // Period is 6h (21600 s). Offset is 1h 25m (5100 s) + update: function( now ) { + var rem = this.secondsToTick( now, 5100, 21600 ); + this.element.style.color = + this.computeColour( rem, 30, 180, 600 ); + this.textNode.data = this.formatTime( rem ); + } + }, + + P: { + label: 'P', + title: 'Time to next planet tick', + + // Planet ticks happen every 3 hours, 25 minutes past the + // hour, starting at 02:00 UTC. + // Period is 3h (10800 s). Offset is 2h 25m (8700 s) + update: function( now ) { + var rem = this.secondsToTick( now, 8700, 10800 ); + this.element.style.color = + this.computeColour( rem, 30, 180, 600 ); + this.textNode.data = this.formatTime( rem ); + } + }, + + S: { + label: 'S', + title: 'Time to next starbase tick', + + // Starbase ticks happen every 3 hours, 25 minutes past + // the hour, starting at 0:00 UTC. + // Period is 3h (10800 s). Offset is 25m (1500 s) + update: function( now ) { + var rem = this.secondsToTick( now, 1500, 10800 ); + this.element.style.color = + this.computeColour( rem, 30, 180, 600 ); + this.textNode.data = this.formatTime( rem ); + } + }, + + L: { + label: 'L', + title: 'Time to next leech armour repair', + + // Leech ticks happen every 20 minutes, starting at 00:00 + // UTC. + // Period is 20m (1200 s), offset is zero. + update: function( now ) { + var rem = this.secondsToTick( now, 0, 1200 ); + this.element.style.color = + this.computeColour( rem, 10, 60, 180 ); + this.textNode.data = this.formatTime( rem ); + } + }, + + E: { + label: 'E', + title: 'Time to next e-matter regeneration', + + // E-matter ticks happen every hour, starting at 05:31 UTC + // Period is 60m (3600 s), offset is 5h 31m (19860 s). + update: function( now ) { + var rem = this.secondsToTick( now, 19860, 3600 ); + this.element.style.color = + this.computeColour( rem, 10, 60, 180 ); + this.textNode.data = this.formatTime( rem ); + } + }, + + N: { + label: 'N', + title: 'Time to next roaming NPC move (not Z series, Lucidi)', + + // NPCs roam 7 times an hour, at minutes 08, 17, 26, 35, + // 44, 53, 59. So the encompassing period is 1h (3600 s), + // offset is 8m (480 s), and ticks occur at the start of + // the period, and 6 more times within the interval. + update: function( now ) { + // Times in the crontab are (17-8)*60, (26-8)*60, etc. + var crontab = [540, 1080, 1620, 2160, 2700, 3060], + rem = this.secondsToCrontabTick( now, 480, 3600, crontab ); + this.element.style.color = + this.computeColour( rem, 10, 30, 60 ); + this.textNode.data = this.formatTime( rem ); + } + }, + + Z: { + label: 'Z', + title: 'Time to next Z series and Lucidi NPC move', + + // Zs and Lucies roam in like fashion as other NPCs, but + // their timing is a bit different, at minutes 08, 17, 26, + // 33, 41, 51, 59. + update: function( now ) { + var crontab = [540, 1080, 1500, 1980, 2580, 3060], + rem = this.secondsToCrontabTick( now, 480, 3600, crontab); + this.element.style.color = + this.computeColour( rem, 10, 30, 60 ); + this.textNode.data = this.formatTime( rem ); + } + }, + + R: { + label: 'R', + title: 'Time to next server reset', + + // Server reset occurs at 05:30 UTC every day. + // Period is 1d (86400 s). Offset is 5h 30m (19800 s) + update: function( now ) { + var rem = this.secondsToTick( now, 19800, 86400 ); + this.element.style.color = + this.computeColour( rem, 30, 180, 600 ); + this.textNode.data = this.formatTime( rem ); + } + }, + + // This is not a timer per se, it just displays current UTC + UTC: { + // ... and we call it "GMT" because people get confused otherwise + label: 'GMT', + title: 'Greenwich Mean Time', + + update: function( now ) { + var t = now % 86400; + this.textNode.data = this.formatTime( t ); + }, + + // Override formatTime, as this is not a countdown and we + // don't want to lose the leading zeros. + formatTime: function( seconds ) { + var hours, minutes, s; + + hours = Math.floor( seconds / 3600 ); + seconds -= hours*3600; + minutes = Math.floor( seconds / 60 ); + seconds -= minutes*60; + + s = ''; + if ( hours < 10 ) { + s += '0'; + } + s += hours; + s += ':'; + if ( minutes < 10 ) { + s += '0'; + } + s += minutes; + s += ':'; + if ( seconds < 10 ) { + s += '0'; + } + s += seconds; + + return s; + } + } + }; + + // Names of all available timers. Bit redundant, as we already + // have these names, as keys in timerMixins. But this defines the + // order in which we render them. + var TIMERS = [ 'AP', 'B', 'P', 'S', 'L', 'E', 'N', 'Z', 'R', 'UTC' ]; + + // Creates an instance of the clock object. + function PSClock( doc ) { + this.doc = doc; + this.enabledTimers = {}; + this.needRebuild = false; + } + + PSClock.prototype = { + setEnabled: function( which, enabled ) { + var timer = this.enabledTimers[ which ]; + + if ( enabled ) { + if ( !timer ) { + var mixin = timerMixins[ which ]; + if ( !mixin ) { + throw new Error( 'Unknown timer ' + which ); + } + + timer = Object.create( timerBase ); + + for ( var key in mixin ) { + timer[ key ] = mixin[ key ]; + } + + this.enabledTimers[ which ] = timer; + this.needRebuild = true; + } + // else it's fine, we already had it enabled + } + else { + if ( timer ) { + delete this.enabledTimers[ which ]; + // We could actually optimise removing of a timer + // without requiring a rebuild. Not very useful + // tho, just a tiny performance gain while you're + // mucking about with settings, probably not even + // watching the pardus tab... + this.needRebuild = true; + } + // else nop, wasn't enabled anyway + } + }, + + createContainer: function() { + var doc = this.doc, body = doc.body, + div = doc.createElement( 'div' ); + + //div.style.fontSize = '10px'; + div.style.position = 'fixed'; + div.style.top = 0; + div.style.right = '10px'; + div.style.width = 'auto'; + body.insertBefore( div, body.firstChild ); + this.container = div; + }, + + resetContainer: function() { + var child, div = this.container; + + if ( div ) { + while (( child = div.firstChild )) { + div.remove( child ); + } + } + }, + + removeContainer: function() { + var div = this.container; + + if ( div ) { + div.parentNode.removeChild( div ); + delete this.container; + } + }, + + rebuild: function() { + if ( Object.keys( this.enabledTimers ).length > 0 ) { + var doc, container; + + // We're going to add at least one. We need a div. + if ( this.container ) { + this.resetContainer(); + } + else { + this.createContainer(); + } + + doc = this.doc; + container = this.container; + + // Add the enabled timers in the proper order + for ( var i = 0, end = TIMERS.length; i < end; i++ ) { + var timer = this.enabledTimers[ TIMERS[i] ]; + if ( timer ) { + var element = timer.createNode( doc ); + container.appendChild( element ); + } + } + } + else { + // No timers enabled. Remove the div if we inserted one + if ( this.container ) { + this.removeContainer(); + } + } + + this.needRebuild = false; + }, + + update: function() { + if ( this.needRebuild ) { + this.rebuild(); + } + + // Date.now() is non-standard, but Chrome supports it + // (and Firefox, too). + // + // (should be (new Date()).milliseconds() or some such). + var now = Math.floor( Date.now() / 1000 ); + for ( var k in this.enabledTimers ) { + this.enabledTimers[k].update( now ); + } + }, + + start: function() { + this.update(); + var self = this, callback = function() { self.update(); }; + this.doc.defaultView.setInterval( callback, 1000 ); + }, + + sink: function( sunk ) { + if ( this.container) { + this.container.style.zIndex = sunk ? '-1' : 'inherit'; + } + } + }; + + return PSClock; + +})(); diff --git a/combat.js b/combat.js index 2eb24a9..50993fa 100644 --- a/combat.js +++ b/combat.js @@ -1,264 +1,376 @@ -// This object handles auto-missiles, auto-highest-rounds, bot fill -// and display damage. It's used in the ship v ship and ship v npc -// screens. +// This content script drives the ship2ship, ship2opponent, and +// building pages, which are much alike. + +'use strict'; + +(function( doc, ConfigurationSet, ShipLinks, Universe ){ + +var config, shipLinksAdded, botsAvailable, shipCondition, damageDisplayed; + +function start() { + var match, pageShipLinks, autoRoundsKey, autoMissilesKey, + cs, universeName, features; + + // Enable features depending on which page we're running on. + var featureSets = { + building: { + shipLinks: true, + autoRoundsKey: null, + autoMissilesKey: 'pvbMissileAutoAll' + }, + ship2opponent_combat: { + shipLinks: false, + autoRoundsKey: 'pvmHighestRounds', + autoMissilesKey: 'pvmMissileAutoAll' + }, + ship2ship_combat: { + shipLinks: true, + autoRoundsKey: 'pvpHighestRounds', + autoMissilesKey: 'pvpMissileAutoAll' + } + }; + match = /^\/([^./]+)\.php/.exec( doc.location.pathname ); + features = featureSets[ match[1] ]; + + if ( !features ) { + throw new Error('running on unexpected pathname'); + } + + cs = new ConfigurationSet(); + universeName = Universe.getName( doc ); + cs.addKey( 'autobots' ); + cs.addKey( 'autobots' + universeName + 'Points', 'autobotsPoints' ); + cs.addKey( 'autobots' + universeName + 'Strength', 'autobotsStrength' ); + cs.addKey( features.autoMissilesKey, 'autoMissiles' ); + cs.addKey( 'displayDamage' ); + + if ( features.shiplinks ) { + cs.addKey( 'navShipLinks' ); + + } + if ( features.autoRoundsKey ) { + cs.addKey( features.autoRoundsKey, 'autoRounds' ); + } + + config = cs.makeTracker( applyConfiguration ); +} + +// Called by the configuration tracker when something changes. +function applyConfiguration() { + var ships; + + // Ship links + if ( shipLinksAdded ) { + ShipLinks.removeElementsByClassName( doc, 'psw-slink' ); + } + if ( config.navShipLinks ) { + ships = ShipLinks.getShips( doc, + "//table/tbody[tr/th = 'Other Ships']/tr/td/a", matchShipId ); + ShipLinks.addShipLinks( ships ); + shipLinksAdded = true; + } + + // Autobots + if ( config.autobots ) { + if ( ! botsAvailable ) { + botsAvailable = getBotsAvailable( doc ); + } + if ( ! shipCondition ) { + shipCondition = getShipCondition(); + } + + fillBots( botsAvailable, shipCondition.components, + config.autobotsPoints, config.autobotsStrength ); + } + + // Automissiles + if ( config.autoMissiles ) { + checkAllMissiles(); + } + + // Autorounds + if ( config.autoRounds ) { + selectHighestRounds(); + } + + // Display damage + if ( config.displayDamage && !damageDisplayed ) { + if ( ! shipCondition ) { + shipCondition = getShipCondition(); + } + displayDamage( shipCondition ); + damageDisplayed = true; + } +} + +function matchShipId( url ) { + var r, match; + + // This could be smarter, doing proper URL-decode of the + // building.php query string, but it isn't likely that'll be + // needed, and it would slow things down. + match = + /building\.php\?detail_type=([A-Za-z]+)&detail_id=(\d+)$/.exec( url ); + if ( match ) { + r = { type: match[ 1 ], id: parseInt( match[ 2 ] ) }; + } + + return r; +} + +// Looks for robots in the "use resources" form. Returns an object +// with properties: count (an integer) and input (the text field where +// you type how many robots to use). If no bots are found, an object +// will still be returned, but these properties won't be defined. + +function getBotsAvailable() { + var tr, xpr, available, input, result = {}; + + tr = doc.evaluate( + "//tr[td/input[@name = 'resid' and @value = 8]]", + doc, null, XPathResult.ANY_UNORDERED_NODE_TYPE, + null ).singleNodeValue; + if ( tr ) { + xpr = doc.evaluate( + "td[position() = 2]|td/input[@name = 'amount']", + tr, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null ); + available = xpr.iterateNext(); + if ( available ) { + result.count = parseInt( available.textContent ); + if ( result.count > 0 ) { + input = xpr.iterateNext(); + if ( input ) { + result.input = input; + } + } + } + } + + return result; +} + +// Given bot availability and ship condition data, as collected by +// getBotsAvailable and getShipCondition, this computes the number of +// bots required to repair the ship's armour as close as possible to +// the desired points, without wasting bots. +// +// Returns the positive number of bots required, or zero if the +// armour requires no repairs, or -1 if the result can't be +// computed. + +function requiredBots( available, componentsCondition, + configuredPoints, configuredStrength ) { + var n, avbcount, points; + + avbcount = available.count; + + if ( avbcount == undefined || !(avbcount >= 0) || + !componentsCondition.selfArmor || + !( configuredPoints > 0 ) || !( configuredStrength > 0 ) ) { + return -1; + } + + points = componentsCondition.selfArmor.points; -// The port is intended to be shared with code outside this object. -// This object will register itself as a message listener on the port, -// but it won't subscribe to the values it needs; this has to be done -// by the caller; the property 'configkeys' in this object contains an -// array of keys that can be passed in a subscribe message (along with -// whatever other keys the page needs). + if ( points < configuredPoints ) { + n = Math.floor( (configuredPoints - points) / configuredStrength ); + return ( n > avbcount ? avbcount : n ); + } -function PSWCombatScreenDriver(doc, port, configmap) { - this.doc = doc; - this.port = port; - this.configmap = configmap; - this.configkeys = Object.keys(this.configmap); - this.configcount = this.configkeys.length; + return 0; +} + +// Computes the required amount of bots and fills the amount field +// as appropriate. + +function fillBots( available, componentsCondition, + configuredPoints, configuredStrength ) { + var n; + + n = requiredBots( available, componentsCondition, + configuredPoints, configuredStrength); + if ( n >= 0 && available.input ) { + available.input.value = n > 0 ? n : ''; + } +} + +// Looks for ship statuses in combat and building pages - those are +// the green, yellow or red thingies that say "Hull points: 225", etc. +// +// Sadly, even after the 2013-09-14 update, which touched this very +// bit, at least for the building page, Pardus still doesn't add a +// proper id to those fields, like it does in the nav page. +// +// So we have to identify these heuristically. These bits are in +// tags (yeah, deprecated tags, too). There aren't that many +// font tags in the document, so we just use getElementsByTagName and +// find them by matching the text in each one. +// +// If any ship statuses are found, this returns an object with two +// properties: shipComponents, and textElements. Each of those contain +// properties selfHull, selfArmor (thusly mispelled :P), selfShield, +// and, if the page shows an opponent, also otherHull, otherArmor, +// otherShield. Don't rely on any of these to be present, except +// perhaps selfHull. + +function getShipCondition() { + var fonts, i, end, font, textElement, match, key, value, points, + table, width, rx = /^(Hull|Armor|Shield) points(?:: (\d+))?$/, + result, componentCount = 0; + + result = { + components: {}, + textElements: {} + }; + + fonts = doc.getElementsByTagName( 'font' ); + for (i = 0, end = fonts.length; i < end; i++ ) { + font = fonts[ i ]; + textElement = font.firstChild; + + if ( textElement && textElement.nodeType == 3 ) { + match = rx.exec( textElement.nodeValue ); + if ( match ) { + points = match[ 2 ]; + if ( points ) { + key = 'self' + match[ 1 ]; + value = { points: parseInt( points ), accurate: true }; + } + else { + key = 'other' + match[ 1 ]; + value = { inferred: true }; + + table = font.nextElementSibling; + if ( table && table.tagName == 'TABLE' ) { + width = table.attributes[ 'width' ]; + if ( width ) { + points = parseInt( width.value ); + value.points = 2 * points; + value.accurate = ( points < 300 ); + } + } + } + + result.components[ key ] = value; + result.textElements[ key ] = textElement; + componentCount++; + } + } + } + + result.count = componentCount; + return result; +} - this.config = new Object(); +function checkAllMissiles() { + var allMissiles, inputs, input, i, end; + + allMissiles = doc.getElementById( 'allmissiles' ); + if ( allMissiles ) { + allMissiles.checked = true; + } + + // This is what the game's javascript does in this case, more or less: + inputs = doc.getElementsByTagName( 'input' ); + for ( i = 0, end = inputs.length; i < end; i++ ) { + input = inputs[ i ]; + if ( input.type == 'checkbox' && + input.name.indexOf( '_missile' ) != -1 ) { + input.checked = true; + } + } +} + +function selectHighestRounds() { + var select; + + select = doc.evaluate( + '//select[@name = "rounds"]', doc, null, + XPathResult.ANY_UNORDERED_NODE_TYPE, null ).singleNodeValue; + if ( select ) { + if ( select.style.display == 'none' && + select.nextElementSibling.tagName == 'SELECT' ) { + // for some reason, Pardus now hides the rounds select, + // and instead adds a second, visible select element, with + // a gibberish name. + select = select.nextElementSibling; + } + + selectMaxValue( select ); + } +} + +function selectMaxValue( select ) { + var opts = select.options, i, end, n, max = -1, maxindex = -1; - var self = this; + for ( i = 0, end = opts.length; i < end; i++) { + n = parseInt( opts[ i ].value ); + if ( n > max ) + maxindex = i; + } - this.port.onMessage.addListener(function(msg) { self.messageHandler(msg); }); + if ( maxindex >= 0 ) { + select.selectedIndex = maxindex; + } } -PSWCombatScreenDriver.prototype = { - messageHandler: function(msg) { - if(msg.op != 'updateValue') - return; - - var key = this.configmap[msg.key]; - if(key) - this.config[key] = msg.value; - if(Object.keys(this.config).length >= this.configcount) - this.configure(); - }, - - // called when configuration is complete - configure: function() { - if(this.config.highestRounds) - this.selectHighestRounds(); - if(this.config.missileAutoAll) - this.checkAllMissiles(); - if(this.config.autobots) - this.fillBots(); - if(this.config.displayDamage) { - this.registerPSSHandlers(); - this.displayDamage(); - } - }, - - selectHighestRounds: function() { - var doc = this.doc, sel, - xpr = doc.evaluate('//select[@name = "rounds"]', doc, null, - XPathResult.UNORDERED_NODE_ITERATOR_TYPE, null); - while((sel = xpr.iterateNext())) { - if(sel.style.display == 'none' && - sel.nextElementSibling.tagName == 'SELECT') - // for some reason, Pardus now hides the rounds select, - // and instead adds a second, visible select element, with - // a gibberish name. - sel = sel.nextElementSibling; - - this.selectMaxValue(sel); - } - }, - - selectMaxValue: function(select) { - var opts = select.options, max = -1, maxindex = -1; - for(var i = 0, end = opts.length; i < end; i++) { - var n = parseInt(opts[i].value); - if(n > max) - maxindex = i; - } - if(maxindex >= 0) - select.selectedIndex = maxindex; - }, - - checkAllMissiles: function() { - var am = this.doc.getElementById("allmissiles"); - if(am) - am.checked = true; - // this is what the game's javascript does in this case, more or less: - var ms = this.doc.getElementsByTagName('input'); - for(var i = 0; i < ms.length; i++) { - var m = ms[i]; - if(m.type == 'checkbox' && m.name.indexOf('_missile') != -1) - m.checked = true; - } - }, - - // This function scans the combat page and extracts the stuff we need - // for bot autofill. It doesn't modify anything. If successful, it - // returns an object with properties "available" (integer) and "input" - // (node). Otherwise, it'll return null. - - getBotsInfo: function() { - if(this.botsInfo !== undefined) - return this.botsInfo; - - this.botsInfo = null; // run only once - var tr, xpr, available, input; - - tr = this.doc.evaluate("//tr[td/input[@name = 'resid' and @value = 8]]", - this.doc, null, XPathResult.ANY_UNORDERED_NODE_TYPE, - null).singleNodeValue; - if(tr) { - xpr = this.doc.evaluate("td[position() = 2]|td/input[@name = 'amount']", - tr, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); - available = xpr.iterateNext(); - if(available) { - available = parseInt(available.textContent); - if(available > 0) { - input = xpr.iterateNext(); - if(input) - this.botsInfo = { available: available, input: input }; - } - } - } - - return this.botsInfo; - }, - - // This function scans the combat page and extracts stuff we need for - // bot autofill and damage tracking. It doesn't modify anything. If - // successful, it returns an object with properties "selfHull", - // "selfArmor" (thusly mispelled grr :P), "otherHull", etc. - - getShipStatus: function() { - if(this.shipStatus) - return this.shipStatus; - - this.shipStatus = { }; - this.shipStatusTextElements = { }; - - var fs = this.doc.getElementsByTagName('font'); - for(var i = 0; i < fs.length; i++) { - var f = fs[i]; - var t = f.firstChild; - - if(t && t.nodeType == 3) { - var m = /^(Hull|Armor|Shield) points(?:: (\d+))?$/.exec(t.nodeValue); - var key, o; - if(m) { - var v = m[2]; - if(v) { - key = 'self' + m[1]; - o = { value: parseInt(v), accurate: true }; - } - else { - key = 'other' + m[1]; - o = { inferred: true }; - - var a, n, table = f.nextElementSibling; - if(table && table.tagName == 'TABLE') { - a = table.attributes['width']; - if(a) { - v = parseInt(a.value); - o.value = 2*v; - o.accurate = (v < 300); - } - } - } - - this.shipStatus[key] = o; - this.shipStatusTextElements[key] = t; - } - } - } - - return this.shipStatus; - }, - - fillBots: function() { - var pts = parseInt(this.config.autobotsPoints); - var str = parseInt(this.config.autobotsStrength); - if(pts && str) { - var bi = this.getBotsInfo(); - if(bi) { - var armour = this.getShipStatus().selfArmor; - if(armour) { - armour = armour.value; - if(armour < pts) { - var n = Math.floor((pts - armour) / str); - if(n > bi.available) - n = bi.available; - if(n > 0) - bi.input.value = n; - } - } - } - } - }, - - registerPSSHandlers: function() { - if(this.pssHandlersRegistered) - return; - this.pssHandlersRegistered = true; - - var fs = this.doc.forms; - var self = this; - for(var i = 0; i < fs.length; i++) { - var form = fs[i]; - form.addEventListener('submit', function() { self.savePSS(); }, null); - } - }, - - savePSS: function() { - if(this.shipStatus) { - this.shipStatus.timestamp = Math.floor(Date.now() / 1000); - var v = JSON.stringify(this.shipStatus); - port.postMessage({ op: 'setValue', key: 'previousShipStatus', value: v }); - } - }, - - displayDamage: function() { - if(this.damageDisplayed) - return; - - this.damageDisplayed = true; - var ss = this.getShipStatus(); - var pss; - - if(this.config.previousShipStatus) { - pss = JSON.parse(this.config.previousShipStatus); - var now = Math.floor(Date.now() / 1000); - - // XXX - hardcoded 5. PSS is saved when the user clicks on combat. - // so, if we get a new combat screen within 5 seconds of having - // left another, we assume this is the same combat continuing and - // show damage. I don't think this is unreasonable... - - if(!pss.timestamp || Math.abs(now - pss.timestamp) > 5) - pss = null; - } - - for(var key in ss) { - var st = ss[key]; - var te = this.shipStatusTextElements[key]; - var s = te.data; - var l = s.length; - - if(st.inferred) { - if(st.accurate) - s += ': ' + st.value; - else - s += ': ' + st.value + '+'; - } - - if(pss) { - var pst = pss[key]; - if(st.accurate && pst.accurate && st.value != pst.value) { - var diff = st.value - pst.value; - if(diff > 0) - diff = '+' + diff; - s += ' (' + diff + ')'; - } - } - - if(l != s.length) - te.data = s; - } - } -}; +function displayDamage( shipCondition ) { + var pscc, psccTimestamp, now, key, component, previousComponent, + textElement, text, textLength, diff; + + // See if there's a saved ship condition stored in the top window + + now = Math.floor( Date.now() / 1000 ); + pscc = top.psSCC, + psccTimestamp = top.psSCCTimestamp || 0; + if ( pscc ) { + // Hardcoded 5. PSS is saved when the user clicks on combat. + // so, if we get a new combat screen within 5 seconds of having + // left another, we assume this is the same combat continuing and + // show damage. I don't think this is unreasonable... + if ( Math.abs( now - psccTimestamp ) > 5 ) { + pscc = undefined; + } + } + + // And save the ship condition + top.psSCC = shipCondition.components; + top.psSCCTimestamp = now; + + for ( key in shipCondition.components ) { + component = shipCondition.components[ key ]; + textElement = shipCondition.textElements[ key ]; + text = textElement.data; + textLength = text.length; + + if ( component.inferred) { + if ( component.accurate ) { + text += ': ' + component.points; + } + else { + text += ': ' + component.points + '+'; + } + } + + if ( pscc ) { + previousComponent = pscc[ key ]; + if ( component.accurate && previousComponent && + previousComponent.accurate && + component.points != previousComponent.points ) { + diff = component.points - previousComponent.points; + if ( diff > 0 ) { + diff = '+' + diff; + } + text += ' (' + diff + ')'; + } + } + + if ( textLength != text.length ) { + textElement.data = text; + } + } +} + +start(); + +})( document, ConfigurationSet, ShipLinks, Universe ); diff --git a/game.js b/game.js index 340a3af..54b4af1 100644 --- a/game.js +++ b/game.js @@ -1,28 +1,32 @@ +'use strict'; + +(function( doc ) { + function checkMessageFrame() { - var f = document.getElementById('msgframe'); - if(f) { + var f = doc.getElementById( 'msgframe' ); + if ( f ) { var src = f.src; var reloadNeeded; try { - reloadNeeded = (f.contentDocument.URL != src); + reloadNeeded = ( f.contentDocument.URL != src ); } - catch(e) { + catch( e ) { // trying to access contentDocument above will cause chrome to // give us a security exception if the msgframe failed to load // and the frame now contains the error document reloadNeeded = true; } - if(reloadNeeded) + if ( reloadNeeded ) { f.src = src; + } } // schedule a check in about 1 minute - setTimeout(checkMessageFrame, 59000); + doc.defaultView.setTimeout( checkMessageFrame, 59000 ); } -// we may use the port in the future. for now, we only connect to enable the page action -var port = chrome.extension.connect(); +doc.defaultView.setTimeout( checkMessageFrame, 55000 ); -setTimeout(checkMessageFrame, 55000); +})( document ); diff --git a/manifest.json b/manifest.json index 2c293c2..d307f9a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,56 +1,73 @@ { - "name": "Pardus Sweetener", - "version": "2.4", - "manifest_version": 2, - "content_security_policy": "script-src 'self'; object-src 'self'", - "description": "User interface enhancements for the online game Pardus", - "background": { - "scripts": [ "alarm.js", "notifier.js", "bg.js" ], - "persistent": false - }, - "options_page": "options.html", - "content_scripts": [ - { "matches": [ "*://*.pardus.at/msgframe.php*" ], - "js": [ "clock.js", "msgframe.js" ], - "all_frames": true }, - { "matches": [ "*://*.pardus.at/ship2opponent_combat.php*" ], - "js": [ "universe.js", "combat.js", "s2oframe.js" ], - "all_frames": true }, - { "matches": [ "*://*.pardus.at/ship2ship_combat.php*" ], - "js": [ "combat.js", "s2sframe.js" ], - "all_frames": true }, - { "matches": [ "*://*.pardus.at/main.php*" ], - "js": [ "map.js", "shiplinks.js", "nav.js" ], - "all_frames": true }, - { "matches": [ "*://*.pardus.at/game.php" ], - "js": [ "game.js" ], - "all_frames": false }, - { "matches": [ "*://*.pardus.at/building.php*" ], - "js": [ "universe.js", "combat.js", "shiplinks.js", "building.js" ], - "all_frames": true }, - { "matches": [ "*://*.pardus.at/myalliance.php*" ], - "js": [ "universe.js", "slicer.js", "myalliance.js" ], - "all_frames": true }, - { "matches": [ "*://*.pardus.at/ambush.php*" ], - "js": [ "universe.js", "ambush.js" ], - "all_frames": true }, - { "matches": [ "*://*.pardus.at/sendmsg.php*" ], - "js": [ "sendmsg.js" ], - "all_frames": false } - ], - "page_action": { - "default_icon": "icons/19.png", - "default_title": "Change Pardus Sweetener settings", - "default_popup": "popup.html" - }, - "icons": { "16": "icons/16.png", - "48": "icons/48.png", - "64": "icons/64.png", - "128": "icons/128.png" }, - "permissions": [ "notifications" ], - "web_accessible_resources": [ - "icons/16.png", - "icons/48.png", - "icons/down.png" - ] + "background": { + "scripts": [ "bg.js" ], + "persistent": false + }, + "content_scripts": [ + { + "matches": [ "*://*.pardus.at/main.php*" ], + "js": [ "cfgset.js", "shiplinks.js", "map.js", "nav.js" ], + "all_frames": true + }, + { + "matches": [ "*://*.pardus.at/msgframe.php*" ], + "js": [ "clock.js", "msgframe.js" ], + "all_frames": true + }, + { + "matches": [ + "*://*.pardus.at/ship2ship_combat.php*", + "*://*.pardus.at/ship2opponent_combat.php*", + "*://*.pardus.at/building.php*" + ], + "js": [ + "cfgset.js", "universe.js", "shiplinks.js", "combat.js" + ], + "all_frames": true + }, + { + "matches": [ "*://*.pardus.at/myalliance.php*" ], + "js": [ "slicer.js", "myalliance.js" ], + "all_frames": true + }, + { + "matches": [ "*://*.pardus.at/ambush.php*" ], + "js": [ "cfgset.js", "universe.js", "ambush.js" ], + "all_frames": true + }, + { + "matches": [ "*://*.pardus.at/sendmsg.php*" ], + "js": [ "sendmsg.js" ], + "all_frames": false + }, + { + "matches": [ "*://*.pardus.at/game.php" ], + "js": [ "game.js" ], + "all_frames": false + } + ], + "content_security_policy": "script-src 'self'; object-src 'self'", + "description": "User interface enhancements for the online game Pardus", + "icons": { + "16": "icons/16.png", + "48": "icons/48.png", + "64": "icons/64.png", + "128": "icons/128.png" + }, + "manifest_version": 2, + "minimum_chrome_version": "28", + "name": "Pardus Sweetener", + "options_page": "options.html", + "page_action": { + "default_icon": "icons/19.png", + "default_title": "Change Pardus Sweetener settings", + "default_popup": "popup.html" + }, + "permissions": [ "notifications", "storage", "*://*.pardus.at/*" ], + "version": "3", + "web_accessible_resources": [ + "icons/16.png", + "icons/48.png", + "icons/down.png" + ] } diff --git a/map.js b/map.js index 438c4b8..e245a17 100644 --- a/map.js +++ b/map.js @@ -12,222 +12,236 @@ // // And so, since it's 2013 and that, we use the HTML5 Canvas. -function PSMap() { } - -PSMap.prototype = { - - configure: function(sector, maxPixelSize) { - this.sector = sector; - - var cols = sector.width, rows = sector.height, tiles = sector.tiles; - if(tiles.length != cols*rows) - throw new Error("Tile data and map dimensions don't match"); - - if(cols > rows) - // This is a wide map. We use cols to determine size. Height - // will be less than required. - this.computeGrid(cols, maxPixelSize); - else - // A tall map, use rows instead. - this.computeGrid(rows, maxPixelSize); - - if(this.grid) { - this.width = cols * (this.tileSize+1) - 1; - this.height = rows * (this.tileSize+1) - 1; - } - else { - this.width = cols * this.tileSize; - this.height = rows * this.tileSize; - } - this.cols = cols; - this.rows = rows; - this.configured = true; - - if(this.canvas) - this.initCanvas(); - }, - - setCanvas: function(canvas) { - this.canvas = canvas; - if(this.configured) - this.initCanvas(); - }, - - // Just gets the 2D context of the canvas. You'll want this to clear - // the map and mark tiles. - get2DContext: function() { - return this.canvas.getContext('2d'); - }, - - // This "clears" the canvas, restoring the sector map. So this - // effectively draws the sector map. The idea being: you'll want to - // clear, then overlay dynamic stuff on the "background" map. - clear: function(ctx) { - ctx.drawImage(this.bgCanvas, 0, 0); - }, - - // This draws a marker on a tile. - markTile: function(ctx, col, row, style) { - var grid = this.grid, size = this.tileSize, gstep = grid ? size+1 : size, - x = col*gstep, y = row*gstep; - - // If the tiles are large enough, make the mark smaller so - // the background shows a bit, let you know what type of tile the - // marker is on. - if(size > 10) { - x += 2; - y += 2; - size -= 4; - } - else if(size > 5) { - x += 1; - y += 1; - size -= 2; - } - - ctx.fillStyle = style; - ctx.fillRect(x, y, size, size); - }, - - // Convert pixel x,y coordinates on the canvas to map row,col. For - // this purpose, if the map has a grid, points on the grid are - // assumed to belong on the tile to the right/bottom. If result is - // ommitted, a new object is created to return the result. - xyToColRow: function(x, y, result) { - if(!result) - result = new Object(); - result.col = Math.floor(x / this.size); - }, - - - // Below is "private" stuff which you shouldn't need to use from - // outside this object. - - - COLOUR: { - b: '#158', // hard energy - e: '#0e2944', // energy - f: '#000', // fuel - g: '#a00', // gas - m: '#0c0', // exotic matter - o: '#666', // ore - v: '#0f0' // viral - }, - - initCanvas: function() { - this.canvas.width = this.width; - this.canvas.height = this.height; - // We actually paint most of the map here - this.setupBgCanvas(); - }, - - setupBgCanvas: function() { - var doc = this.canvas.ownerDocument; - if(!doc) - // We can't draw anyway - return; - - var x, y, px0, row, col, - rows = this.rows, cols = this.cols, c, - sector = this.sector, data = sector.tiles, - width = this.width, height = this.height, - size = this.tileSize, grid = this.grid, - colour = this.COLOUR, canvas = doc.createElement('canvas'); - - canvas.width = width; - canvas.height = height; - this.bgCanvas = canvas; - - var ctx = canvas.getContext('2d'); - - if(grid) { - // When the grid is enabled, we paint tiles of side size+1. The - // extra pixel is really part of the grid line, but painting in - // the tile colour first makes the map prettier. - size += 1; - - // Since there is one less grid line than there are rows (or - // columns), one of these "tile plus grid pixel" areas has to be - // 1px smaller. We feel it looks better if this is the first - // row and the first column. So we paint 1px up and to the - // left, and let the canvas clip it. - px0 = -1; - } - else - px0 = 0; - - for(row = 0, y = px0; row < rows; row++, y += size) { - for(col = 0, x = px0; col < cols; col++, x += size) { - c = data[row*cols + col]; - ctx.fillStyle = colour[c]; - ctx.fillRect(x, y, size, size); - } - } - - if(grid) { - ctx.fillStyle = 'rgba(128, 128, 128, 0.25)'; - for(y = size-1; y < height; y += size) - ctx.fillRect(0, y, width, 1); - for(x = size-1; x < width; x += size) - ctx.fillRect(x, 0, 1, height); - } - - // Paint beacons - for(var beacon_name in sector.beacons) { - var beacon = sector.beacons[beacon_name], style; - switch(beacon.type){ - case 'wh': - style = '#c6f'; - break; - default: - style = '#fff'; - } - this.markTile(ctx, beacon.x, beacon.y, style); - } - - // We don't need this any more, release the reference - delete this.sector; - }, - - // Compute the tile size and whether we'll draw grid lines. - // - // The aim is to fit the given number of tiles in the given number - // of pixels. Our tiles are square, so we only really compute this - // for one dimension. - // - // Our tiles are of uniform size. This means we don't really - // output a map of the requested dimensions, but the largest size we - // can create, while keeping our cells square and uniform size, that - // is still less than or equal than the specified pixel size. - // - // We want thin 1px grid lines if the tiles are big enough. When the - // map is so large that the tiles become tiny, we don't want to - // waste pixels in those. - computeGrid: function(tiles, maxPixels) { - if(!(tiles > 0 && maxPixels > 0)) - throw new Error('Invalid parameters'); - if(tiles > maxPixels) - throw new Error('Cannot draw ' + tiles + ' tiles in ' + - maxPixels + ' pixels'); - - var grid, size = Math.floor((maxPixels+1)/tiles); - - // A tile would be size-1 pixels per side, the extra pixel is for - // the grid. All our tiles fit in the allowed pixels because - // there is one less grid line than there are tiles. - if(size < 4) { - // This means our tiles would be 2 pixels per side. We don't - // want grid lines in this case. - size = Math.floor(maxPixels/tiles); - grid = false; - } - else { - size -= 1; - grid = true; - } - - this.tileSize = size; - this.grid = grid; - } - +'use strict'; + +function SectorMap() {} +SectorMap.prototype = { + + configure: function( sector, maxPixelSize ) { + this.sector = sector; + + var cols = sector.width, rows = sector.height, tiles = sector.tiles; + + if ( tiles.length != cols*rows ) { + throw new Error( "Tile data and map dimensions do not match" ); + } + + if ( cols > rows ) { + // This is a wide map. We use cols to determine + // size. Height will be less than required. + this.computeGrid( cols, maxPixelSize ); + } + else { + // A tall map, use rows instead. + this.computeGrid( rows, maxPixelSize ); + } + + if ( this.grid ) { + this.width = cols * ( this.tileSize + 1 ) - 1; + this.height = rows * ( this.tileSize + 1 ) - 1; + } + else { + this.width = cols * this.tileSize; + this.height = rows * this.tileSize; + } + this.cols = cols; + this.rows = rows; + this.configured = true; + + if ( this.canvas ) { + this.initCanvas(); + } + }, + + setCanvas: function( canvas ) { + this.canvas = canvas; + if ( this.configured ) { + this.initCanvas(); + } + }, + + // Just gets the 2D context of the canvas. You'll want this to + // clear the map and mark tiles. + get2DContext: function() { + return this.canvas.getContext( '2d' ); + }, + + // This "clears" the canvas, restoring the sector map. So this + // effectively draws the sector map. The idea being: you'll want + // to clear, then overlay dynamic stuff on the "background" map. + clear: function( ctx ) { + ctx.drawImage( this.bgCanvas, 0, 0 ); + }, + + // This draws a marker on a tile. + markTile: function( ctx, col, row, style ) { + var grid = this.grid, size = this.tileSize, + gstep = grid ? size+1 : size, x = col*gstep, y = row*gstep; + + // If the tiles are large enough, make the mark smaller so + // the background shows a bit, let you know what type of tile + // the marker is on. + if ( size > 10 ) { + x += 2; + y += 2; + size -= 4; + } + else if ( size > 5 ) { + x += 1; + y += 1; + size -= 2; + } + + ctx.fillStyle = style; + ctx.fillRect( x, y, size, size ); + }, + + // Convert pixel x,y coordinates on the canvas to map row,col. + // For this purpose, if the map has a grid, points on the grid are + // assumed to belong on the tile to the right/bottom. If result is + // ommitted, a new object is created to return the result. + xyToColRow: function( x, y, result ) { + if ( !result ) { + result = {}; + } + result.col = Math.floor( x / this.size ); + }, + + + // Below is "private" stuff which you shouldn't need to use from + // outside this object. + + COLOUR: { + b: '#158', // hard energy + e: '#0e2944', // energy + f: '#000', // fuel + g: '#a00', // gas + m: '#0c0', // exotic matter + o: '#666', // ore + v: '#0f0' // viral + }, + + initCanvas: function() { + this.canvas.width = this.width; + this.canvas.height = this.height; + // We actually paint most of the map here + this.setupBgCanvas(); + }, + + setupBgCanvas: function() { + var doc = this.canvas.ownerDocument; + if ( !doc ) { + // We can't draw anyway + return; + } + + var ctx, x, y, px0, row, col, + rows = this.rows, cols = this.cols, c, + sector = this.sector, data = sector.tiles, + width = this.width, height = this.height, + size = this.tileSize, grid = this.grid, + colour = this.COLOUR, canvas = doc.createElement( 'canvas' ); + + canvas.width = width; + canvas.height = height; + this.bgCanvas = canvas; + + ctx = canvas.getContext( '2d' ); + + if ( grid ) { + // When the grid is enabled, we paint tiles of side + // size+1. The extra pixel is really part of the grid + // line, but painting in the tile colour first makes the + // map prettier. + size += 1; + + // Since there is one less grid line than there are rows + // (or columns), one of these "tile plus grid pixel" areas + // has to be 1px smaller. We feel it looks better if this + // is the first row and the first column. So we paint 1px + // up and to the left, and let the canvas clip it. + px0 = -1; + } + else { + px0 = 0; + } + + for ( row = 0, y = px0; row < rows; row++, y += size ) { + for ( col = 0, x = px0; col < cols; col++, x += size ) { + c = data[ row*cols + col ]; + ctx.fillStyle = colour[ c ]; + ctx.fillRect( x, y, size, size ); + } + } + + if ( grid ) { + ctx.fillStyle = 'rgba(128, 128, 128, 0.25)'; + for ( y = size-1; y < height; y += size ) { + ctx.fillRect( 0, y, width, 1 ); + } + for ( x = size-1; x < width; x += size ) { + ctx.fillRect( x, 0, 1, height ); + } + } + + // Paint beacons + for ( var beacon_name in sector.beacons ) { + var beacon = sector.beacons[ beacon_name ], style; + switch ( beacon.type ){ + case 'wh': + style = '#c6f'; + break; + default: + style = '#fff'; + } + this.markTile( ctx, beacon.x, beacon.y, style ); + } + + // We don't need this any more, release the reference + delete this.sector; + }, + + // Compute the tile size and whether we'll draw grid lines. + // + // The aim is to fit the given number of tiles in the given number + // of pixels. Our tiles are square, so we only really compute + // this for one dimension. + // + // Our tiles are of uniform size. This means we don't really + // output a map of the requested dimensions, but the largest size + // we can create, while keeping our cells square and uniform size, + // that is still less than or equal than the specified pixel size. + // + // We want thin 1px grid lines if the tiles are big enough. When + // the map is so large that the tiles become tiny, we don't want + // to waste pixels in those. + computeGrid: function( tiles, maxPixels ) { + if ( !( tiles > 0 && maxPixels > 0 ) ) { + throw new Error( 'Invalid parameters' ); + } + + if ( tiles > maxPixels ) { + throw new Error( 'Cannot draw ' + tiles + ' tiles in ' + + maxPixels + ' pixels'); + } + + var grid, size = Math.floor( (maxPixels + 1) / tiles ); + + // A tile would be size-1 pixels per side, the extra pixel is + // for the grid. All our tiles fit in the allowed pixels + // because there is one less grid line than there are tiles. + if ( size < 4 ) { + // This means our tiles would be 2 pixels per side. We + // don't want grid lines in this case. + size = Math.floor( maxPixels / tiles ); + grid = false; + } + else { + size -= 1; + grid = true; + } + + this.tileSize = size; + this.grid = grid; + } }; diff --git a/map/P/PJ_3373.json b/map/P/PJ_3373.json index 16dd00b..40ef23d 100644 --- a/map/P/PJ_3373.json +++ b/map/P/PJ_3373.json @@ -3,9 +3,9 @@ "sector": "PJ 3373", "width": 10, "height": 6, - "tiles": "bbbbbbeeebbbbbbbeeebbeeebbbebbeefeeeeeeeefeeeeeeeeeeebbbbbbb", + "tiles": "bbbbbeeebbbbbbbeeebbbeeebbebbbeefeeeeeeeefeeeeeeeeeeebbbbbbb", "beacons": { - "Pass FED-03": { "type": "wh", "x": 7, "y": 0 }, + "Pass FED-03": { "type": "wh", "x": 6, "y": 0 }, "Quurze": { "type": "wh", "x": 9, "y": 4 }, "Epsilon Eridani": { "type": "wh", "x": 0, "y": 5 } } diff --git a/msgframe.js b/msgframe.js index 4ec1de1..50f2930 100644 --- a/msgframe.js +++ b/msgframe.js @@ -1,69 +1,292 @@ -// include the VClock before this - -var port; -var clock; -var indicators = { - 'icon_amsg.png': 'Ally', - 'icon_combat.png': 'Combat', - 'icon_mission.png': 'Mission', - 'icon_msg.png': 'PM', - 'icon_pay.png': 'Payment', - 'icon_trade.png': 'Trade', - 'gnome-error.png': 'Warning', - 'gnome-info.png': 'Info' +// Message frame driver. Require clock. + +'use strict'; + +(function( doc, PSClock ) { + +// These configuration keys we're interested in. It's convenient to +// keep clocks separate from notifications.. +// +// Keep clock options in sync with timers available for the clock. + +var +CLOCK_CONFIG_KEYS = [ + 'clockUTC', 'clockAP', 'clockB', 'clockP', 'clockS', + 'clockL', 'clockE', 'clockN', 'clockZ', 'clockR' +], +INDICATOR_CONFIG_KEYS = [ + 'alarmCombat', 'alarmAlly', 'alarmWarning', 'alarmPM', + 'alarmMission', 'alarmTrade', 'alarmPayment', + 'desktopCombat', 'desktopAlly', 'desktopWarning', 'desktopPM', + 'desktopMission', 'desktopTrade','desktopPayment' +]; + +// The images that appear in the message frame, and the suffix of the +// related indicator config keys. + +var INDICATORS = { + 'icon_amsg.png': 'Ally', + 'icon_combat.png': 'Combat', + 'icon_mission.png': 'Mission', + 'icon_msg.png': 'PM', + 'icon_pay.png': 'Payment', + 'icon_trade.png': 'Trade', + 'gnome-error.png': 'Warning', + 'gnome-info.png': 'Info' }; -function scanForNotifications() { - var r = new Object(); - var any = false; - var imgs = document.getElementsByTagName('img'); - for(var i = 0; i < imgs.length; i++) { - var src = imgs[i].src; - var offset = src.lastIndexOf('/'); - if(offset >= 0) { - src = src.substr(offset+1); - var ind = indicators[src]; - if(ind) { - r[ind] = true; - any = true; - } - } - } - - // Get the character name - var name; - var u = document.getElementById('universe'); - if(u && u.alt) { - name = u.alt; - var offset = name.indexOf(':'); - if(offset >= 0 && offset+2 < name.length) - name = name.substr(offset+2); - } - - //chrome.extension.sendRequest(req, function(response) { }); - port.postMessage({ 'op': 'dispatchNotifications', - 'character_name': name, - 'indicators': r }); - - clock.sink(any); + +var characterName, clockConfig, clock, + indicatorConfig, indicators, indicatorsFound, alarmPort; + +function start() { + var imgs, keys; + + // Request configuration + keys = CLOCK_CONFIG_KEYS.concat( INDICATOR_CONFIG_KEYS ); + chrome.storage.local.get( keys, onConfigurationReady ); + + // And while it arrives, scan the page to see which indicators we + // find. These won't change dynamically, they'll always stay the + // same; if Pardus wants to display a message, it'll clobber the + // whole document and send a new one. + // + // Yeah, feels strangely static after our shenanigans. + + indicators = {}; + indicatorsFound = false; + imgs = doc.getElementsByTagName( 'img' ); + for ( var i = 0, end = imgs.length; i < end; i++ ) { + var m = /\/([^/]+)$/.exec( imgs[i].src ); + if ( m ) { + var suffix = INDICATORS[ m[1] ]; + if ( suffix ) { + indicators[ suffix ] = indicatorsFound = true; + } + } + } + + // And get the character name while we're at it. + + var u = doc.getElementById( 'universe' ); + if ( u ) { + var m = /:\s+(.*)$/.exec( u.alt ); + if ( m ) { + characterName = m[ 1 ]; + } + } +} + +function onConfigurationReady( items ) { + var i, end, key, value; + + // Initialise the clock instance + clock = new PSClock( doc ); + + // Store the initial configurations + clockConfig = {}; + for ( i = 0, end = CLOCK_CONFIG_KEYS.length; i < end; i++ ) { + key = CLOCK_CONFIG_KEYS[ i ]; + value = items[ key ]; + clockConfig[ key ] = value; + setClockConfigurationEntry( key, value ); + } + indicatorConfig = {}; + for ( i = 0, end = INDICATOR_CONFIG_KEYS.length; i < end; i++ ) { + key = INDICATOR_CONFIG_KEYS[ i ]; + indicatorConfig[ key ] = items[ key ]; + } + + // Act on configuration changes + chrome.storage.onChanged.addListener( onConfigurationChange ); + + // And do our thing. + notify(); + clock.start(); + + // This changes the z-index of the clock so the notification + // icons, if any, cover the clock if they overlap. Because we + // think notifications are more important than clocks. + clock.sink( indicatorsFound ); +} + +function onConfigurationChange( changes, area ) { + if ( area == 'local' ) { + var indicatorConfigChanged = false; + + for ( var key in changes ) { + if ( indicatorConfig.hasOwnProperty( key )) { + indicatorConfig[ key ] = changes[ key ].newValue; + indicatorConfigChanged = true; + } + else { + setClockConfigurationEntry( key, changes[key].newValue ); + } + } + + if ( indicatorConfigChanged ) { + notify(); + } + } +} + +function setClockConfigurationEntry( key, value ) { + if ( clockConfig.hasOwnProperty( key ) ) { + clockConfig[ key ] = value; + + // Clocks are named AP, P, S, etc. Keys are clockAP, clockP, + // clockS, etc. + clock.setEnabled( key.substr(5), value ); + } +} + +// Sounding the alarm is a little less straightforward than just +// sending a message to the extension, because we need to keep a +// connection open to it for as long as we want the alarm ringing. +// This function takes care of this. + +function setAlarm( state ) { + if ( state ) { + // Bring the noise + if ( !alarmPort ) { + alarmPort = chrome.runtime.connect(); + alarmPort.postMessage({ alarm: true }); + } + // else it's already on. Never mind then. + } + else { + if ( alarmPort ) { + // Just disconnect, the extension will shut the alarm. + alarmPort.disconnect(); + alarmPort = undefined; + } + // else it isn't on, nop. + } +} + +function notify() { + var message = {}; + + setAlarm( testIndicators('alarm') ); + + if ( testIndicators('desktop') ) { + message.desktopNotification = indicatorsToHuman(); + chrome.runtime.sendMessage( message, function(){} ); + // Read about this below. + delete top.psShouldNotHaveToClearDesktopNotifications; + } + else { + // There are no notifications to be shown. We could simply + // tell the extension to hide the notification, but that means + // we would be waking up the event page every 30 seconds, most + // times for doing absolutely nothing. + // + // So this is what we do: we make a note in the top window + // that the last time we ran we displayed no desktop + // notification. We check for that note now. Only if the + // note *doesn't* exist, we tell the extension to clear the + // notification. + if ( !top.psShouldNotHaveToClearDesktopNotifications ) { + // Double negatives ftw. + message.desktopNotification = null; + chrome.runtime.sendMessage( message, function(){} ); + } + + // And we set the note. + top.psShouldNotHaveToClearDesktopNotifications = true; + } + } -function messageHandler(msg) { - if(msg.op == 'updateValue' && msg.key.substr(0,5) == 'clock') - clock.setEnabled(msg.key.substr(5), msg.value); +function testIndicators( prefix ) { + for ( var suffix in indicators ) { + if ( indicatorConfig[ prefix + suffix ] ) { + return true; + } + } + + return false; } -function run() { - port = chrome.extension.connect(); - clock = new VClock(document); +function indicatorsToHuman() { + var a = [], pendings, warn, notifs, stuff; + + if ( indicators['Warning'] ) { + warn = 'There is a game warning you should see in the message frame.'; + } + else if ( indicators[ 'Info' ] ) { + warn = 'There is some information for you in the message frame.'; + } + + if ( indicators['Ally'] ) { + a.push( 'alliance' ); + } + if ( indicators['PM'] ) { + a.push('private'); + } + if ( a.length > 0 ) { + pendings = 'unread ' + a.join(' and ') + ' messages'; + a.length = 0; + } - port.onMessage.addListener(messageHandler); - port.postMessage({ op: 'subscribe', - keys: [ 'clockUTC', 'clockAP', 'clockB', 'clockP', 'clockS', - 'clockL', 'clockE', 'clockN', 'clockZ', 'clockR' ] }); + if ( indicators['Trade'] || indicators['Payment'] ) { + a.push( 'money' ); + } + if ( indicators['Mission'] ) { + a.push( 'mission' ); + } + if ( a.length > 0 ) { + notifs = a.join(' and ') + ' notifications'; + a.length = 0; + } - clock.start(); - scanForNotifications(); + if ( pendings ) { + a.push( pendings ); + } + if ( notifs ) { + a.push( notifs ); + } + if ( a.length > 0 ) { + stuff = a.join(', and ') + '.'; + a.length = 0; + } + + if ( warn ) { + a.push( warn ); + } + + if ( indicators['Combat'] || stuff ) { + if ( characterName ) { + a.push( + ( warn ? 'And your' : 'Your' ) + + ' character ' + characterName ); + } + else { + a.push( ( warn ? 'And a' : 'A' ) + ' character of yours' ); + } + + if ( indicators['Combat'] ) { + a.push( 'has been fighting with someone.' ); + if ( stuff ) { + if ( characterName ) { + a.push( character_name + ' also has' ); + } + else { + a.push( 'You also have' ); + } + a.push( stuff ); + } + } + else { + a.push( 'has' ); + a.push( stuff ); + } + } + + return a.join( ' ' ); } -run(); +// Start the ball. +start(); + +})( document, PSClock ); diff --git a/myalliance.js b/myalliance.js index f73b16a..5cc3cb5 100644 --- a/myalliance.js +++ b/myalliance.js @@ -1,133 +1,375 @@ -// load universe.js and slicer.js before this - -var port, enabledKey, qls, qlspans, highlighted_ql; - -function highlightQL(name) { - if(name == highlighted_ql) - return; - - var spans, i; - - if(highlighted_ql) { - spans = qlspans[highlighted_ql]; - if(spans) - for(i = 0; i < spans.length; i++) - spans[i].style.color = 'inherit'; - } - - spans = qlspans[name]; - if(spans) { - highlighted_ql = name; - for(i = 0; i < spans.length; i++) - spans[i].style.color = '#3984c6'; - } -}; - -// get a decent name for a QL -function qlName(name) { - // trim and normalise space - var s = name.replace(/^\s+/, '').replace(/\s+$/, '').replace(/\s+/g, ' '); - var m, r; - - // "SG" style name - if((m = /^{(.+)}$/.exec(s))) - r = m[1].replace(/^\s/, '').replace(/\s$/, '').toUpperCase(); - else if((m = /^(.*):$/.exec(s))) - r = m[1].replace(/\s$/, ''); - else - r = s; - - if(r.length == 0) - r = null; - - return r; +// The My Alliance page driver. +// Require slicer.js. + +'use strict'; + +(function( doc, TreeSlicer ){ + +var + // The names of the config keys we use + allianceQLsKey, allianceQLsEnabledKey, allianceQLsMTimeKey, + // The current configuration + config, + // The state of the page + pageScanTimestamp, pageQLs, pageAddedElements, highlightedQL; + +function start() { + var m, universe, universeName, keys; + + m = /^([^.]+)\.pardus\.at$/.exec( doc.location.hostname ); + if ( !m ) { + // No universe?! + return; + } + + universe = m[ 1 ]; + + // We use the universe name to build configKeys. We need it + // capitalised. + universeName = + universe.substr( 0, 1 ).toUpperCase() + + universe.substr( 1 ); + + allianceQLsKey = 'allianceQLs' + universeName; + allianceQLsEnabledKey = allianceQLsKey + 'Enabled'; + allianceQLsMTimeKey = allianceQLsKey + 'MTime'; + + // Request our configuration. + keys = [ allianceQLsKey, allianceQLsEnabledKey, allianceQLsMTimeKey]; + chrome.storage.local.get( keys, onConfigurationReady ); } -function registerQL(name, ql, spans) { - ql = ql.replace(/\s+/g, ''); - if(!ql || ql.length == 0) - // need a valid QL - return; - - name = qlName(name); - if(!name || qlspans[name]) - // need a valid unique name - return; - - var listener = function() { highlightQL(name); }; - var title = 'Quick List: ' + name; - var mark = spans[0].firstChild; - var img = document.createElement('img'); - img.src = chrome.extension.getURL('icons/16.png'); - img.alt = title; - img.title = title; - spans[0].insertBefore(img, mark); - spans[0].insertBefore(document.createTextNode(' '), mark); - for(var i = 0; i < spans.length; i++) { - var span = spans[i]; - span.addEventListener('mouseover', listener, false); - span.title = title; - } - - qls.push({ name: name, ql: ql }); - qlspans[name] = spans; +function onConfigurationReady( items ) { + config = items; + + // If enabled, we do our thing here. + if ( config[ allianceQLsEnabledKey ] ) { + getPageQLs(); + storePageQLs(); + } + + // Listen for chhanges in configuration. + chrome.storage.onChanged.addListener( onConfigurationChange ); } -function parseQLs() { - // find the tabstyle table - var tables = document.getElementsByClassName('tabstyle'); - for(var i = 0; i < tables.length; i++) { - - // the infamous QL regexp - var rx = /(\S[^\n]*\n)\s*((?:d|r)\s*;\s*m?\s*;\s*t?\s*;\s*r?\s*;\s*[efn]*\s*;\s*[feun]*\s*;\s*b?\s*;\s*(?:f(?::\d*)?)?\s*;\s*(?:e(?::\d*)?)?\s*;\s*(?:u(?::\d*)?)?\s*;\s*(?:n(?::\d*)?)?\s*;\s*(?:(?:g|l):\d+)?\s*;\s*[0-6]*\s*;\s*(?:\d+(?:\s*,\s*\d+)*)?\s*;\s*(?:\d+(?:\s*,\s*\d+)*)?\s*;\s*[fn]*\s*;\s*[feun]*\s*;\s*(?:(?:g|l):\d+)?\s*;\s*[0-6]*\s*;\s*(?:\d+(?:\s*,\s*\d+)*)?\s*;\s*(?:\d+(?:\s*,\s*\d+)*)?\s*;\s*\d+)/g; - var element = tables[i]; - var slicer = new TreeSlicer(element); - - var m; - while((m = rx.exec(slicer.text))) { - var ql = m[2]; - var offset = m.index + m[0].length - ql.length; - //console.log('match name ' + name + ': [' + ql + ']'); - var spans = slicer.slice(offset, offset + ql.length); - registerQL(m[1], ql, spans); - } - } - - if(qls.length > 0) { - var universe = universeName(); - port.postMessage({ op: 'setValue', - key: 'allianceQLs' + universe, - value: JSON.stringify(qls) }); - port.postMessage({ op: 'setValue', - key: 'allianceQLs' + universe + 'MTime', - value: Math.floor(Date.now() / 1000) }); - - var msg; - if(qls.length == 1) - msg = 'Updated one alliance quick list.'; - else - msg = 'Updated ' + qls.length + ' alliance quick lists.'; - port.postMessage({ op: 'showNotification', - title: 'Alliance Quick Lists', - message: msg, - duration: 10000 }); - } +function onConfigurationChange( changes, area ) { + if ( area != 'local' ) { + return; + } + + // Update our copies of the config values. + + for ( var key in changes ) { + if ( config.hasOwnProperty( key ) ) { + config[ key ] = changes[ key ].newValue; + } + } + + // Now react to the user enabling or disabling alliance QLs in + // this universe. + + if ( changes.hasOwnProperty( allianceQLsEnabledKey ) ) { + if ( config[ allianceQLsEnabledKey ] ) { + if ( pageScanTimestamp ) { + // It got re-enabled. Just show all icons again, + // update if needed. + for ( var name in pageAddedElements ) { + pageAddedElements[ name ].icon.style.display = null; + } + } + else { + // It was disabled when we loaded the page, and now + // it's on. Scan the page then. + getPageQLs(); + } + + // And (re) store the page QLs (unless stale). + storePageQLs(); + } + else { + // Alliance QLs were disabled. Ordinarily, we'll remove + // all of our changes and leave a pristine document. + // However, that'd be too much faff for this page - the + // slicer makes a lot of changes. So instead we'll just + // hide them. + highlightQL( null ); + for ( var name in pageAddedElements ) { + pageAddedElements[ name ].icon.style.display = 'none'; + } + } + } } -function messageHandler(msg) { - if(msg.key == enabledKey && msg.value) { - qls = new Array(); - qlspans = new Object(); - parseQLs(); - } +// This scans the document, and irreversibly changes it. Make sure it +// runs only once. +function getPageQLs() { + var tabstyleTables, i, end; + + if ( pageScanTimestamp ) { + throw new Error( 'getQLs running twice' ); + } + + pageScanTimestamp = Math.floor( Date.now() / 1000 ); + pageQLs = []; + pageAddedElements = {}; + highlightedQL = null; + + // My Alliance has a table with class "tabstyle". I don't suppose + // there could be more than one of those, but we don't care, we + // check them all cause we can and it's easy. + + tabstyleTables = document.getElementsByClassName( 'tabstyle' ); + for ( i = 0, end = tabstyleTables.length; i < end; i++ ) { + processTabstyleTable( tabstyleTables[ i ], pageQLs ); + } + + fixNames( pageQLs ); + + // Get icons and spans out from pageQLs, so we can store pageQLs + // if we have to. + for ( i = 0, end = pageQLs.length; i < end; i++ ) { + var entry = pageQLs[ i ], name = entry.name, spans = entry.spans; + + pageAddedElements[ name ] = { + icon: entry.icon, + spans: spans + }; + + delete entry.icon; + delete entry.spans; + + // Also add a mouseover handler to the spans, for extra feedback + for ( var j = 0, jend = spans.length; j < jend; j++ ) { + var span = spans[ j ]; + span.addEventListener( 'mouseover', highlightQL ); + span.title = name; + } + } } -function run() { - var universe = universeName(); - enabledKey = 'allianceQLs' + universe + 'Enabled'; - port = chrome.extension.connect(); - port.onMessage.addListener(messageHandler); - port.postMessage({ op: 'subscribe', keys: [ enabledKey ] }); +// This runs once the page QLs have been scanned, and again if +// allianceQLsEnabled is disabled and then re-enabled for the +// universe. It compares the configured value with what's on the +// page. If it's not the same, updates the configuration. +function storePageQLs() { + var notification, items, message; + + if ( config[ allianceQLsMTimeKey ] >= pageScanTimestamp ) { + // Configuration is up-to-date or is even more recent than the + // time when when we scanned the page. + return; + } + + items = {}; + notification = compareQLLists( config[ allianceQLsKey ], pageQLs ); + + if ( notification ) { + message = { + desktopNotification: notification, + title: 'Alliance QLs' + }; + chrome.runtime.sendMessage( message, function(){} ); + items[ allianceQLsKey ] = pageQLs; + } + + items[ allianceQLsMTimeKey ] = pageScanTimestamp; + + // If QLs were updated, this will trigger onConfigurationChange, + // which will updated config. + chrome.storage.local.set( items ); } -run(); +function processTabstyleTable( table, qls ) { + var slicer, match, text, name, qltext, offset, + spans, child, icon, entry; + + // The infamous QL regexp. This is actually properly documented, + // but not here, that'd be too much. See + // https://github.com/valitas/Pardus-Sweetener/wiki/QL-parsing + + var rx = /(?:\{\s*([A-Za-z0-9](?:[-+_/#&()A-Za-z0-9 \t\u00a0]{0,58}[A-Za-z0-9)])?)\s*\}|\[\s*([A-Za-z0-9](?:[-+_/#&()A-Za-z0-9 \t\u00a0]{0,58}[A-Za-z0-9)])?)\s*\]|^\s*?([A-Z](?:[-+_/#&()A-Za-z0-9 \t\u00a0]{0,58}[A-Za-z0-9)])?)(?:\s*:)?)?\s*((?:d|r(?:\d*)?);m?;t?;r?;[efn]{0,3};[feun]{0,4};b?;[f:0-9\s]*;[e:0-9\s]*;[u:0-9\s]*;[n:0-9\s]*;[gl:0-9\s]*;[0-6\s]*;[0-9,\s]*;[0-9,\s]*;[fn\s]*;[feun\s]*;[gl:0-9\s]*;[0-6\s]*;[0-9,\s]*;[0-9,\s]*;[0-9\s]*[0-9](?:\s*;\s*[es])?)/gm; + + slicer = new TreeSlicer( table ); + text = slicer.text; + + while (( match = rx.exec( text ) )) { + name = match[ 1 ] || match[ 2 ]; + if ( name ) { + // Delimited ("SG" style), we capitalise here. + name = name.toUpperCase(); + } + else { + // Free form. If no name was found, we use "QL #n", where + // n is the ordinal of the QL as it appears on the page. + // This means there may be, e.g., an "Op QL" and a + // "QL #2". This is what we want. + name = match[ 3 ] || ( 'QL #' + (qls.length + 1) ); + } + + qltext = match[ 4 ]; + offset = match.index + match[ 0 ].length - qltext.length; + + // This modifies the document, and gives us an array of the + // added spans. + spans = slicer.slice( offset, offset + qltext.length ); + + // Add a tiny sweetener icon to the first span, to make + // visible where we parsed a QL. + child = spans[0].firstChild; + icon = document.createElement( 'img' ); + icon.src = chrome.extension.getURL( 'icons/16.png' ); + icon.alt = name; + icon.title = name; + spans[ 0 ].insertBefore( icon, child ); + spans[ 0 ].insertBefore( document.createTextNode( ' ' ), child ); + + entry = { + name: name, + ql: qltext.replace( /\s+/g, '' ), + + // These two we'll save here temporarily, so we can get + // back to them after fixNames. We need to delete both + // before storing this entry in config, though. + icon: icon, + spans: spans + }; + + qls.push( entry ); + } +} + +function highlightQL( event ) { + var target = event ? event.target : null, + name = target ? target.title : null, + spans, i, end; + + if ( name == highlightedQL ) { + return; + } + + if ( highlightedQL ) { + spans = pageAddedElements[ highlightedQL ].spans; + for ( i = 0, end = spans.length; i < end; i++ ) { + spans[ i ].style.color = null; + } + } + + highlightedQL = name; + + if ( config[ allianceQLsEnabledKey ] && highlightedQL ) { + spans = pageAddedElements[ highlightedQL ].spans; + for ( i = 0, end = spans.length; i < end; i++ ) { + spans[ i ].style.color = '#3984c6'; + } + } +} + +// We don't want QLs with duplicate names. If there are any duplicate +// names, change them to "name #1", "name #2", etc. Where a "name #n" +// already existed, use the next n available. +// +// This renaming may cause unexpected results if the alliance page +// itself was using the "#n" suffix, and using it inconsistently. But +// that's pathological, so we'll press forward. We will be consistent, +// and we won't have duplicates, even in that case. + +function fixNames( qls ) { + var inUse = {}, counters = {}, i, end, ql, name, n, newName; + + for ( i = 0, end = qls.length; i < end; i++ ) { + ql = qls[ i ]; + name = ql.name; + + if ( inUse[ name ] ) { + inUse[ name ] += 1; + } + else { + inUse[ name ] = 1; + } + } + + for ( i = 0, end = qls.length; i < end; i++ ) { + ql = qls[ i ]; + name = ql.name; + if ( inUse[ name ] > 1 ) { + n = counters[ name ]; + if ( !n ) { + n = counters[ name ] = 1; + } + + // Find the first n that is not in use in a name. + do { + newName = name + ' #' + n; + n += 1; + } while ( inUse[newName] ); + + ql.name = newName; + counters [ name ] = n; + } + } +} + +// See if there were any changes between two sets of QLs. This is to +// fix #29. While we're at it, build a human notice suitable for a +// desktop notification. + +function compareQLLists( first, second ) { + var key, entry, i, end, a = {}, b = {}, + added = 0, removed = 0, changed = 0, r, n; + + for ( i = 0, end = first.length; i < end; i++) { + entry = first[ i ]; + a[ entry.name ] = entry; + } + + for ( i = 0, end = second.length; i < end; i++) { + entry = second[ i ]; + key = entry.name; + b[ key ] = true; + + if ( a.hasOwnProperty( key ) ) { + if ( a[ key ].ql != entry.ql ) { + changed++; + } + } + else { + added++; + } + } + + for ( key in a ) { + if ( !b.hasOwnProperty( key ) ) { + removed++; + } + } + + r = []; + + if ( added ) { + r.push( 'added ' + added ); + n = added; + } + if ( changed ) { + r.push( 'updated ' + changed ); + n = changed; + } + if ( removed ) { + r.push( 'removed ' + removed ); + n = removed; + } + + if ( r.length == 0 ) { + return null; + } + + r = r.join( ', ' ); + + return r.substr( 0, 1 ).toUpperCase() + r.substr( 1 ) + ' alliance ' + + ( n == 1 ? 'QL' : 'QLs' ) + + '.'; +} + +// Start the ball. +start(); + +})( document, TreeSlicer ); diff --git a/nav.js b/nav.js index dc34ccd..49aa492 100644 --- a/nav.js +++ b/nav.js @@ -1,354 +1,451 @@ -// Additions to the nav page -// Load shiplinks.js before this. - -function PSNavPageDriver(doc) { this.initialise(doc); } - -PSNavPageDriver.prototype = { - - LINKS: { - navEquipmentLink: { href: 'ship_equipment.php', - name: 'Ship equipment' }, - navPlanetTradeLink: { href: 'planet_trade.php', - name: 'Trade with planet' }, - navSBTradeLink: { href: 'starbase_trade.php', - name: 'Trade with starbase' }, - navBldgTradeLink: { href: 'building_trade.php', - name: 'Trade with building' }, - navBMLink: { href: 'blackmarket.php', - name: 'Black market' }, - navHackLink: { href: 'hack.php', - name: 'Hack information' }, - navBBLink: { href: 'bulletin_board.php', - name: 'Bulletin board' } - }, - - PSBKEYS: { - '/planet.php': [ 'navEquipmentLink', - 'navPlanetTradeLink', - 'navBMLink', - 'navHackLink', - 'navBBLink' ], - '/starbase.php': [ 'navEquipmentLink', - 'navSBTradeLink', - 'navBMLink', - 'navHackLink', - 'navBBLink' ], - '/building.php': [ 'navBldgTradeLink', - 'navHackLink' ] - }, - - // Ordinarily, we'd wait for DOMContentLoaded before doing all the - // initialisation below. However, this being a content script, we - // rely on Chrome to call us at the proper time, and assume doc is - // ready by now. - - initialise: function(doc) { - this.doc = doc; - this.enabledLinks = new Object(); - - this.port = chrome.extension.connect(); - this.port.onMessage.addListener(this.onPortMessage.bind(this)); - - var keys = Object.keys(this.LINKS); - - // Remember how many link options we'll request. We use this - // later to notice when we have them all. - this.linkOptionCount = keys.length; - - keys.push('navShipLinks'); - keys.push('miniMap'); - keys.push('miniMapPosition'); - this.port.postMessage({ op: 'subscribe', keys: keys }); - - // Insert a bit of script to execute in the page's context and - // send us what we need. And add a listener to receive the call. - var window = doc.defaultView; - window.addEventListener('message', this.onMessage.bind(this), false); - var script = doc.createElement('script'); - script.type = 'text/javascript'; - script.textContent = "(function() {var fn=function(){window.postMessage({pardus_sweetener:1,loc:typeof(userloc)=='undefined'?null:userloc,ajax:typeof(ajax)=='undefined'?null:ajax},window.location.origin);};if(typeof(addUserFunction)=='function')addUserFunction(fn);fn();})();"; - doc.body.appendChild(script); - }, - - // This is a handler for DOM messages coming from the game page. - // Arrival of a message means the page contents were updated. The - // message contains the value of the userloc variable, too. - onMessage: function(event) { - var data = event.data; - if(!data || data.pardus_sweetener != 1) - return; - this.userloc = parseInt(data.loc); - this.ajax = data.ajax; - - var doc = this.doc, box = doc.getElementById('commands_content'); - if(box && this.linksConfigured) - this.setupLinks(box); - - box = doc.getElementById('otherships_content'); - if(box && this.shipLinksEnabled) { - removeElementsByClassName(box, 'psw-slink'); - var ships = - getShips(box, "table/tbody/tr/td[position() = 2]/a", this.matchId); - addShipLinks(ships); - } - - if(this.miniMapEnabled && this.miniMapPosition) - this.updateMiniMap(); - }, - - matchId: function(url) { - var r; - - // This matches strings of the form: - // javascript:scanId(22324, "player") - // or - // javascript:scanId(25113, "opponent") - var m = /^javascript:scanId\((\d+),\s*['"]([^'"]+)['"]\)|main\.php\?scan_details=(\d+)&scan_type=([A-Za-z]+).*$/.exec(url); - if(m) { - var id = m[1]; - if(id) - r = { type: m[2], id: parseInt(id) }; - else - r = { type: m[4], id: parseInt(m[3]) }; - } - - return r; - }, - - onPortMessage: function(msg) { - switch(msg.op) { - case 'updateValue': - var doc = this.doc, info = this.LINKS[msg.key]; - if(info) { - this.enabledLinks[msg.key] = msg.value; - if(Object.keys(this.enabledLinks).length == this.linkOptionCount) { - // configuration is complete - this.linksConfigured = true; - var cbox = doc.getElementById('commands_content'); - if(cbox) - this.setupLinks(cbox); - } - } - else { - switch(msg.key) { - case 'navShipLinks': - // First of all, remove all links we may have added before. - // This function will be called if the configuration - // changes. The utility function removeElementsByClassName - // is currently in shiplinks.js; we may move it somewhere - // else later. - var sbox = doc.getElementById('otherships_content'); - if(sbox) { - removeElementsByClassName(sbox, 'psw-slink'); - this.shipLinksEnabled = msg.value; - // Now, if enabled, add them again. - if(this.shipLinksEnabled) { - var ships = - getShips(sbox, "table/tbody/tr/td[position() = 2]/a", - this.matchId); - addShipLinks(ships); - } - } - break; - case 'miniMap': - this.miniMapEnabled = msg.value; - if(this.miniMapEnabled) { - if(this.miniMapPosition) - this.updateMiniMap(); - // else we haven't yet received the position, so we'll - // update when we get it - } - else - this.removeMiniMap(); - break; - case 'miniMapPosition': - if(this.miniMapPosition != msg.value) { - if(this.miniMapPosition) - // position may have changed; get shot on the displayed - // map, if any. - this.removeMiniMap(); - - this.miniMapPosition = msg.value; - if(this.miniMapEnabled) - this.updateMiniMap(); - } - } - } - break; - - case 'updateMap': - if(this.miniMapEnabled) - this.configureMiniMap(msg.sector); - } - }, - - setupLinks: function(cbox) { - // First of all, remove all links we may have added before. This - // function will be called if the configuration changes. The - // utility function removeElementsByClassName is currently in - // shiplinks.js, we may move it somewhere else later. - removeElementsByClassName(cbox, 'psw-plink'); - - // find the "Land on planet", "Land on starbase" or "Enter - // building" div. We look for the link to planet.php, starbase.php - // or building.php really; if we find one of those, we use the - // parent div. - var planet, keys, i, end; - var as = cbox.getElementsByTagName('a'); - for(i = 0, end = as.length; i < end; i++) { - var a = as[i]; - keys = this.PSBKEYS[a.pathname]; - if(keys) { - planet = a.parentNode; - break; - } - } - - if(planet) { - // rock and roll. add the enabled links then - var doc = this.doc, here = planet.nextSibling; - for(i = 0, end = keys.length; i < end; i++) { - var key = keys[i]; - if(this.enabledLinks[key]) { - var info = this.LINKS[key]; - var e = doc.createElement('div'); - e.className = 'psw-plink'; - var a = doc.createElement('a'); - a.href = info.href; - a.textContent = info.name; - e.appendChild(a); - // don't ask me, this weird positioning is how pardus does it... - e.style.position = 'relative'; - e.style.top = '6px'; - e.style.left = '6px'; - e.style.fontSize = '11px'; - cbox.insertBefore(e, here); - } - } - } - }, - - getCurrentSectorName: function() { - var elt = this.doc.getElementById('sector'); - return elt ? elt.textContent : null; - }, - - getCurrentCoords: function(result) { - var elt = this.doc.getElementById('coords'); - if(elt) { - var m = /^\[(\d+),(\d+)\]$/.exec(elt.textContent); - if(m) { - if(!result) - result = new Object(); - result.col = parseInt(m[1]); - result.row = parseInt(m[2]); - return result; - } - } - - return null; - }, - - // This is only called when both the miniMap setting has been - // received and is true, and miniMapPosition has been received. We - // check this. - updateMiniMap: function() { - var sectorName = this.getCurrentSectorName(); - // If we can't find the sector, there's no point continuing. - if(!sectorName) - return; - - // If we have no map, or if the sector currently displayed is not - // the one we're in, we need to reconfigure the map. - var map = this.map; - if(!map || !this.miniMapSector || this.miniMapSector.sector != sectorName) { - this.port.postMessage({ op: 'requestMap', sector: sectorName }); - return; - } - - var ctx = map.get2DContext(); - map.clear(ctx); - - var c = this.getCurrentCoords(); - if(c) - map.markTile(ctx, c.col, c.row, '#fc0'); - }, - - removeMiniMap: function() { - if(this.map) - delete this.map; - if(this.miniMapSector) - delete this.miniMapSector; - if(this.mapContainer) { - this.mapContainer.parentNode.removeChild(this.mapContainer); - delete this.mapContainer; - } - }, - - configureMiniMap: function(sector) { - var doc = this.doc, map = this.map; - - this.removeMiniMap(); - - if(this.miniMapPosition == 'statusbox') { - // Add map to status box - var sbox = doc.getElementById('status_content'); - if(!sbox) - return; - // status_content gets clobbered by partial refresh, so we - // don't add our canvas to it. - // - // partial refresh *appends* a new status_content to the - // parent of that node. so we don't add it there either, or - // the new partial_content will appear after our map. instead, - // we add a new tr to the table. - var sctd = sbox.parentNode, sctr = sctd.parentNode, tr, td; - tr = sctr.cloneNode(false); - td = sctd.cloneNode(false); - td.style.textAlign = 'center'; - // This is needed because Pardus' tables are off centre with - // respect to the borders drawn as background images. Crusty, - // old, early 2000's HTML there. - td.style.paddingRight = '3px'; - sctr.parentNode.insertBefore(tr, sctr.nextSibling); - tr.appendChild(td); - - var canvas = doc.createElement('canvas'); - td.appendChild(canvas); - - map = new PSMap(); - map.setCanvas(canvas); - map.configure(sector, 180); - // Remember the tr we added to the status table. Because - // that's the one we'll have to remove if the map should be - // switched off. - this.mapContainer = tr; - } - else { - // Add map on top of the right-side bar. - var rtd = doc.getElementById('tdTabsRight'); - if(!rtd) - return; - var div = doc.createElement('div'); - div.style.textAlign = 'center'; - div.style.width = '208px'; - div.style.margin = '0 2px 24px auto'; - var canvas = doc.createElement('canvas'); - canvas.style.border = '1px outset #a0b1c9'; - div.appendChild(canvas); - rtd.insertBefore(div, rtd.firstChild); - - map = new PSMap(); - map.setCanvas(canvas); - map.configure(sector, 200); - - this.mapContainer = div; - } - - this.map = map; - this.miniMapSector = sector; - this.updateMiniMap(); - } -}; - -var ps_pagedriver = new PSNavPageDriver(document); +// The nav driver. +// Require shiplinks.js + +'use strict'; + +(function( top, doc, ShipLinks, SectorMap ){ + +var LOCATION_LINKS = { + planet: [ + { key: 'navEquipmentLink', + text: 'Ship equipment', + url: 'ship_equipment.php' }, + { key: 'navTradeLink', + text: 'Trade with planet', + url: 'planet_trade.php' }, + { key: 'navBlackMarketLink', + text: 'Black market', + url: 'blackmarket.php' }, + { key: 'navHackLink', + text: 'Hack information', + url: 'hack.php' }, + { key: 'navBulletinBoardLink', + text: 'Bulletin board', + url: 'bulletin_board.php' }, + { key: 'navBountyBoardLink', + text: 'Bounty board', + url: 'bounties.php' }, + { key: 'navShipyardLink', + text: 'Shipyard', + url: 'shipyard.php' }, + { key: 'navCrewQuartersLink', + text: 'Crew quarters', + url: 'crew_quarters.php' } + ], + starbase: [ + { key: 'navEquipmentLink', + text: 'Ship equipment', + url: 'ship_equipment.php' }, + { key: 'navTradeLink', + text: 'Trade with starbase', + url: 'starbase_trade.php' }, + { key: 'navBlackMarketLink', + text: 'Black market', + url: 'blackmarket.php' }, + { key: 'navHackLink', + text: 'Hack information', + url: 'hack.php' }, + { key: 'navBulletinBoardLink', + text: 'Bulletin board', + url: 'bulletin_board.php' }, + { key: 'navBountyBoardLink', + text: 'Bounty board', + url: 'bounties.php' }, + { key: 'navShipyardLink', + text: 'Shipyard', + url: 'shipyard.php' }, + { key: 'navCrewQuartersLink', + text: 'Crew quarters', + url: 'crew_quarters.php' }, + { key: 'navFlyCloseLink', + text: 'Fly close', + url: 'main.php?entersb=1' } ], + building: [ + { key: 'navTradeLink', + text: 'Trade with building', + url: 'building_trade.php' }, + { key: 'navHackLink', + text: 'Hack information', + url: 'hack.php' } + ] + }; + +// These variables hold state for the different bits and bobs on this +// page: +var config, configured, userloc, ajax, shiplinks, + showingLoclinks, minimap, minimapSector, minimapContainer; + +function start() { + var cs = new ConfigurationSet(); + + cs.addKey( 'navShipLinks' ); + cs.addKey( 'miniMap' ); + cs.addKey( 'miniMapPlacement' ); + + cs.addKey( 'navEquipmentLink' ); + cs.addKey( 'navShipyardLink' ); + cs.addKey( 'navCrewQuartersLink' ); + cs.addKey( 'navTradeLink' ); + cs.addKey( 'navBlackMarketLink' ); + cs.addKey( 'navHackLink' ); + cs.addKey( 'navBulletinBoardLink' ); + cs.addKey( 'navBountyBoardLink' ); + cs.addKey( 'navFlyCloseLink' ); + + shiplinks = new ShipLinks.Controller + ( 'table/tbody/tr/td[position() = 2]/a', matchShipId ); + config = cs.makeTracker( applyConfiguration ); + +} + +function applyConfiguration() { + if ( configured ) { + // Skipping these the first time we run because we know + // there's an upcoming game message which will call these + // anyway. Subsequent calls are configuration changes tho, + // then we want to act. + + shiplinks.update( config.navShipLinks ); + updateLocationLinks(); + + // This may be a bit wasteful: we reinstall the minimap even + // if unrelated parameters changed. Configuration changes are + // infrequent, though. + + removeMinimap(); + updateMinimap(); + } + else { + // Instead, we only want to do this the first time we run, + // because we only want to do it once. We didn't do it in + // start() because we didn't want to receive messages from the + // game until we were properly configured. But now we are. + + // Insert a bit of script to execute in the page's context + // and send us what we need. And add a listener to receive + // the call. This will call us back immediately, and + // again whenever a partial refresh completes. + doc.defaultView.addEventListener( 'message', onGameMessage ); + var script = doc.createElement( 'script' ); + script.type = 'text/javascript'; + script.textContent = "(function() {var fn=function(){window.postMessage({pardus_sweetener:1,loc:typeof(userloc)=='undefined'?null:userloc,ajax:typeof(ajax)=='undefined'?null:ajax},window.location.origin);};if(typeof(addUserFunction)=='function')addUserFunction(fn);fn();})();"; + doc.body.appendChild( script ); + + configured = true; + } +} + +// Arrival of a message means the page contents were updated. The +// message contains the value of the userloc variable, too. +function onGameMessage( event ) { + var data = event.data; + + if ( !data || data.pardus_sweetener != 1 ) { + return; + } + + userloc = parseInt( data.loc ); + ajax = data.ajax; + + // The shiplinks box is usually clobbered by partial refresh, so + // we need a new container. This is cheap anyway. + shiplinks.setContainer( doc.getElementById('otherships_content') ); + shiplinks.update( config.navShipLinks ); + + // Likewise, the commands box may have been wiped. + updateLocationLinks(); + + updateMinimap(); + + configured = true; +} + +// This does a bit more work than may be needed. It's called when a +// partial refresh completes, and also when configuration changes. +// The possibly wasteful computations are: +// +// * It may remove elements with class 'psw-plink' after partial +// refresh just clobbered the commands_content box, so there won't +// be any such elements anyway. We don't know if we can ascertain +// cheaply that we still have the old box, though, so we could do +// the removal conditionally. In any case, we don't do the removal +// if the previous call resulted in no links, which means the +// majority of times we won't do it anyway. +// +// * It will reinstall the location links when unrelated configuration +// parameter change. Specifically: navShipLinks, minimap, +// minimapPlacement. This, however, is very infrequent; we'd waste +// a lot more time checking whether loclinks options changed, every +// time. + +function updateLocationLinks() { + var loctype, cbox, anchor, loclinks, here; + + cbox = doc.getElementById( 'commands_content' ); + + if ( showingLoclinks ) { + // Remove all links we may have added before. + ShipLinks.removeElementsByClassName( cbox, 'psw-plink' ); + showingLoclinks = false; + } + + // Find the "Land on planet", "Land on starbase" or "Enter + // building" anchor. + if (( anchor = doc.getElementById( 'aCmdPlanet' ) )) { + loctype = 'planet'; + } + else if (( anchor = doc.getElementById('aCmdStarbase') )) { + loctype = 'starbase'; + } + else if (( anchor = doc.getElementById('aCmdBuilding') )) { + loctype = 'building'; + } + else { + return; + } + + loclinks = LOCATION_LINKS[ loctype ]; + here = anchor.parentNode.nextSibling; + for ( var i = 0, end = loclinks.length; i < end; i++ ) { + var spec = loclinks[ i ]; + + if ( config[ spec.key ] ) { + var e = doc.createElement( 'div' ), + a = doc.createElement( 'a' ); + + e.className = 'psw-plink'; + a.href = spec.url; + a.textContent = spec.text; + e.appendChild( a ); + + // don't ask me, this weird positioning is how pardus does it... + e.style.position = 'relative'; + e.style.top = '6px'; + e.style.left = '6px'; + e.style.fontSize = '11px'; + cbox.insertBefore( e, here ); + + showingLoclinks = true; + } + } +} + +function matchShipId( url ) { + var rx, r, m; + + // This matches strings of the form: + // javascript:scanId(22324, "player") + // or + // javascript:scanId(25113, "opponent") + rx = /^javascript:scanId\((\d+),\s*['"]([^'"]+)['"]\)|main\.php\?scan_details=(\d+)&scan_type=([A-Za-z]+).*$/; + m = rx.exec( url ); + if ( m ) { + var id = m[ 1 ]; + if ( id ) { + r = { type: m[2], id: parseInt( id ) }; + } + else { + r = { type: m[4], id: parseInt( m[ 3 ] ) }; + } + } + + return r; +} + +function updateMinimap() { + var sectorName; + + if ( !config.miniMap ) { + return; + } + + sectorName = getCurrentSectorName(); + if ( sectorName ) { + // If we have a map, and the sector currently displayed is the + // one we're in, just refresh the map to show our current + // position. + if ( minimap && minimapSector && minimapSector.sector == sectorName ) { + refreshMinimap(); + } + else { + // We need to reconfigure the map. See if we have the map + // data cached in the top window. If we do, we can save a + // lot of time: no message shuffling, no XMLHttpRequest in + // the event page, no JSON parsing. + var sector = top.psMapData && top.psMapData[ sectorName ]; + if ( sector ) { + configureMinimap( sector ); + } + else { + // Nope, request it + chrome.runtime.sendMessage( { requestMap: sectorName }, + configureMinimap ); + } + } + } + else { + // If we don't know in which sector we are, there's no point + // in showing a map at all. + removeMinimap(); + } +} + +// This assumes we have a working map. +function refreshMinimap() { + var ctx = minimap.get2DContext(), coords = getCurrentCoords(); + + minimap.clear( ctx ); + + if ( coords ) { + minimap.markTile( ctx, coords.col, coords.row, '#0f0' ); + } +} + +function removeMinimap() { + minimap = undefined; + minimapSector = undefined; + if ( minimapContainer ) { + minimapContainer.parentNode.removeChild( minimapContainer ); + minimapContainer = undefined; + } +} + +// This is called when we receive the sector data from the extension. +function configureMinimap( sector ) { + var canvas, container, size; + + // If we were showing a map, get shot of it, we're rebuilding it + // anyway. + removeMinimap(); + + if ( sector.error ) { + return; + } + + // Cache the sector data, so we don't ask for it again. + if ( !top.psMapData ) { + top.psMapData = {}; + } + top.psMapData[ sector.sector ] = sector; + + canvas = doc.createElement( 'canvas' ); + + // Figure out where to place the map and what size it should be. + + if ( config.miniMapPlacement == 'statusbox' ) { + // Inside the status box. This is a table with id "status", + // which contains three rows. The first and last are graphic + // stuff, contain pics showing the top and bottom of a box - + // all very early 2000's HTML, this. The middle row contains + // a td which in turn contains a div with id "status_content", + // where all the status stuff is shown. + // + // Now the "status_content" div gets clobbered by partial + // refresh, so we don't add our canvas to it - we want it to + // outlive partial refresh. Also, partial refresh *appends* a + // new "status_content" div to the td that contained the old + // one, so we can't add our map in that same td either, or + // else the new status_content would end up below the old map + // after the first partial refresh. + // + // So what we do instead is: we add a new tr to the status + // table, right after the tr that contains status_content, and + // before the tr with the bottom picture. And our map lives + // in that new tr. + + var tbody, tr, td, newtd; + + // Right then, so first we find the td that contains + // "status_content"... + td = doc.evaluate( "//tr/td[div[@id = 'status_content']]", + doc, null, XPathResult.ANY_UNORDERED_NODE_TYPE, + null ).singleNodeValue; + if ( !td ) { + return; + } + + // ... and its parent tr, and whatever contains that (tbody of + // course, but we don't care what exactly it is). + tr = td.parentNode; + tbody = tr.parentNode; + + // Then we shallow-clone both, so we get their attributes, + // which include their styling. The new td will contain the + // map canvas and the new tr will be the single element that + // we'll insert in the document (read: the one that would have + // to be removed to restore the document to its pristine + // state). + container = tr.cloneNode( false ); + newtd = td.cloneNode( false ); + + // Tweak a bit for looks. This is needed because Pardus' + // tables are off centre with respect to the borders drawn as + // background images. Crusty HTML there, I tell you. + newtd.style.textAlign = 'center'; + newtd.style.paddingRight = '3px'; + + // Finally, add the canvas and assemble the table row. + newtd.appendChild( canvas ); + container.appendChild( newtd ); + tbody.insertBefore( container, tr.nextSibling ); + size = 180; + } + else { + // Add the map at the top of the right-side bar. This is + // easier: there's a td that contains the whole sidebar, so we + // just insert a div as first element. + + var td = doc.getElementById( 'tdTabsRight' ); + + if ( !td ) { + return; + } + + container = doc.createElement( 'div' ); + container.style.textAlign = 'center'; + container.style.width = '208px'; + container.style.margin = '0 2px 24px auto'; + canvas.style.border = '1px outset #a0b1c9'; + container.appendChild( canvas ); + td.insertBefore( container, td.firstChild ); + size = 200; + } + + // At this point we already have the canvas in the document. So + // just configure it, and remember the pertinent variables. + minimap = new SectorMap(); + minimap.setCanvas( canvas ); + minimap.configure( sector, size ); + minimapContainer = container; + minimapSector = sector; + + // And draw the map. + refreshMinimap(); +} + +function getCurrentSectorName() { + var elt = doc.getElementById( 'sector' ); + return elt ? elt.textContent : null; +} + +function getCurrentCoords( result ) { + var elt = doc.getElementById( 'coords' ); + if ( elt ) { + var m = /^\[(\d+),(\d+)\]$/.exec( elt.textContent ); + if ( m ) { + if ( !result ) { + result = new Object(); + } + result.col = parseInt( m[1] ); + result.row = parseInt( m[2] ); + return result; + } + } + + return null; +} + +// Start the ball +start(); + +})( top, document, ShipLinks, SectorMap ); diff --git a/notifier.js b/notifier.js deleted file mode 100644 index 36bd54b..0000000 --- a/notifier.js +++ /dev/null @@ -1,28 +0,0 @@ -function Notifier() { } - -Notifier.prototype = { - hide: function() { - if(this.notification) { - this.notification.cancel(); - delete this.notification; - } - }, - - show: function(title, text, timeout) { - this.hide(); - - var self = this; - if(!timeout) - timeout = 15000; - var n = webkitNotifications.createNotification('icons/48.png', title, text); - n.ondisplay = function() { - setTimeout(function() { - if(n == self.notification) - self.hide(); - }, timeout); - }; - - this.notification = n; - this.notification.show(); - } -}; diff --git a/options.html b/options.html index 658e2b3..912eda6 100644 --- a/options.html +++ b/options.html @@ -39,7 +39,11 @@

Audible alarm

Alarm sound:
@@ -161,25 +165,14 @@

Ambush and combat

Quick lists

- User interface additions for fast ambush setting. Alliance QLs - are obtained from your My Alliance page, provided the page is - formatted properly (see + Alliance QLs are obtained from your My Alliance page; see here - for details). + for details.

-
- - -
-
Artemis @@ -251,6 +244,24 @@

Quick lists

+ +
+ + +
+ +
+ + +
+
@@ -581,10 +592,10 @@

General user interface enhancements

Clocks

- Display countdowns to recurring in-game events. For these to be - accurate, your computer's time zone and clock should be properly - set. Modern operating systems can keep your clock on the correct - time automatically; see + Countdowns to recurring in-game events. For these to be accurate, + your computer's clock and time zone should be set correctly. + Modern operating systems can keep your clock on the correct time + automatically; see this @@ -603,7 +614,7 @@

Clocks

@@ -676,7 +687,7 @@

Map

Map placement in the Nav page: - @@ -701,45 +712,59 @@

Quick links

- -
- -
- -
- -
- -
- -
+ +
+ + +
+ +
+ +
@@ -780,7 +805,7 @@

Pardus Sweetener

Copyright © 2011-2013 Val. This software is free to use and modify: see legal notice in the file README.md included in the software package, or read it -
online. Please report bugs and request enhancements in the diff --git a/options.js b/options.js index ba5b749..3b9c727 100644 --- a/options.js +++ b/options.js @@ -1,318 +1,355 @@ // Pardus Sweetener // The wiring of the options page. -function PSOptionsPageDriver(doc) { this.initialise(doc); } -PSOptionsPageDriver.prototype = { - initialise: function(doc) { - this.doc = doc; - doc.addEventListener('DOMContentLoaded', - this.onDOMContentLoaded.bind(this)); - }, - - onDOMContentLoaded: function() { - this.controls = new Object(); - var doc = this.doc, controls = this.controls, keys, i, end; - - // Find all these elements in the document, save references to - // them in own object properties. - keys = [ 'showAlarmGroup', 'showCombatGroup', 'showGeneralGroup', - 'showHelpGroup', - 'alarmGroup', 'combatGroup', 'generalGroup', 'helpGroup', - 'testAlarm', 'testNotification', 'version' ]; - for(i = 0, end = keys.length; i < end; i++) { - var key = keys[i]; - this[key] = doc.getElementById(key); - } - - // Find all these elements in the document, save references to - // them in this.controls, and install event listeners. - keys = [ 'muteAlarm', 'alarmSound', 'alarmCombat', 'alarmAlly', - 'alarmWarning', 'alarmPM', 'alarmMission', - 'alarmTrade', 'alarmPayment', 'desktopCombat', - 'desktopAlly', 'desktopWarning', 'desktopPM', - 'desktopMission', 'desktopTrade', 'desktopPayment', - 'clockUTC', 'clockAP', 'clockB', 'clockP', 'clockS', - 'clockL', 'clockE', 'clockN', 'clockZ', 'clockR', - 'pvpMissileAutoAll', 'pvpHighestRounds', - 'pvmMissileAutoAll', 'pvmHighestRounds', - 'pvbMissileAutoAll', 'displayDamage', 'autobots', - 'autobotsArtemisPreset', 'autobotsArtemisPoints', - 'autobotsArtemisStrength', 'autobotsOrionPreset', - 'autobotsOrionPoints', 'autobotsOrionStrength', - 'autobotsPegasusPreset', 'autobotsPegasusPoints', - 'autobotsPegasusStrength', 'navEquipmentLink', - 'navPlanetTradeLink', 'navSBTradeLink', - 'navBldgTradeLink', 'navBMLink', 'navHackLink', - 'navBBLink', 'navShipLinks', 'overrideAmbushRounds', - 'allianceQLsArtemisEnabled', - 'personalQLArtemisEnabled', 'personalQLArtemis', - 'allianceQLsOrionEnabled', 'personalQLOrionEnabled', - 'personalQLOrion', 'allianceQLsPegasusEnabled', - 'personalQLPegasusEnabled', 'personalQLPegasus', - 'miniMap', 'miniMapPosition', 'sendmsgShowAlliance' ]; - - for(i = 0, end = keys.length; i < end; i++) { - var key = keys[i]; - var control = doc.getElementById(key); - if(control) { - controls[key] = control; - switch(control.type) { - case 'checkbox': - control.addEventListener('click', this.onCheckboxClick.bind(this)); - break; - case 'select-one': - control.addEventListener('change', this.onControlInput.bind(this)); - break; - case 'text': - case 'textarea': - control.addEventListener('input', this.onControlInput.bind(this)); - } - } - } - - this.version.textContent = chrome.runtime.getManifest().version; - - // Install additional listeners - this.wireGroupSwitch(this.showAlarmGroup, this.alarmGroup); - this.wireGroupSwitch(this.showCombatGroup, this.combatGroup); - this.wireGroupSwitch(this.showGeneralGroup, this.generalGroup); - this.wireGroupSwitch(this.showHelpGroup, this.helpGroup); - this.testAlarm. - addEventListener('click', this.onTestAlarmClick.bind(this)); - controls.alarmSound. - addEventListener('change', this.disableTestAlarm.bind(this)); - this.testNotification. - addEventListener('click', this.onTestNotificationClick.bind(this)); - controls.muteAlarm. - addEventListener('click', this.updateAlarmControlsDisable.bind(this)); - controls.autobots. - addEventListener('click', this.updateAutobotControlsDisable.bind(this)); - controls.miniMap. - addEventListener('click', this.updateMiniMapControlsDisable.bind(this)); - this.wireAutobotsPreset(controls.autobotsArtemisPreset, - controls.autobotsArtemisPoints); - this.wireAutobotsPreset(controls.autobotsOrionPreset, - controls.autobotsOrionPoints); - this.wireAutobotsPreset(controls.autobotsPegasusPreset, - controls.autobotsPegasusPoints); - this.wireQLControls(controls.personalQLArtemisEnabled, - controls.personalQLArtemis); - this.wireQLControls(controls.personalQLOrionEnabled, - controls.personalQLOrion); - this.wireQLControls(controls.personalQLPegasusEnabled, - controls.personalQLPegasus); - - // Connect to background page. - this.port = chrome.extension.connect(); - this.port.onMessage.addListener(this.messageHandler.bind(this)); - this.port.postMessage({ op: 'requestList', name: 'alarmSound' }); - // Note keys is still set to the last list of keys we used above - this.port.postMessage({ op: 'subscribe', keys: keys }); - }, - - // A shorthand, to simplify setup above - wireGroupSwitch: function(button, group) { - button.addEventListener('click', - this.onShowGroupClick.bind(this, button, group)); - }, - // Another shorthand - wireAutobotsPreset: function(preset, points) { - preset.addEventListener('change', - this.onAutobotsPresetChange.bind(this, - preset, points)); - points.addEventListener('input', - this.onAutobotsPointsInput.bind(this, - preset, points)); - }, - // And another shorthand - wireQLControls: function(enabled, ql) { - enabled.addEventListener('change', - this.onQLEnabledClick.bind(this, enabled, ql)); - }, - - onShowGroupClick: function(groupButton, group) { - var i, end, keys; - keys = ['showAlarmGroup', 'showCombatGroup', 'showGeneralGroup', - 'showHelpGroup']; - for(i = 0, end = keys.length; i < end; i++) - this[keys[i]].parentNode.classList.remove('selected'); - keys = ['alarmGroup', 'combatGroup', 'generalGroup', 'helpGroup']; - for(i = 0, end = keys.length; i < end; i++) - this[keys[i]].classList.remove('selected'); - groupButton.parentNode.classList.add('selected'); - group.classList.add('selected'); - group.scrollIntoView(true); - }, - - disableTestAlarm: function() { - this.port.postMessage({ op: 'stopAlarm' }); - this.testAlarm.value = 'Test'; - this.testAlarm.disabled = true; - }, - - onTestAlarmClick: function() { - if(this.testAlarm.value == 'Stop') { - this.port.postMessage({ op: 'stopAlarm' }); - this.testAlarm.value = 'Test'; - } - else { - var i = this.controls.alarmSound.selectedIndex; - if(i >= 0) { - this.testAlarm.value = 'Stop'; - this.port.postMessage({ op: 'soundAlarm' }); - } - } - }, - - onTestNotificationClick: function() { - this.port.postMessage({ op: 'testNotification' }); - }, - - onCheckboxClick: function(e) { - this.port.postMessage({ op: 'setValue', - key: e.target.id, value: e.target.checked }); - }, - - onControlInput: function(e) { - this.port.postMessage({ op: 'setValue', - key: e.target.id, value: e.target.value }); - }, - - messageHandler: function(msg) { - var control; - switch(msg.op) { - case 'updateValue': - control = this.controls[msg.key]; - if(control) - this.updateControlState(control, msg.value); - break; - case 'updateList': - control = this.controls[msg.name]; - if(control) - this.populateSelectControl(control, msg.list); - break; - case 'sampleReady': - if(this.testAlarm) - this.testAlarm.disabled = false; - } - }, - - updateControlState: function(control, value) { - switch(control.type) { - case 'checkbox': - control.checked = value; - switch(control.id) { - case 'autobots': - this.updateAutobotControlsDisable(); - break; - case 'muteAlarm': - this.updateAlarmControlsDisable(); - break; - case 'personalQLArtemisEnabled': - case 'personalQLOrionEnabled': - case 'personalQLPegasusEnabled': - this.updateQLControlsDisable(); - break; - case 'miniMap': - this.updateMiniMapControlsDisable(); - } - break; - case 'select-one': - this.updateSelectState(control, value); - break; - case 'text': - case 'textarea': - control.value = value; - } - }, - - // Find the option with a given value (not name or id), and select it - updateSelectState: function(control, value) { - var opts = control.options; - for(var i = 0; i < opts.length; i++) { - var opt = opts[i]; - if(opt.value == value) { - opt.selected = true; - break; - } - } - }, - - populateSelectControl: function(control, list) { - while(control.hasChildNodes()) - control.removeChild(control.firstChild); - - var doc = control.ownerDocument; - - for(var i = 0; i < list.length; i++) { - var entry = list[i]; - var o = doc.createElement('option'); - o.setAttribute('value', entry.id); - o.appendChild(doc.createTextNode(entry.name)); - control.appendChild(o); - } - }, - - updateAlarmControlsDisable: function() { - var disabled = this.controls.muteAlarm.checked; - var keys = [ 'alarmSound', 'alarmCombat', 'alarmAlly', - 'alarmWarning', 'alarmPM', 'alarmMission', - 'alarmTrade', 'alarmPayment' ]; - - for(var i = 0; i < keys.length; i++) - this.controls[keys[i]].disabled = disabled; - if(disabled) - this.disableTestAlarm(); - else - this.testAlarm.disabled = false; - }, - - updateAutobotControlsDisable: function() { - var disabled = !this.controls.autobots.checked; - var keys = [ 'autobotsArtemisPreset', 'autobotsArtemisPoints', - 'autobotsArtemisStrength', - 'autobotsOrionPreset', 'autobotsOrionPoints', - 'autobotsOrionStrength', - 'autobotsPegasusPreset', 'autobotsPegasusPoints', - 'autobotsPegasusStrength' ]; - - for(var i = 0, end = keys.length; i < end; i++) - this.controls[keys[i]].disabled = disabled; - }, - - onAutobotsPresetChange: function(preset, points) { - var v = parseInt(preset.value); - if(v > 0) - points.value = v; - // XXX - is this really necessary? doesn't the above fire a change - // event on the points field? - this.port.postMessage({ op: 'setValue', key: points.id, value: v }); - }, - - onAutobotsPointsInput: function(preset, points) { - if(parseInt(points.value) != parseInt(preset.value)) { - preset.selectedIndex = 0; - this.port.postMessage({ op: 'setValue', key: preset.id, value: 0 }); - } - }, - - onQLEnabledClick: function(enabled_checkbox, ql_field) { - ql_field.disabled = !enabled_checkbox.checked; - }, - - updateQLControlsDisable: function() { - var controls = this.controls; - controls.personalQLArtemis.disabled = - !controls.personalQLArtemisEnabled.checked; - controls.personalQLOrion.disabled = - !controls.personalQLOrionEnabled.checked; - controls.personalQLPegasus.disabled = - !controls.personalQLPegasusEnabled.checked; - }, - - updateMiniMapControlsDisable: function() { - this.controls.miniMapPosition.disabled = !this.controls.miniMap.checked; - } -}; - -var ps_pagedriver = new PSOptionsPageDriver(document); +'use strict'; + +(function( doc ) { + +var controls, extraControls, port; + +function start() { + doc.addEventListener( 'DOMContentLoaded', onDOMContentLoaded ); +} + +function onDOMContentLoaded() { + var keys, i, end; + + controls = {}; + extraControls = {}; + + // Find all these elements in the document, save references to + // them in xControls. + keys = [ + 'showAlarmGroup', 'showCombatGroup', 'showGeneralGroup', + 'showHelpGroup', 'alarmGroup', 'combatGroup', 'generalGroup', + 'helpGroup', 'testAlarm', 'testNotification', 'version' + ]; + + for ( i = 0, end = keys.length; i < end; i++ ) { + var key = keys[ i ]; + extraControls[ key ] = doc.getElementById( key ); + } + + // Find the rest of the controls, save references to them in + // controls, and install event listeners. + // + // That's a lot of initialisation... we'll define a helper + // function. + function setupControls() { + var event = arguments[0], listener = arguments[1]; + + for ( var i = 2, end = arguments.length; i < end; i++ ) { + var key = arguments[ i ], control = doc.getElementById( key ); + controls[ key ] = control; + control.addEventListener( event, listener ); + } + }; + + // 1. Checkboxes + setupControls ( 'click', onCheckboxClick, + 'muteAlarm', 'alarmCombat', 'alarmAlly', 'alarmWarning', 'alarmPM', + 'alarmMission', 'alarmTrade', 'alarmPayment', 'desktopCombat', + 'desktopAlly', 'desktopWarning', 'desktopPM', 'desktopMission', + 'desktopTrade', 'desktopPayment', 'clockUTC', 'clockAP', 'clockB', + 'clockP', 'clockS', 'clockL', 'clockE', 'clockN', 'clockZ', 'clockR', + 'pvpMissileAutoAll', 'pvpHighestRounds', 'pvmMissileAutoAll', + 'pvmHighestRounds', 'pvbMissileAutoAll', 'displayDamage', 'autobots', + 'navShipLinks', 'navEquipmentLink', 'navTradeLink', + 'navBlackMarketLink', 'navHackLink', 'navBulletinBoardLink', + 'navBountyBoardLink', 'navShipyardLink', 'navCrewQuartersLink', + 'navFlyCloseLink', 'allianceQLsArtemisEnabled', + 'personalQLArtemisEnabled', 'allianceQLsOrionEnabled', + 'personalQLOrionEnabled', 'allianceQLsPegasusEnabled', + 'personalQLPegasusEnabled', 'overrideAmbushRounds', + 'fitAmbushRounds', 'miniMap', 'sendmsgShowAlliance' ); + + // 2. Free-form strings + setupControls ( 'input', onControlInput, + 'personalQLArtemis', 'personalQLOrion', 'personalQLPegasus' ); + + // 3. Numeric fields + setupControls ( 'input', onNumericControlInput, + 'autobotsArtemisPoints', 'autobotsOrionPoints', + 'autobotsPegasusPoints' ); + + // 4. Selects + setupControls ( 'change', onControlInput, + 'alarmSound', 'autobotsArtemisPreset', 'autobotsOrionPreset', + 'autobotsPegasusPreset', 'miniMapPlacement' ); + + // 5. Selects that we store as numbers, cause we use the value + setupControls ( 'change', onNumericControlInput, + 'autobotsArtemisStrength', 'autobotsOrionStrength', + 'autobotsPegasusStrength'); + + extraControls.version.textContent = chrome.runtime.getManifest().version; + + // Install additional listeners + function wireGroupSwitch( button, group ) { + var listener = function() { onShowGroupClick( button, group ); }; + button.addEventListener( 'click', listener ); + } + wireGroupSwitch( extraControls.showAlarmGroup, + extraControls.alarmGroup ); + wireGroupSwitch( extraControls.showCombatGroup, + extraControls.combatGroup ); + wireGroupSwitch( extraControls.showGeneralGroup, + extraControls.generalGroup ); + wireGroupSwitch( extraControls.showHelpGroup, + extraControls.helpGroup); + + extraControls + .testAlarm.addEventListener( 'click', onTestAlarmClick ); + extraControls.testNotification + .addEventListener( 'click', onTestNotificationClick ); + controls.muteAlarm + .addEventListener( 'click', updateAlarmControlsDisable ); + controls.autobots + .addEventListener( 'click', updateAutobotControlsDisable ); + controls.miniMap + .addEventListener( 'click', updateMiniMapControlsDisable ); + + function wireAutobotsPreset( preset, points ) { + var + presetListener = + function() { onAutobotsPresetChange( preset, points ); }, + pointsListener = + function() { onAutobotsPointsInput( preset, points ); }; + + preset.addEventListener( 'change', presetListener ); + points.addEventListener( 'input', pointsListener ); + } + wireAutobotsPreset( controls.autobotsArtemisPreset, + controls.autobotsArtemisPoints ); + wireAutobotsPreset( controls.autobotsOrionPreset, + controls.autobotsOrionPoints ); + wireAutobotsPreset( controls.autobotsPegasusPreset, + controls.autobotsPegasusPoints ); + + // And another shorthand + function wireQLControls( enabled, ql ) { + var listener = function() { onQLEnabledClick( enabled, ql ); }; + enabled.addEventListener( 'change', listener ); + } + wireQLControls( controls.personalQLArtemisEnabled, + controls.personalQLArtemis ); + wireQLControls( controls.personalQLOrionEnabled, + controls.personalQLOrion ); + wireQLControls( controls.personalQLPegasusEnabled, + controls.personalQLPegasus ); + + // Request the configuration + chrome.storage.local.get( Object.keys( controls ), + onConfigurationReady ); +} + +function onConfigurationReady( items ) { + for ( var key in items ) { + var control = controls[ key ]; + if ( control ) { + updateControlState( control, items[ key ] ); + } + } + + // Listen for changes in configuration. + chrome.storage.onChanged.addListener( onConfigurationChange ); + + // Connect to the extension and ask for updates on alarm state. + port = chrome.extension.connect(); + port.onMessage.addListener( onPortMessage ); + port.postMessage({ watchAlarm: true }); +} + +function onConfigurationChange( changes, area ) { + if ( area == 'local' ) { + for ( var key in changes ) { + var control = controls[ key ]; + if ( control ) { + updateControlState( control, changes[key].newValue ); + } + } + } +} + +function onPortMessage( msg ) { + if ( msg.hasOwnProperty( 'alarmState' ) ) { + extraControls.testAlarm.value = msg.alarmState ? 'Stop' : 'Test'; + } +} + +function onShowGroupClick( groupButton, group ) { + var i, end, keys; + + keys = [ 'showAlarmGroup', 'showCombatGroup', 'showGeneralGroup', + 'showHelpGroup' ]; + for ( i = 0, end = keys.length; i < end; i++ ) { + extraControls[ keys[i] ].parentNode.classList.remove( 'selected' ); + } + + keys = [ 'alarmGroup', 'combatGroup', 'generalGroup', 'helpGroup' ]; + for ( i = 0, end = keys.length; i < end; i++ ) { + extraControls[ keys[i] ].classList.remove( 'selected' ); + } + + groupButton.parentNode.classList.add( 'selected' ); + group.classList.add( 'selected' ); + group.scrollIntoView( true ); +} + +function onTestAlarmClick() { + var message = { alarm: ( extraControls.testAlarm.value == 'Test' ) }; + port.postMessage( message ); +} + +function onTestNotificationClick() { + var message = { + desktopNotification: 'You requested a sample desktop notification.', + timeout: 4000 + }; + + chrome.runtime.sendMessage( message, function(){} ); +} + +function onCheckboxClick( event ) { + var target = event.target, items = {}; + + items[ target.id ] = target.checked; + chrome.storage.local.set( items ); +} + +function onControlInput( event ) { + var target = event.target, items = {}; + + items[ target.id ] = target.value; + chrome.storage.local.set( items ); +} + +// This is like the above, but only allows numeric values greater than +// 1. One day we may need more sophistication... +function onNumericControlInput( event ) { + var target = event.target, m = /^\s*(\d+)\s*$/.exec( target.value ), + value = m ? parseInt( m[1] ) : NaN; + + if ( value > 0 ) { + target.style.color = null; + var items = {}; + items[ target.id ] = value; + chrome.storage.local.set( items ); + } + else { + target.style.color = 'red'; + } +} + +function updateControlState( control, value ) { + switch ( control.type ) { + case 'checkbox': + control.checked = value; + switch ( control.id ) { + case 'autobots': + updateAutobotControlsDisable(); + break; + case 'muteAlarm': + updateAlarmControlsDisable(); + break; + case 'personalQLArtemisEnabled': + case 'personalQLOrionEnabled': + case 'personalQLPegasusEnabled': + updateQLControlsDisable(); + break; + case 'miniMap': + updateMiniMapControlsDisable(); + } + break; + case 'select-one': + updateSelectState( control, value ); + break; + case 'text': + case 'textarea': + control.value = value; + } +} + +// Find the option with a given value (not name or id), and select it +function updateSelectState( control, value ) { + var options = control.options; + + for ( var i = 0, end = options.length; i < end; i++ ) { + var option = options[ i ]; + + if ( option.value == value ) { + option.selected = true; + return; + } + } +} + +function updateAlarmControlsDisable() { + var disabled = controls.muteAlarm.checked, + keys = [ 'alarmSound', 'alarmCombat', 'alarmAlly', 'alarmWarning', + 'alarmPM', 'alarmMission', 'alarmTrade', 'alarmPayment' ]; + + extraControls.testAlarm.disabled = disabled; + for ( var i = 0, end = keys.length; i < end; i++ ) { + controls[ keys[i] ].disabled = disabled; + } +} + +function updateAutobotControlsDisable() { + var disabled = !controls.autobots.checked, + keys = [ 'autobotsArtemisPreset', 'autobotsArtemisPoints', + 'autobotsArtemisStrength', 'autobotsOrionPreset', + 'autobotsOrionPoints', 'autobotsOrionStrength', + 'autobotsPegasusPreset', 'autobotsPegasusPoints', + 'autobotsPegasusStrength' ]; + + for ( var i = 0, end = keys.length; i < end; i++ ) { + controls[ keys[i] ].disabled = disabled; + } +} + +function onAutobotsPresetChange( preset, points ) { + var presetPoints, items; + + presetPoints = parseInt( preset.value ); + if ( presetPoints > 0 ) { + points.value = presetPoints; + } + + items = {}; + items[ points.id ] = presetPoints; + chrome.storage.local.set( items ); +} + +function onAutobotsPointsInput( preset, points ) { + var items; + + if ( parseInt(points.value) != parseInt(preset.value) ) { + preset.selectedIndex = 0; + items = {}; + items[ preset.id ] = '0'; + chrome.storage.local.set( items ); + } +} + +function onQLEnabledClick( enabledCheckbox, qlField ) { + qlField.disabled = !enabledCheckbox.checked; +} + +function updateQLControlsDisable() { + controls.personalQLArtemis.disabled = + !controls.personalQLArtemisEnabled.checked; + controls.personalQLOrion.disabled = + !controls.personalQLOrionEnabled.checked; + controls.personalQLPegasus.disabled = + !controls.personalQLPegasusEnabled.checked; +} + +function updateMiniMapControlsDisable() { + controls.miniMapPlacement.disabled = !controls.miniMap.checked; +} + +// Start the ball +start( doc ); + +})( document ); diff --git a/popup.html b/popup.html index 9091d60..c3e4c3f 100644 --- a/popup.html +++ b/popup.html @@ -11,27 +11,32 @@
Quick alarm settings:
- +
- +
- + + +
+ +
+
- +
- +
diff --git a/popup.js b/popup.js index b86cf6b..bb1cee1 100644 --- a/popup.js +++ b/popup.js @@ -1,93 +1,128 @@ -function PSPopUpPageDriver(doc) { this.initialise(doc); } - -PSPopUpPageDriver.prototype = { - initialise: function(doc) { - this.doc = doc; - doc.addEventListener('DOMContentLoaded', - this.onDOMContentLoaded.bind(this)); - }, - - onDOMContentLoaded: function() { - this.controls = new Object(); - var doc = this.doc, - keys = [ 'muteAlarm', 'alarmCombat', 'alarmAlly', 'alarmPM' ]; - for(var i = 0, end = keys.length; i < end; i++) { - var key = keys[i], control = doc.getElementById(key); - this[key] = control; - control.addEventListener('click', this.onSettingClick.bind(this)); - } - - this.testAlarm = doc.getElementById('testAlarm'); - this.testAlarm.addEventListener('click', this.onTestAlarmClick.bind(this)); - this.muteAlarm.addEventListener('click', - this.updateAlarmControlsDisable.bind(this)); - - this.openOptions = doc.getElementById('openOptions'); - this.openOptions.addEventListener('click', this.onOpenOptions.bind(this)); - - this.port = chrome.extension.connect(); - this.port.onMessage.addListener(this.onMessage.bind(this)); - this.port.postMessage({ op: 'subscribe', keys: keys }); - }, - - onSettingClick: function(event) { - var target = event.target; - this.port.postMessage({ op: 'setValue', - key: target.id, - value: target.checked }); - }, - - onMessage: function(msg) { - if(msg.op == 'updateValue') { - var control = this[msg.key]; - if(control) { - control.checked = msg.value; - if(msg.key == 'muteAlarm') - this.updateAlarmControlsDisable(); - } - } - }, - - onTestAlarmClick: function() { - if(this.testAlarm.value == 'Stop Alarm') { - this.port.postMessage({ op: 'stopAlarm' }); - this.testAlarm.value = 'Test Alarm'; - } - else { - this.port.postMessage({ op: 'soundAlarm' }); - this.testAlarm.value = 'Stop Alarm'; - } - }, - - disableTestAlarm: function() { - this.port.postMessage({ op: 'stopAlarm' }); - this.testAlarm.value = 'Test Alarm'; - this.testAlarm.disabled = true; - }, - - updateAlarmControlsDisable: function() { - var disabled = this.muteAlarm.checked; - var keys = [ 'alarmCombat', 'alarmAlly', 'alarmPM' ]; - - for(var i = 0, end = keys.length; i < end; i++) - this[keys[i]].disabled = disabled; - if(disabled) - this.disableTestAlarm(); - else - this.testAlarm.disabled = false; - }, - - onOpenOptions: function(event) { - event.preventDefault(); - var optionsUrl = chrome.extension.getURL('options.html'); - chrome.tabs.query({url: optionsUrl}, - function(tabs) { - if(tabs.length) - chrome.tabs.update(tabs[0].id, {active: true}); - else - chrome.tabs.create({url: optionsUrl}); - }); - } +'use strict'; + +(function( doc ){ + +// All simple booleans, and all bound to a checkbox. This simplifies +// things a bit. Keep muteAlarm as first element (see loop in +// updateAlarmControlsDisable). +var CONFIG_KEYS = [ + 'muteAlarm', 'alarmCombat', 'alarmWarning', 'alarmAlly', 'alarmPM' +]; + +var controls, testAlarm, openOptions, port; + +function start() { + doc.addEventListener( 'DOMContentLoaded', onDOMContentLoaded ); +} + +function onDOMContentLoaded() { + controls = {}; + + for ( var i = 0, end = CONFIG_KEYS.length; i < end; i++ ) { + var key = CONFIG_KEYS[ i ], control = doc.getElementById( key ); + + controls[ key ] = control; + control.addEventListener( 'click', onSettingClick ); + } + + testAlarm = doc.getElementById( 'testAlarm' ); + testAlarm.addEventListener( 'click', onTestAlarmClick ); + //controls.muteAlarm.addEventListener( 'click', updateAlarmControlsDisable ); + + openOptions = doc.getElementById( 'openOptions' ); + openOptions.addEventListener( 'click', onOpenOptions ); + + // Request our configuration + chrome.storage.local.get( CONFIG_KEYS, onConfigurationReady ); }; -var ps_pagedriver = new PSPopUpPageDriver(document); +function onConfigurationReady( items ) { + for ( var key in items) { + updateControlState( controls[key], items[key] ); + } + + // Listen for changes in configuration. + chrome.storage.onChanged.addListener( onConfigurationChange ); + + // Connect to the extension. We need a connection to drive the + // alarm test. + port = chrome.runtime.connect(); + port.onMessage.addListener( onPortMessage ); + port.postMessage({ watchAlarm: true }); +} + +function onConfigurationChange( changes, area ) { + if ( area == 'local' ) { + for ( var key in changes ) { + if ( controls.hasOwnProperty(key) ) { + updateControlState( controls[key], changes[key].newValue ); + } + } + } +} + +function updateControlState( control, value ) { + control.checked = value; + if ( control === controls.muteAlarm ) { + updateAlarmControlsDisable(); + } +} + +function onSettingClick( event ) { + var control = event.target, items = {}; + items[ control.id ] = control.checked; + chrome.storage.local.set( items ); +} + +function onTestAlarmClick() { + var alarm = ( testAlarm.value != 'Stop Alarm' ); + port.postMessage({ alarm: alarm }); +} + +//function disableTestAlarm() { +// port.postMessage({ alarm: false }); +// testAlarm.value = 'Test Alarm'; +// testAlarm.disabled = true; +//} + +function updateAlarmControlsDisable() { + var disabled = controls.muteAlarm.checked; + + // We start at 1, because muteAlarm is first. + for ( var i = 1, end = CONFIG_KEYS.length; i < end; i++ ) { + controls[ CONFIG_KEYS[i] ].disabled = disabled; + } + + testAlarm.disabled = disabled; + +/* if ( disabled ) { + disableTestAlarm(); + } + else { + testAlarm.disabled = false; + }*/ +} + +function onPortMessage( message ) { + testAlarm.value = message.alarmState ? 'Stop Alarm' : 'Test Alarm'; +} + +function onOpenOptions( event ) { + var optionsUrl = chrome.extension.getURL( 'options.html' ), + queryInfo = { url: optionsUrl }, + callback = function( tabs ) { + if ( tabs.length ) { + chrome.tabs.update( tabs[0].id, { active: true } ); + } + else { + chrome.tabs.create( queryInfo ); + } + }; + + event.preventDefault(); + chrome.tabs.query( queryInfo, callback ); +} + +start(); + +})( document ); diff --git a/s2oframe.js b/s2oframe.js deleted file mode 100644 index 702f632..0000000 --- a/s2oframe.js +++ /dev/null @@ -1,23 +0,0 @@ -// Ship 2 opponent content script. What you get when you're shooting NPCs. -// Load universe.js and combat.js before this. - -var port; -var pswCombatScreenDriver; - -function run() { - var configmap = { pvmMissileAutoAll: 'missileAutoAll', - pvmHighestRounds: 'highestRounds', - autobots: 'autobots', - displayDamage: 'displayDamage', - previousShipStatus: 'previousShipStatus' }; - var universe = universeName(); - configmap[ 'autobots' + universe + 'Points' ] = 'autobotsPoints'; - configmap[ 'autobots' + universe + 'Strength' ] = 'autobotsStrength'; - - port = chrome.extension.connect(); - pswCombatScreenDriver = new PSWCombatScreenDriver(document, port, configmap); - - port.postMessage({ op: 'subscribe', keys: pswCombatScreenDriver.configkeys }); -} - -run(); diff --git a/s2sframe.js b/s2sframe.js deleted file mode 100644 index 0884c7e..0000000 --- a/s2sframe.js +++ /dev/null @@ -1,20 +0,0 @@ -// Ship 2 ship content script. What you get when you're shooting people. -// Load combat.js before this. - -var port; -var pswCombatScreenDriver; - -function run() { - port = chrome.extension.connect(); - pswCombatScreenDriver = - new PSWCombatScreenDriver(document, - port, - { pvpMissileAutoAll: 'missileAutoAll', - pvpHighestRounds: 'highestRounds', - displayDamage: 'displayDamage', - previousShipStatus: 'previousShipStatus' }); - - port.postMessage({ op: 'subscribe', keys: pswCombatScreenDriver.configkeys }); -} - -run(); diff --git a/sendmsg.js b/sendmsg.js index 3d0a046..6d2f7fa 100644 --- a/sendmsg.js +++ b/sendmsg.js @@ -1,90 +1,121 @@ // Display the recipient's alliance on the sendmsg form. // This is RIDICULOUSLY overengineered, don't laugh. -function PSSendMsgPageDriver(doc) { this.initialise(doc); } - -PSSendMsgPageDriver.prototype = { - - // We rely on Chrome to call us after DOM is ready. - initialise: function(doc) { - this.doc = doc; - this.port = chrome.extension.connect(); - this.port.onMessage.addListener(this.onPortMessage.bind(this)); - this.port.postMessage({ op: 'subscribe', keys: [ 'sendmsgShowAlliance' ] }); - this.sweeten(); - }, - - onPortMessage: function(msg) { - if(msg.op == 'updateValue' && msg.key == 'sendmsgShowAlliance') { - if(msg.value) - this.sweeten(); - else - this.unsweeten(); - } - }, - - sweeten: function() { - if(this.sweetened) - return; - - // The recipient field is contained in a TD. The next TD should - // contain the mugshot, and the mugshot's alt (or title) should - // contain the alliance name, which will be the text after the - // dash. Bail out if any of these assumptions doesn't hold. - var doc = this.doc, - recipient = doc.getElementById('recipient2'); - if(!recipient) - return; - var recipient_td = recipient.parentNode; - if(!recipient_td || recipient_td.tagName != 'TD') - return; - var recipient_tr = recipient_td.parentNode; - if(!recipient_tr || recipient_tr.tagName != 'TR') - return; - var mugshot_td = recipient_td.nextElementSibling; - if(!mugshot_td || mugshot_td.tagName != 'TD' || mugshot_td.rowSpan != 2) - return; - var mugshot = mugshot_td.firstElementChild; - if(!mugshot || mugshot.tagName != 'IMG' || !mugshot.alt) - return; - var m = /^[^-]+-\s*(.+?)\s*$/.exec(mugshot.alt); - if(!m) - return; - var alliance_name = m[1]; - - // Ok all seems good, make the changes - var tr = doc.createElement('tr'), - td = doc.createElement('td'); - tr.appendChild(td); - td = doc.createElement('td'); - if(alliance_name == 'No alliance participation') { - var i = doc.createElement('i'); - i.textContent = 'No alliance participation'; - td.appendChild(i); - } - else - td.textContent = alliance_name; - tr.appendChild(td); - mugshot_td.rowSpan = 3; - recipient_tr.parentNode.insertBefore(tr, recipient_tr.nextSibling); - - // And remember what we need to undo them - this.allianceTR = tr; - this.mugshotTD = mugshot_td; - this.sweetened = true; - }, - - unsweeten: function() { - if(!this.sweetened) - return; - - this.allianceTR.parentNode.removeChild(this.allianceTR); - this.mugshotTD.rowSpan = 2; - delete this.allianceTR; - delete this.mugshotTD; - delete this.sweetened; - } - -}; - -var ps_pagedriver = new PSSendMsgPageDriver(document); +(function( doc ) { + +var enabled, sweetened, allianceTR, mugshotTD; + +function start( doc ) { + chrome.storage.local.get( ['sendmsgShowAlliance'], onConfigurationReady ); +} + +function onConfigurationReady( items ) { + enabled = items.sendmsgShowAlliance; + + if ( enabled ) { + sweeten(); + } + + // Listen for changes in configuration. + chrome.storage.onChanged.addListener( onConfigurationChange ); +} + +function onConfigurationChange( changes, area ) { + if ( area != 'local' ) { + return; + } + + if ( changes.sendmsgShowAlliance ) { + enabled = changes.sendmsgShowAlliance.newValue; + if ( enabled ) { + sweeten(); + } + else { + unsweeten(); + } + } +} + +function sweeten() { + var recipient, recipientTD, recipientTR, mugshot, + allianceName, match, td, i; + + if ( sweetened ) { + return; + } + + // The recipient field is contained in a TD. The next TD should + // contain the mugshot, and the mugshot's alt (or title) should + // contain the alliance name, which will be the text after the + // dash. Bail out if any of these assumptions doesn't hold. + + recipient = doc.getElementById( 'recipient2' ); + if ( !recipient ) { + return; + } + + recipientTD = recipient.parentNode; + if ( recipientTD.tagName != 'TD' ) { + return; + } + + recipientTR = recipientTD.parentNode; + if ( recipientTR.tagName != 'TR' ) { + return; + } + + mugshotTD = recipientTD.nextElementSibling; + if ( !mugshotTD || mugshotTD.tagName != 'TD' || mugshotTD.rowSpan != 2 ) { + return; + } + + mugshot = mugshotTD.firstElementChild; + if ( !mugshot || mugshot.tagName != 'IMG' || !mugshot.alt ) { + return; + } + + match = /^[^-]+-\s*(.+?)\s*$/.exec( mugshot.alt ); + if ( !match ) { + return; + } + + allianceName = match[ 1 ]; + + // Ok all seems good, make the changes + allianceTR = doc.createElement( 'tr' ); + td = doc.createElement( 'td' ); + allianceTR.appendChild( td ); + + td = doc.createElement( 'td' ); + if ( allianceName == 'No alliance participation' ) { + i = doc.createElement( 'i' ); + i.textContent = 'No alliance participation'; + td.appendChild( i ); + } + else { + td.textContent = allianceName; + } + allianceTR.appendChild( td ); + + mugshotTD.rowSpan = 3; + recipientTR.parentNode.insertBefore( allianceTR, recipientTR.nextSibling ); + + // We're done. Remember this. + sweetened = true; +} + +function unsweeten() { + if ( !sweetened ) { + return; + } + + allianceTR.parentNode.removeChild( allianceTR ); + allianceTR = undefined; + mugshotTD.rowSpan = 2; + mugshotTD = undefined; + sweetened = false; +} + +start(); + +})( document ); diff --git a/shiplinks.js b/shiplinks.js index 9ab8955..50930d8 100644 --- a/shiplinks.js +++ b/shiplinks.js @@ -1,90 +1,165 @@ -// Routines for adding the attack and trade icons to ships in the otherships box - -// This one extracts a list of ships/opponents from a container -// element. xpath is evaluated from container, and is expected to find -// the links that matchId will match. - -function getShips(container, xpath, matchId) { - var doc = container.ownerDocument; - if(!doc) - doc = container; - var ships = []; - var xpr = doc.evaluate(xpath, container, null, - XPathResult.ORDERED_NODE_ITERATOR_TYPE, null); - var a, entry; - while((a = xpr.iterateNext())) { - var href = a.href; - var m = matchId(href); - if(m) { - entry = m; - entry.name = a.textContent; - entry.td = a.parentNode; - ships.push(entry); - - /* one day we'll have use for this; it works - if(entry.type == 'player') { - // see if we find an alliance link - var xpr2 = doc.evaluate("font/b/a", - entry.td, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, - null); - var aa; - while((aa = xpr2.iterateNext())) { - if(aa.pathname == '/alliance.php' && (m = /^\?id=(\d+)$/.exec(aa.search))) { - entry.ally_id = parseInt(m[1]); - entry.ally_name = aa.textContent; - break; - } - } - } */ - } - } - - return ships; -} - -// Very generic, could move somewhere else -function removeElementsByClassName(base, className) { - var elts = base.getElementsByClassName(className), a = [], i, end; - // Add to a proper array first, cause if we modify the document - // while traversing the HTMLCollection, funky things would happen. - for(i = 0, end = elts.length; i < end; i++) - a.push(elts[i]); - // Now remove them. - for(i = 0, end = a.length; i < end; i++) { - var elt = a[i]; - elt.parentNode.removeChild(elt); - } -} - -function addShipLinks(ships) { - for(var i = 0, end = ships.length; i < end; i++) { - var entry = ships[i]; - var player = entry.type == 'player'; - var doc = entry.td.ownerDocument; - var div = doc.createElement('div'); - div.className = 'psw-slink'; - div.style.fontSize = '10px'; - div.style.fontWeight = 'bold'; - var a = doc.createElement('a'); - if(player) - a.href = 'ship2ship_combat.php?playerid=' + entry.id; - else - a.href = 'ship2opponent_combat.php?opponentid=' + entry.id; - a.style.color = '#cc0000'; - a.title = 'Attack ' + entry.name; - a.appendChild(doc.createTextNode('Attack')); - div.appendChild(a); - - if(player) { - div.appendChild(doc.createTextNode(' ')); - a = doc.createElement('a'); - a.href = 'ship2ship_transfer.php?playerid=' + entry.id; - a.style.color = '#a1a1af'; - a.title = 'Trade with ' + entry.name; - a.appendChild(doc.createTextNode('Trade')); - div.appendChild(a); - } - - entry.td.appendChild(div); - } -} +// Ship links - functions for adding the attack and trade links to +// ships in the otherships box. + +'use strict'; + +var ShipLinks = (function() { + +var module = { + // Extracts a list of ships/opponents from a container element. + // Expects matchId to be a function that parses the href of links + // in the otherships box, and extracts from it the opponent type + // and id (see this in use in nav.js and combat.js). xpath is + // evaluated from container, and is expected to find the anchors + // that will be tested by matchId. + + getShips: function( container, xpath, matchId ) { + var doc = container.ownerDocument, ships = [], xpr, a, entry; + + if ( !doc ) { + doc = container; + } + + xpr = doc.evaluate( xpath, container, null, + XPathResult.ORDERED_NODE_ITERATOR_TYPE, null ); + while (( a = xpr.iterateNext() )) { + var href = a.href, m = matchId( href ); + if ( m ) { + entry = m; + entry.name = a.textContent; + entry.td = a.parentNode; + ships.push( entry ); + + /* one day we'll have use for this; it works + if (entry.type == 'player') { + // see if we find an alliance link + var xpr2 = doc.evaluate("font/b/a", + entry.td, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, + null); + var aa; + while ((aa = xpr2.iterateNext())) { + if (aa.pathname == '/alliance.php' && + (m = /^\?id=(\d+)$/.exec(aa.search))) { + entry.ally_id = parseInt(m[1]); + entry.ally_name = aa.textContent; + break; + } + } + } */ + } + } + + return ships; + }, + + // Takes the list of ships built by getShips above, and actually + // adds the links. + addShipLinks: function( ships ) { + for ( var i = 0, end = ships.length; i < end; i++ ) { + var entry = ships[ i ], + player = ( entry.type == 'player' ), + doc = entry.td.ownerDocument, + div = doc.createElement( 'div' ), + a; + + div.className = 'psw-slink'; + div.style.fontSize = '10px'; + div.style.fontWeight = 'bold'; + a = doc.createElement( 'a' ); + if ( player ) { + a.href = 'ship2ship_combat.php?playerid=' + entry.id; + } + else { + a.href = 'ship2opponent_combat.php?opponentid=' + entry.id; + } + a.style.color = '#cc0000'; + a.title = 'Attack ' + entry.name; + a.appendChild( doc.createTextNode( 'Attack' ) ); + div.appendChild( a ); + + if ( player ) { + div.appendChild( doc.createTextNode( ' ' ) ); + a = doc.createElement( 'a' ); + a.href = 'ship2ship_transfer.php?playerid=' + entry.id; + a.style.color = '#a1a1af'; + a.title = 'Trade with ' + entry.name; + a.appendChild( doc.createTextNode( 'Trade' ) ); + div.appendChild( a ); + } + + entry.td.appendChild( div ); + } + }, + + // What it says on the tin. Very general, this one, could move + // somewhere else... + + removeElementsByClassName: function( base, className ) { + var elts = base.getElementsByClassName( className ), a = [], i, end; + + // Add to a proper array first, cause if we modify the + // document while traversing the HTMLCollection, funky things + // happen. + for ( i = 0, end = elts.length; i < end; i++ ) { + a.push( elts[i] ); + } + + // Now remove them. + for ( i = 0, end = a.length; i < end; i++ ) { + var elt = a[ i ]; + elt.parentNode.removeChild( elt ); + } + }, + + // The controller is an object that manages the display of ship + // links in a document or document element. It knows the current + // state, that is, whether links are being shown or not; and based + // on this, it knows whether to add the links, remove them, or do + // nothing, when told to turn the links on or off. + // + // Container is where we're adding and removing links; can be a + // Document, though an element may be more efficient. Xpath and + // matchId are as described for ShipLinks.getShips. + // + // This is a constructor; you use is by instantiating, e.g., + // + // var controller = new ShipLinks.Controller( doc, xpath, func ); + // + // Instantiation is cheap, has no side effects, and the controller + // needs no cleanup. + + Controller: function( xpath, matchId ) { + this.xpath = xpath; + this.matchId = matchId; + } + +}; + +module.Controller.prototype = { + setContainer: function ( container, state ) { + this.container = container; + this.state = state; + }, + + update: function( enable ) { + var ships; + + if ( enable != this.state && this.container ) { + // First remove all links we may have added before. + module.removeElementsByClassName( this.container, 'psw-slink' ); + + if ( enable ) { + ships = ShipLinks.getShips + ( this.container, this.xpath, this.matchId); + module.addShipLinks( ships ); + } + + this.state = enable; + } + // else state hasn't changed, leave as is + } +}; + +return module; + +})(); diff --git a/slicer.js b/slicer.js index 525ea80..09df733 100644 --- a/slicer.js +++ b/slicer.js @@ -6,7 +6,7 @@ // these text fragments may span across elements in the document, so // we won't have a single node to work with. Instead, each text // fragment will be split in a sequence of nodes, inserted as children -// of the original nodes that contain pieces of the text. +// of the original nodes that contained pieces of the text. // // The class below does this processing. Upon creation, it scans the // tree from the designated root downwards and constructs a single @@ -17,160 +17,274 @@ // appropriate, and return the array of spans enclosing the parts of // the range. -function TreeSlicer(root) { - this.root = root; - this.doc = root.ownerDocument; - this.text = new Array(); // will be joined into a single string after the scan - this.textLength = 0; - this.chunks = new Array(); +function TreeSlicer( root ) { + // Privates + var + HTML_IGNORE = { + // Document metadata. We really shouldn't encounter these, ever. + head: true, + title: true, + base: true, + link: true, + meta: true, + style: true, + // For completeness only, we also don't expect these: + frameset: true, // deprecated + frame: true, // deprecated + noframes: true, // deprecated + // Scripting + script: true, + // Embedded content, we don't look into these. + img: true, + iframe: true, + embed: true, + object: true, + param: true, + video: true, + audio: true, + source: true, + track: true, + canvas: true, + map: true, + area: true, + svg: true, + math: true, + // Void table elements + col: true, + colgroup: true, + // Void form elements + input: true, + button: true, + select: true, + datalist: true, + optgroup: true, + option: true, + textarea: true, + keygen: true, + progress: true, + meter: true, + // misc + command: true // deprecated anyway + }, + // Block elements. Well, rather, elements that we consider to + // introduce a line break when we convert to text. + HTML_BLOCK = { + // Here for completeness only + html: true, + body: true, + // Sections + section: true, + nav: true, + article: true, + aside: true, + h1: true, + h2: true, + h3: true, + h4: true, + h5: true, + h6: true, + header: true, + footer: true, + address: true, + main: true, + // Grouping + p: true, + hr: true, + pre: true, + blockquote: true, + ol: true, + ul: true, + li: true, + dl: true, + dt: true, + dd: true, + figure: true, + figcaption: true, + div: true, + // The BR + br: true, + // Tabular content + table: true, + caption: true, + tbody: true, + thead: true, + tfoot: true, + tr: true, + // we add newlines after TDs and THs + td: true, + th: true, + // Forms + form: true, + fieldset: true, + legend: true, + // other + center: true, // deprecated + dir: true, // deprecated + menu: true, + noscript: true + }; - this.scanForText(root); + // The following recursive function is used below to recurse into + // the DOM tree, extracting text. At the end of the recursion, + // context.chunks contains each text node found and the offset + // into the text at which it occurs. + function scanForText( slicer, node ) { + switch ( node.nodeType ) { + case 3: + // A text node. Collect it. + var text = node.nodeValue; + if ( text.length > 0 ) { + slicer.text.push( text ); + slicer.chunks.push({ o: slicer.textLength, n: node }); + slicer.textLength += text.length; + } + // else ignore empty texts (can this even happen?) + break; - this.text = this.text.join(''); - this.chunks.push({ o: this.text.length }); // sentinel + case 1: + // An element node. + // If ignorable, ignore; else recurse into children. + // If appropriate, append a linefeed to the text. + var tag = node.tagName.toLowerCase(); + if ( !HTML_IGNORE[tag] ) { + var children = node.childNodes; + for ( var i = 0, end = children.length; i < end; i++ ) { + scanForText( slicer, children[i] ); + } + + if ( HTML_BLOCK[tag] && slicer.text.length > 0 ) { + slicer.text[ slicer.text.length - 1 ] += "\n"; + slicer.textLength++; + } + } + } + } + + this.root = root; + this.doc = root.ownerDocument; + this.text = []; // will be joined into a single string after scan + this.textLength = 0; + this.chunks = []; + + scanForText( this, root ); + + this.text = this.text.join( '' ); + this.chunks.push({ o: this.text.length }); // sentinel } TreeSlicer.prototype = { - // XXX - these should be customisable, we may not always want to skip these - HTML_IGNORE: { style: true, script: true, form: true }, - HTML_BLOCK: { - address: true, blockquote: true, br: true, center: true, div: true, - dir: true, frameset: true, h1: true, h2: true, h3: true, h4: true, - h5: true, h6: true, hr: true, isindex: true, noframes: true, - noscript: true, p: true, pre: true, table: true, form: true - }, - - // The following method is not intended to be a part of the public - // interface. It's used by the constructor to recurse into the DOM - // tree, extracting text. At the end of the recursion, context.chunks - // contains each text node found and the offset into the text at which - // it occurs. - - scanForText: function(node) { - switch(node.nodeType) { - case 3: - // a text node; collect it - var text = node.nodeValue; - if(text.length > 0) { - this.text.push(text); - this.chunks.push({ o: this.textLength, n: node }); - this.textLength += text.length; - } - // else ignore empty texts (can this even happen?) - break; - - case 1: - // an element node. - // * if script or style, ignore; else recurse into children. - // * if block element (address, blockquote, center, div, dir, - // frameset, h1, h2, h3, h4, h5, h6, hr, isindex, noframes, - // noscript, p, pre, table, form) or br, append a linefeed to the - // text. - var tag = node.tagName.toLowerCase(); - if(!this.HTML_IGNORE[tag]) { - var children = node.childNodes; - for(var i = 0; i < children.length; i++) - this.scanForText(children[i]); - if(this.HTML_BLOCK[tag] && this.text.length > 0) { - this.text[this.text.length-1] += "\n"; - this.textLength++; - } - } - } - }, - - // This is the method we want to use to "slice" a text fragment. - // - // Parameters 'start' and 'end' are offsets into this.text. The text - // fragment includes the character at this.text[start], and runs up - // to, but not including, the character at this.text[end]. The method - // will identify all document nodes containing parts of the text, and - // modify the tree so that each part is enclosed in a span element - // (which may, and usually does mean splitting text nodes). - // - // IMPORTANT: You may call this method repeatedly, but the ranges in - // each call should be at increasing offsets and must not overlap with - // ranges used in previous calls. - // - // The method returns an array of nodes. Each node is a span element, - // and the whole set comprise the text you want. You may set all - // these spans to some class, or assign a style, or event listeners or - // whatever. - - slice: function(start, end) { - var r = new Array(); - - if(start < 0) - start = 0; - if(end > this.text.length) - end = this.text.length; - - if(end <= start + 1) - return r; - - // Find the entry in this.chunks which contains the first character - // in the range, using Raymond's elegant binary search. - var a = 0, b = this.chunks.length; - while(a < b) { - var i = a + b >> 1; - if(start < this.chunks[i].o) - b = i; - else if(start >= this.chunks[i+1].o) - a = i+1; - else - a = b = i; - } - - // Now, for every entry in this.chunks which intersects with the - // text fragment, extract the intersecting text and put it into a - // span. - while(i < this.chunks.length) { - var entry = this.chunks[i]; - var node = entry.n; - var parent = node.parentNode; - var nextNode = node.nextSibling; - var nodeText = node.nodeValue; - var nodeStart = start - entry.o; - var nodeEnd = Math.min(end, this.chunks[i+1].o) - entry.o; - - var before, middle, after; - - // before is the text in this node before the range - if(nodeStart > 0) - before = nodeText.substring(0, nodeStart); - else - before = null; - // middle is the highlighted text - middle = nodeText.substring(nodeStart, nodeEnd); - // after is the text in this node after the range - if(nodeEnd < nodeText.length) - after = nodeText.substr(nodeEnd); - else - after = null; - - // now update the DOM - if(before) - node.nodeValue = before; - else - parent.removeChild(node); - var span = this.doc.createElement('span'); - span.appendChild(this.doc.createTextNode(middle)); - parent.insertBefore(span, nextNode); - r.push(span); - if(after) { - var newtext = this.doc.createTextNode(after); - parent.insertBefore(newtext, nextNode); - // important: make a copy, do not overwrite - this.chunks[i] = { n: newtext, i: nodeEnd }; - } - - i++; - - // if the range doesn't reach the following chunk, we're done - if(end <= this.chunks[i].o) - break; - } - - return r; - } + // This is the method we want to use to "slice" a text fragment. + // + // Parameters 'start' and 'end' are offsets into this.text. The + // text fragment includes the character at this.text[start], and + // runs up to, but not including, the character at this.text[end]. + // The method will identify all document nodes containing parts of + // the text, and modify the tree so that each part is enclosed in + // a span element (which may, and usually does mean splitting text + // nodes). + // + // IMPORTANT: You may call this method repeatedly, but the ranges + // in each call should be at increasing offsets and must not + // overlap with ranges used in previous calls. + // + // The method returns an array of nodes. Each node is a span + // element, and the whole set comprise the text you want. You may + // set all these spans to some class, or assign a style, or event + // listeners or whatever. + + slice: function( start, end ) { + var r = [], a, b; + + if ( start < 0 ) { + start = 0; + } + + if ( end > this.text.length ) { + end = this.text.length; + } + + if ( end <= start + 1 ) { + return r; + } + + // Find the entry in this.chunks which contains the first + // character in the range, using Raymond's elegant binary + // search. + a = 0; + b = this.chunks.length; + while ( a < b ) { + var i = a + b >> 1; + if ( start < this.chunks[ i ].o ) { + b = i; + } + else if ( start >= this.chunks[i + 1].o ) { + a = i + 1; + } + else { + a = b = i; + } + } + + // Now, for every entry in this.chunks which intersects with + // the text fragment, extract the intersecting text and put it + // into a span. + while ( i < this.chunks.length ) { + var entry = this.chunks[i], + node = entry.n, + parent = node.parentNode, + nextNode = node.nextSibling, + nodeText = node.nodeValue, + nodeStart = start - entry.o, + nodeEnd = Math.min( end, this.chunks[ i + 1 ].o ) - entry.o, + before, middle, after; + + // before is the text in this node before the range + if ( nodeStart > 0 ) { + before = nodeText.substring( 0, nodeStart ); + } + else { + before = null; + } + + // middle is the highlighted text + middle = nodeText.substring( nodeStart, nodeEnd ); + + // after is the text in this node after the range + if ( nodeEnd < nodeText.length ) { + after = nodeText.substr( nodeEnd ); + } + else { + after = null; + } + + // now update the DOM + if ( before ) { + node.nodeValue = before; + } + else { + parent.removeChild( node ); + } + + var span = this.doc.createElement( 'span' ); + span.appendChild( this.doc.createTextNode(middle) ); + parent.insertBefore( span, nextNode ); + r.push( span ); + + if ( after ) { + var newtext = this.doc.createTextNode( after ); + parent.insertBefore( newtext, nextNode ); + // important: make a copy, do not overwrite + this.chunks[ i ] = { n: newtext, i: nodeEnd }; + } + + i++; + + // if the range doesn't reach the following chunk, we're done + if ( end <= this.chunks[ i ].o ) { + break; + } + } + + return r; + } }; diff --git a/universe.js b/universe.js index fda21ce..efb5d74 100644 --- a/universe.js +++ b/universe.js @@ -1,17 +1,27 @@ // Universe detection -// 'artemis', 'orion', 'pegasus' -function detectUniverse(doc) { - if(!doc) - doc = document; - var m = /^([^.]+)\.pardus\.at$/.exec(doc.location.host); - if(m) - return m[1]; - return null; -} +var Universe = (function() { + +function getServer( doc ) { + var match; + + match = /^([^.]+)\.pardus\.at$/.exec( doc.location.hostname ); + if ( match ) { + return match[ 1 ]; + } -// 'Artemis', 'Orion', 'Pegasus' -function universeName(doc) { - var universe = detectUniverse(doc); - return universe.substr(0,1).toUpperCase() + universe.substr(1); + return null; } + +return { + // 'artemis', 'orion', 'pegasus' + getServer: getServer, + + // 'Artemis', 'Orion', 'Pegasus' + getName: function( doc ) { + var server = getServer( doc ); + return server.substr( 0, 1 ).toUpperCase() + server.substr( 1 ); + } +}; + +})();