From 51de0ef45d8aabad02e5ea4a802a6f6db28eecb6 Mon Sep 17 00:00:00 2001 From: Val Date: Tue, 15 Oct 2013 04:02:10 -0500 Subject: [PATCH] Version 3 Squashed commit of the following: commit 7681f713418814bd05645e2036e62338790628c1 Author: Val Date: Tue Oct 15 03:59:51 2013 -0500 Finishing touches. commit 48b8ed44e2091dc7460ce46a38dc93fa539b3499 Author: Val Date: Tue Oct 15 01:46:13 2013 -0500 Actually actually fixing the issue. Tested now. commit 18472c25ffe425dd77bbc2ce2ef0d4c8b7e6e814 Author: Val Date: Tue Oct 15 01:17:49 2013 -0500 Actually fixing the previous issue (hopefully). commit d3eb9b34c8005a424c9e117aa572b6b202cd9c69 Author: Val Date: Tue Oct 15 01:03:31 2013 -0500 Fixing a situation where we were acting upon configuration values that hadn't yet been installed. commit 1da0d2cd4d8af8f0c203b4c1c90dfbfa3a5dd62a Author: Val Date: Tue Oct 15 00:46:38 2013 -0500 Fixing details of import old config, removing couple pending notes. commit 752130c8ea98ba440d1f4a82e4547265e977b80a Author: Val Date: Tue Oct 15 00:07:19 2013 -0500 Nextgen changes. Almost done. commit ad6aa2b212a05d280de15d891dcd6350e9fd33ae Author: Val Date: Mon Oct 14 11:03:58 2013 -0500 Nextgen ongoing development, see diffs. commit 58982b715c5180b247f86cf3a51f814ccf047d87 Author: Val Date: Thu Oct 10 23:22:41 2013 -0500 Moar changes. Too many to list, see diff. commit 69330623228b15e695582a8d5830c14a212696e0 Author: Val Date: Wed Oct 9 08:10:00 2013 -0500 Ambush functionality back, many other changes. commit 85a23cf79970ad2b1ecb6570ae31f2b84f445ccc Author: Val Date: Tue Oct 8 05:31:11 2013 -0500 Lots of nextgen changes Fixes to map, bg, myalliance, and more. commit a3a477bd31060cb39f128c3a6bfd15afe6b292d5 Author: Val Date: Sun Oct 6 04:46:12 2013 -0500 Making this thing work again for the umpteenth time Also, switching to Idiomatic.js style. Sorry about the diffs. commit 290284df203f62483b043aa5bca8498b72712904 Author: Val Date: Sat Oct 5 04:16:43 2013 -0500 Another partial nexgen rewrite. This a rewrite of the rewrite, actually. Things are starting to prove tricky, and we're pondering if all this faff is worth at all. commit be5687848f308262c26a4192a1d1315e313cd078 Author: Val Date: Tue Oct 1 21:10:26 2013 -0500 Fixing alarm and stuff Rewriting is more like it <_< commit 33f611c5a70eaa9a52e41f589b99d2e75638289d Author: Val Date: Tue Oct 1 07:03:52 2013 -0500 Partial commit of nextgen rewrite * Important fix in content script require loading. * Message frame (clocks and notifications) mostly working again. commit 9285e4ca3bc343acbba5a6cb52ce5869f5fbc2ff Author: Val Date: Mon Sep 30 17:42:54 2013 -0500 Nextgen nav fixes, pretty much done with this file now * Fixes a nasty bug with the minimap entering a refresh loop * Fixes the crew quarters link. commit 5103cf49d51afbd4a67aac8a829188728ea43f7a Author: Val Date: Mon Sep 30 03:22:49 2013 -0500 Partial commit of nextgen rewrite Work in progress. Nav, options, and page action working again, rest still broken. commit 1f14cc320cb881732908bb4aa5056e677638d6d2 Author: Val Date: Mon Sep 30 01:17:56 2013 -0500 Partial commit of nextgen rewrite Broken still, work in progress. commit 2706484647a15b47a7f1301e12aab073f6510497 Author: Val Date: Sun Sep 29 19:48:17 2013 -0500 Partial commit of the humongous "nextgen" rewrite We're changing the whole architecture so that we no longer load large content scripts every time a frame is loaded, and so we can cache the rendered minimap. Also, we're migrating config storage from localStorage to chrome.storage (which is arguably questionable, but it does make more sense for our purposes). --- README.md | 1 + alarm.js | 61 --- ambush.js | 629 +++++++++++++++++------------- bg.js | 938 ++++++++++++++++++++++++++------------------- building.js | 49 --- cfgset.js | 144 +++++++ clock.js | 829 +++++++++++++++++++++------------------ combat.js | 626 +++++++++++++++++------------- game.js | 22 +- manifest.json | 125 +++--- map.js | 450 +++++++++++----------- map/P/PJ_3373.json | 4 +- msgframe.js | 341 +++++++++++++--- myalliance.js | 488 +++++++++++++++++------ nav.js | 805 +++++++++++++++++++++----------------- notifier.js | 28 -- options.html | 105 +++-- options.js | 667 +++++++++++++++++--------------- popup.html | 15 +- popup.js | 217 ++++++----- s2oframe.js | 23 -- s2sframe.js | 20 - sendmsg.js | 205 +++++----- shiplinks.js | 255 +++++++----- slicer.js | 418 ++++++++++++-------- universe.js | 36 +- 26 files changed, 4402 insertions(+), 3099 deletions(-) delete mode 100644 alarm.js delete mode 100644 building.js create mode 100644 cfgset.js delete mode 100644 notifier.js delete mode 100644 s2oframe.js delete mode 100644 s2sframe.js 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 ); + } +}; + +})();