Thursday, October 21, 2010

jQuery exactly 1

jQuery rules. However, it is very very common to see people write $('myselector').something() assuming they match exactly 1 thing. jQuery operates on sets and is just as happy with 0 as 1 as many so our attempt to manipulate that one specific div may go awry unnoticed. A toggle() may be fairly noticable but $('#blah').text(v) may be changing 0 elements for ages before anyone notices.

To avoid having this type of error fly under the radar I find it very helpful to define $1 as a jQuery selection that raises an error if it doesn't match exactly 1 thing. According to Firebug the $ (or jQuery) function is defined as (jQuery 1.4.2):
function (a, b) {
    return new (c.fn.init)(a, b);
}
So we can simply define $1 as delegating to $:
$1 = function(a, b) {
 var result = $(a, b);
 if (1 != result.length) {
  throw "expected to match exactly one element";
 }
 return result;
}
Voila! Now we can write $1('someselector') and get a warning there was a js error if our selector doesn't match exactly one thing.

Google AppEngine JUnit tests that use JDO persistence

Google AppEngine has a very clear article on setting up local unit tests. It even says how to setup Datastore tests. What it doesn't say explicitly is that this means JDO will auto-magically work too. Naive idiots (self) therefore assume there is some magic incantation that makes it work. Or that it just doesn't work, but that doesn't really seem likely. To further confuse us, the internets abound with complex solutions for getting a PersistenceManager at test-time. Ignore all the noise; it is as simple as setting up the Datastore and just using JDO as normal. In more detail:
  1. As per Google instructions, make sure appengine-testing.jar, appengine-api-labs.jar, and appengine-api-stubs.jar are on your classpath
  2. Setup a datastore for testing, again just as in Googles instructions
  3. Run your test using a PersistenceManager just as you would normally (assuming you have a PMF setup as per Google's example)
    import com.google.appengine.tools.development.testing.LocalDatastoreServiceTestConfig;
    import com.google.appengine.tools.development.testing.LocalServiceTestHelper;
    public class MyTestClass {
        private final LocalServiceTestHelper helper =
            new LocalServiceTestHelper(new LocalDatastoreServiceTestConfig());
    
        @Before
        public void setUp() {
            helper.setUp();        
        }
    
        @After
        public void tearDown() {
            helper.tearDown();
        }
    
     @Test
     public void simpleJdo() {
      MyObject t = new MyObject("test");
      
      PersistenceManager pm;
      
      //prove MyObject doesn't span tests
      pm = PMF.getPm();
      boolean notFound = false;
      try {
       pm.getObjectById(MyObject.class, "test");
       fail("should have raised not found");
      } catch (JDOObjectNotFoundException e) {
       notFound = true;
      } finally {
       pm.close();
      }
      assertTrue(notFound);
      
      pm = PMF.getPm();
      try {
       pm.makePersistent(t);
      } finally {
       pm.close();
      }
      
      pm = PMF.getPm();
      try {
       t = pm.getObjectById(MyObject.class, "test");
      } finally {
       pm.close();
      }
      
      assertNotNull(pm);
      assertEquals("test", t.getName());
     }
    }
    
  4. Yay!

Tuesday, October 19, 2010

jQuery & JSON to draw single-elimination tournament bracket

Often I see sites that present tournament brackets as an image (even on fairly technical sites, eg http://us.battle.net/sc2/en/blog/936927#blog). Purely out of curiosity, I decided to see what would be involved in merely providing the data from the server and letting an HTML UI build up on the fly using jQuery. To avoid needing to produce a server for this I simply hard-coded in JSON that might have been returned from a server with (very) basic information about the tournament:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-us">
<head>
  <title>MyTournamentName</title>
 <script src='http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js' type='text/javascript'>
 </script>
 <script src='http://ajax.googleapis.com/ajax/libs/jqueryui/1.7.2/jquery-ui.min.js' type='text/javascript'>
 </script>  
  <script type="text/javascript">  
    var matchInfo = {
      "rounds" : [
        { "name": "Round1",
          "matches" : [
            { "id" : 1, "p1" : "mTwDeMuslim", "p2" : "Luffy" },
            { "id" : 2, "p1" : "SeleCT", "p2" : "NEXGenius" },
            { "id" : 3, "p1" : "Fenix", "p2" : "SoftBall" },
            { "id" : 4, "p1" : "White-Ra", "p2" : "Ice" },
            { "id" : 5, "p1" : "HuK", "p2" : "RedArchon" },
            { "id" : 6, "p1" : "Capoch", "p2" : "Loner" },
            { "id" : 7, "p1" : "mTwDIMAGA", "p2" : "MakaPrime" },
            { "id" : 8, "p1" : "TLAF-Liquid`TLO", "p2" : "SEN" }
          ]
        },
        { "name": "Round2",
          "matches" : [
            { "id" : 9, "p1" : null, "p2" : null },
            { "id" : 10, "p1" : null, "p2" : null },
            { "id" : 11, "p1" : null, "p2" : null },
            { "id" : 12, "p1" : null, "p2" : null }
          ]
        },
        { "name": "Round3",
          "matches" : [
            { "id" : 13, "p1" : null, "p2" : null },
            { "id" : 14, "p1" : null, "p2" : null },
          ]
        },
        { "name": "Round4",
          "matches" : [
            { "id" : 15, "p1" : null, "p2" : null },
          ]
        }                
      ]
    };
  </script>
</head>
<body>
  <div>blah blah blah</div>
  <div id="writeHere" class="tournament"></div>
  <div>blah blah blah</div>
</body>
</html>
Next we need to write some jQuery code to fill in the div with id="writeHere" with our purely html-based tournament bracket. Easy enough to do (note that some rudimentary css has been slapped in to show us where which bits are):
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-us">
<head>
  <title>MyTournamentName</title>
 <script src='http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js' type='text/javascript'>
 </script>
 <script src='http://ajax.googleapis.com/ajax/libs/jqueryui/1.7.2/jquery-ui.min.js' type='text/javascript'>
 </script>  
  <style type="text/css">
  .tournament {    
    background-color: #F0F0F0;
    border: dashed 1px solid;
    overflow: auto;
  }
  .tournament .bracket {
    background-color: #DFDFDF;
    min-width: 100px;    
    vertical-align: top;
    float: left;
  }
  
  .tournament .bracket .match {
    background-color: #D0D0D0;
    border-top: 1px solid;
    border-right: 1px solid;
    border-bottom: 1px solid;  
  }
  .tournament .bracket .match .p1 {    
    height: 20px;
  }
  .tournament .bracket .match .p2 {
    height: 20px;
  }    
  .tournament .bracket .match .spacer {
    background-color: #DFDFDF;
    height: 38px;
  }
  .tournament .bracket .spacer {
    height: 80px;
  }
  .tournament .bracket .half-spacer {
    height: 40px;
  }
  .tournament .bracket .small-spacer {
    height: 10px;
    background-color: #F1F1F1;
  }
  
  .left-line {
    border-left: 1px solid;
  }
  
  .tournament .cell {
    min-width: 100px;
    height: 20px;
    float: left;
    background-color: #DFDFDF;    
  }   
  .tournament .l2 {
    background-color: #D0D0D0;
  }     
  .tournament .lmax {
    width: 0px;
    clear: both;
  }    
  </style>
  <script type="text/javascript">
  
    var matchInfo = {
      "rounds" : [
        { "name": "Round1",
          "matches" : [
            { "id" : 1, "p1" : "mTwDeMuslim", "p2" : "Luffy" },
            { "id" : 2, "p1" : "SeleCT", "p2" : "NEXGenius" },
            { "id" : 3, "p1" : "Fenix", "p2" : "SoftBall" },
            { "id" : 4, "p1" : "White-Ra", "p2" : "Ice" },
            { "id" : 5, "p1" : "HuK", "p2" : "RedArchon" },
            { "id" : 6, "p1" : "Capoch", "p2" : "Loner" },
            { "id" : 7, "p1" : "mTwDIMAGA", "p2" : "MakaPrime" },
            { "id" : 8, "p1" : "TLAF-Liquid`TLO", "p2" : "SEN" }
          ]
        },
        { "name": "Round2",
          "matches" : [
            { "id" : 9, "p1" : null, "p2" : null },
            { "id" : 10, "p1" : null, "p2" : null },
            { "id" : 11, "p1" : null, "p2" : null },
            { "id" : 12, "p1" : null, "p2" : null }
          ]
        },
        { "name": "Round3",
          "matches" : [
            { "id" : 13, "p1" : null, "p2" : null },
            { "id" : 14, "p1" : null, "p2" : null },
          ]
        },
        { "name": "Round4",
          "matches" : [
            { "id" : 15, "p1" : null, "p2" : null },
          ]
        }                
      ]
    };
  
    $(document).ready(function($) {       
      var base = $('#writeHere');
      var numTeams = 16;
      var matchesByRound = setupMatchboxes(numTeams);
      
      for (var lvl=0; lvl<matchesByRound.length; lvl++) {                
        var matchBoxes = matchesByRound[lvl];        
        var bracket = checkedAppend('<div class="bracket"></div>', base);
        
        for (var i=0; i<matchBoxes.length; i++) {                     
          var match = matchInfo.rounds[lvl].matches[i];
          var matchHtml = '<div class="match" id="match' + match.id + '">'
            + '<div class="p1">' + fmtName(match.p1) + '</div>'
            + '<div class="spacer"></div>'
            + '<div class="p2">' + fmtName(match.p2) + '</div>';
          checkedAppend(matchHtml, bracket);  
        }
      }      
    });
    
    function fmtName(name) {
      return null != name ? name : '?';
    }
    
    function setupMatchboxes(numTeams) {
      var numLevels = Math.log(numTeams)/Math.LN2;
      var numMatchesForLevel = numTeams / 2;
      var matchBoxes = [];
      
      do {
        var matchesForLevel = [];        
        matchBoxes.push(matchesForLevel);
        
        for (var match=0; match<numMatchesForLevel; match++) {
          matchesForLevel.push(match);
        }
        
        numMatchesForLevel = numMatchesForLevel / 2;
      } while(numMatchesForLevel >= 1);
      return matchBoxes;
    }
    
    function checkedAppend(rawHtml, appendTo) {
      var html = $(rawHtml);
      if (0 == html.length) {
        throw "Built ourselves bad html : " + rawHtml;
      }
      html.appendTo(appendTo);      
      return html;
    }
  </script>
</head>
<body>
  <div>blah blah blah</div>
  <div id="writeHere" class="tournament"></div>
  <div>blah blah blah</div>
</body>
</html>
However, this doesn't line things up quite as nicely as one might hope (to say the least):

We have a couple of clear problems:

  1. We probably want a small vertical space between the first row of matches.
  2. For rows 2..N, a match needs to line up such that its top is at the center of one of the matches on the previous row and its bottom is at the center of another. The specific offset helpfully changes from row to row. It turns out to be a bit of a pain to write css for this so instead we'll just write jQuery code to manually size elements for our first pass. Eg we want something like this (note inconsistent sizing and positioning row to row):
Luckily jQuery provides convenient accessors for height and position so we can write code that literally says "make a vertical spacing div that is half the size of that div and make my div tall enough to stretch from there to there". The main thing that will need an update is that we'll need to keep references to the divs as we go along row by row. This will let us easily set things relative to other things similar to:
var newH = stretchTo.position().top + stretchTo.height()/2 - matchDiv.position().top;
This will ultimately yield the following javascript gibberish:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-us">
<head>
  <title>MyTournamentName</title>
 <script src='http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js' type='text/javascript'>
 </script>
 <script src='http://ajax.googleapis.com/ajax/libs/jqueryui/1.7.2/jquery-ui.min.js' type='text/javascript'>
 </script>  
  <style type="text/css">
  .tournament {    
    background-color: #F0F0F0;
    border: dashed 1px solid;
    overflow: auto;
  }
  .tournament .bracket {
    background-color: #DFDFDF;
    min-width: 100px;    
    vertical-align: top;
    float: left;
  }
  
  .tournament .bracket .match {
    background-color: #D0D0D0;
    border-top: 1px solid;
    border-right: 1px solid;
    border-bottom: 1px solid;  
  }
  .tournament .bracket .match .p1 {    
    height: 20px;
  }
  .tournament .bracket .match .p2 {
    height: 20px;
  }    
  .tournament .bracket .match .spacer {
    background-color: #DFDFDF;
    height: 38px;
  }
  .tournament .bracket .spacer {
    height: 80px;
  }
  .tournament .bracket .half-spacer {
    height: 40px;
  }
  .tournament .bracket .small-spacer {
    height: 10px;
    background-color: #F1F1F1;
  }
  
  .left-line {
    border-left: 1px solid;
  }
  
  .tournament .cell {
    min-width: 100px;
    height: 20px;
    float: left;
    background-color: #DFDFDF;    
  }   
  .tournament .l2 {
    background-color: #D0D0D0;
  }     
  .tournament .lmax {
    width: 0px;
    clear: both;
  }    
  </style>
  <script type="text/javascript">
  
    var matchInfo = {
      "rounds" : [
        { "name": "Round1",
          "matches" : [
            { "id" : 1, "p1" : "mTwDeMuslim", "p2" : "Luffy" },
            { "id" : 2, "p1" : "SeleCT", "p2" : "NEXGenius" },
            { "id" : 3, "p1" : "Fenix", "p2" : "SoftBall" },
            { "id" : 4, "p1" : "White-Ra", "p2" : "Ice" },
            { "id" : 5, "p1" : "HuK", "p2" : "RedArchon" },
            { "id" : 6, "p1" : "Capoch", "p2" : "Loner" },
            { "id" : 7, "p1" : "mTwDIMAGA", "p2" : "MakaPrime" },
            { "id" : 8, "p1" : "TLAF-Liquid`TLO", "p2" : "SEN" }
          ]
        },
        { "name": "Round2",
          "matches" : [
            { "id" : 9, "p1" : null, "p2" : null },
            { "id" : 10, "p1" : null, "p2" : null },
            { "id" : 11, "p1" : null, "p2" : null },
            { "id" : 12, "p1" : null, "p2" : null }
          ]
        },
        { "name": "Round3",
          "matches" : [
            { "id" : 13, "p1" : null, "p2" : null },
            { "id" : 14, "p1" : null, "p2" : null },
          ]
        },
        { "name": "Round4",
          "matches" : [
            { "id" : 15, "p1" : null, "p2" : null },
          ]
        }                
      ]
    };
  
    $(document).ready(function($) {       
      var base = $('#writeHere');
      var numTeams = 16;
      var matchesByRound = setupMatchboxes(numTeams);
      var matchDivsByRound = [];
      
      for (var lvl=0; lvl<matchesByRound.length; lvl++) {                
        var matchBoxes = matchesByRound[lvl];        
        var bracket = checkedAppend('<div class="bracket"></div>', base);
        var matchDivs = [];
        matchDivsByRound.push(matchDivs);
        
        for (var i=0; i<matchBoxes.length; i++) {                     
          var vOffset = checkedAppend('<div></div>', bracket);
        
          var match = matchInfo.rounds[lvl].matches[i];
          var matchHtml = '<div class="match" id="match' + match.id + '">'
            + '<div class="p1">' + fmtName(match.p1) + '</div>'
            + '<div class="spacer"></div>'
            + '<div class="p2">' + fmtName(match.p2) + '</div>';
          matchDiv = checkedAppend(matchHtml, bracket);
          matchDivs.push(matchDiv);
          
          if (lvl > 0) {
            //row 2+; line up with previous row
            var alignTo = matchDivsByRound[lvl-1][i*2];
            //offset to line up tops
            var desiredOffset = alignTo.position().top - matchDiv.position().top;
            
            //offset by half the previous match-height
            desiredOffset += alignTo.height() / 2;
            vOffset.height(desiredOffset);            
          } else {
            checkedAppend('<div class="small-spacer"></div>', bracket);
          }
          
          if (lvl > 0) {
            //tweak our size so we stretch to the middle of the appropriate element
            var stretchTo = matchDivsByRound[lvl-1][i*2+1];
            var newH = stretchTo.position().top + stretchTo.height()/2 - matchDiv.position().top;            
            var deltaH = newH - matchDiv.height();
            matchDiv.height(newH);
            var spacer = matchDiv.find('.spacer');
            spacer.height(spacer.height() + deltaH);
          }          
        }
      }      
    });
    
    function fmtName(name) {
      return null != name ? name : '?';
    }
    
    function setupMatchboxes(numTeams) {
      var numLevels = Math.log(numTeams)/Math.LN2;
      var numMatchesForLevel = numTeams / 2;
      var matchBoxes = [];
      
      do {
        var matchesForLevel = [];        
        matchBoxes.push(matchesForLevel);
        
        for (var match=0; match<numMatchesForLevel; match++) {
          matchesForLevel.push(match);
        }
        
        numMatchesForLevel = numMatchesForLevel / 2;
      } while(numMatchesForLevel >= 1);
      return matchBoxes;
    }
    
    function checkedAppend(rawHtml, appendTo) {
      var html = $(rawHtml);
      if (0 == html.length) {
        throw "Built ourselves bad html : " + rawHtml;
      }
      html.appendTo(appendTo);      
      return html;
    }
  </script>
</head>
<body>
  <div>blah blah blah</div>
  <div id="writeHere" class="tournament"></div>
  <div>blah blah blah</div>
</body>
</html>
On nice modern browsers this yields something like this:

Last of all lets clean up our javascript slightly, in particular making our code a little more directly based on the JSON and a little less on hard-coded test variables like numTeams. And lets add a spot for the final victor:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-us">
<head>
  <title>MyTournamentName</title>
 <script src='http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js' type='text/javascript'>
 </script>
 <script src='http://ajax.googleapis.com/ajax/libs/jqueryui/1.7.2/jquery-ui.min.js' type='text/javascript'>
 </script>  
  <style type="text/css">
  .tournament {    
    background-color: #F0F0F0;
    border: dashed 1px solid;
    overflow: auto;
  }
  .tournament .bracket {
    background-color: #DFDFDF;
    min-width: 100px;    
    vertical-align: top;
    float: left;
  }
  
  .tournament .bracket .match {
    background-color: #D0D0D0;
    border-top: 1px solid;
    border-right: 1px solid;
    border-bottom: 1px solid;  
  }
  .tournament .bracket .match .p1 {    
    height: 20px;
  }
  .tournament .bracket .match .p2 {
    height: 20px;
  }    
  .tournament .bracket .match .spacer {
    background-color: #DFDFDF;
    height: 38px;
  }
  .tournament .bracket .spacer {
    height: 80px;
  }
  .tournament .bracket .half-spacer {
    height: 40px;
  }
  .tournament .bracket .small-spacer {
    height: 10px;
    background-color: #F1F1F1;
  }
  .tournament .bracket .winner {
    border-bottom: 1px solid;
  }
  
  .left-line {
    border-left: 1px solid;
  }
  
  .tournament .cell {
    min-width: 100px;
    height: 20px;
    float: left;
    background-color: #DFDFDF;    
  }   
  .tournament .l2 {
    background-color: #D0D0D0;
  }     
  .tournament .lmax {
    width: 0px;
    clear: both;
  }    
  </style>
  <script type="text/javascript">
  
    var matchInfo = {
      "rounds" : [
        { "name": "Round1",
          "matches" : [
            { "id" : 1, "p1" : "mTwDeMuslim", "p2" : "Luffy" },
            { "id" : 2, "p1" : "SeleCT", "p2" : "NEXGenius" },
            { "id" : 3, "p1" : "Fenix", "p2" : "SoftBall" },
            { "id" : 4, "p1" : "White-Ra", "p2" : "Ice" },
            { "id" : 5, "p1" : "HuK", "p2" : "RedArchon" },
            { "id" : 6, "p1" : "Capoch", "p2" : "Loner" },
            { "id" : 7, "p1" : "mTwDIMAGA", "p2" : "MakaPrime" },
            { "id" : 8, "p1" : "TLAF-Liquid`TLO", "p2" : "SEN" }
          ]
        },
        { "name": "Round2",
          "matches" : [
            { "id" : 9, "p1" : null, "p2" : null },
            { "id" : 10, "p1" : null, "p2" : null },
            { "id" : 11, "p1" : null, "p2" : null },
            { "id" : 12, "p1" : null, "p2" : null }
          ]
        },
        { "name": "Round3",
          "matches" : [
            { "id" : 13, "p1" : null, "p2" : null },
            { "id" : 14, "p1" : null, "p2" : null },
          ]
        },
        { "name": "Round4",
          "matches" : [
            { "id" : 15, "p1" : null, "p2" : null },
          ]
        } 
      ]
    };
  
    $(document).ready(function($) {       
      var base = $('#writeHere');
      var matchDivsByRound = [];
      
      for (var roundIndex=0; roundIndex<matchInfo.rounds.length; roundIndex++) {    
        var round = matchInfo.rounds[roundIndex];
        var bracket = checkedAppend('<div class="bracket"></div>', base);
        var matchDivs = [];
        matchDivsByRound.push(matchDivs);
        
        //setup the match boxes round by round
        for (var i=0; i<round.matches.length; i++) {                     
          var vOffset = checkedAppend('<div></div>', bracket);
        
          var match = round.matches[i];
          var matchHtml = '<div class="match" id="match' + match.id + '">'
            + '<div class="p1">' + fmtName(match.p1) + '</div>'
            + '<div class="spacer"></div>'
            + '<div class="p2">' + fmtName(match.p2) + '</div>';
          matchDiv = checkedAppend(matchHtml, bracket);
          matchDivs.push(matchDiv);
          
          if (roundIndex > 0) {
            //row 2+; line up with previous row
            var alignTo = matchDivsByRound[roundIndex-1][i*2];
            //offset to line up tops
            var desiredOffset = alignTo.position().top - matchDiv.position().top;
            
            //offset by half the previous match-height
            desiredOffset += alignTo.height() / 2;
            vOffset.height(desiredOffset);            
          } else {
            checkedAppend('<div class="small-spacer"></div>', bracket);
          }
          
          if (roundIndex > 0) {
            //tweak our size so we stretch to the middle of the appropriate element
            var stretchTo = matchDivsByRound[roundIndex-1][i*2+1];
            var newH = stretchTo.position().top + stretchTo.height()/2 - matchDiv.position().top;            
            var deltaH = newH - matchDiv.height();
            matchDiv.height(newH);
            var spacer = matchDiv.find('.spacer');
            spacer.height(spacer.height() + deltaH);
          }          
        }                
      }
      //setup the final winners box; just a space for a name whose bottom is centrally aligned with the last match
      bracket = checkedAppend('<div class="bracket"></div>', base);
      var vOffset = checkedAppend('<div></div>', bracket);
      var alignTo = matchDivsByRound[matchInfo.rounds.length - 1][0]; //only 1 match in the last round
      var html = '<div class="winner">?</div>';
      var winnerDiv = checkedAppend(html, bracket);      
      vOffset.height(alignTo.position().top - winnerDiv.position().top + alignTo.height() / 2 - winnerDiv.height());
    });
    
    function fmtName(name) {
      return null != name ? name : '?';
    }
    
    function checkedAppend(rawHtml, appendTo) {
      var html = $(rawHtml);
      if (0 == html.length) {
        throw "Built ourselves bad html : " + rawHtml;
      }
      html.appendTo(appendTo);      
      return html;
    }
  </script>
</head>
<body>
  <div>blah blah blah</div>
  <div id="writeHere" class="tournament"></div>
  <div>blah blah blah</div>
</body>
</html>

Ugly, but sized and positioned the way we want, ready to actually talk to a server and/or get some dynamic elements (eg the ability to designate a winner and have them promote through the tournament).

Ultimately this will hopefully get rolled up into a practicum project involving tournament management.

Saturday, October 9, 2010

Windows 7 has directory symlinks! And they are a great way to work around Dropbox limitations!

Dropbox is moderately awesome. One of the remarkably rare pieces of software that "just works" (so far). Except they haven't implemented the sync any folder feature (vote here!) despite receiving 60k votes for it over an 11 month period :( This causes horribly mundane and annoying problems like:
  • I want to use P:\Dropbox as my sync folder; ok, works great!
  • I have a shared folder for Starcraft 2 replays so I get replays under P:\Dropbox\My Dropbox\SC2-Replay. 
  • Starcraft 2 doesn't look there; in order to have these replays show up for selection in the replays list I need to sync only the SC2-Replay directory into a subdirectory of the directory Starcraft 2 looks at 
    • That is, I want C:\Users\UserName\Documents\StarCraft II\Accounts\########\1-S2-1-######\Replays to sync with the same thing as P:\Dropbox\My Dropbox\SC2-Replay
I could just put the entire Dropbox folder under Starcraft 2 but that really isn't ideal. In XP we'd have a problem; NTFS only had junction points (ref) and it wasn't as easy as you might hope to create them. Luckily in Windows 7 (and Vista) we finally get directory symlinks (ref)! Always nice to learn something useful while trying to accomplish something frivolous like making watching game replays easier.

So, having found out directory symlinks are now available I figured I'd find out how to create one. The first way I found was perhaps not as easy as I'd hoped:

BOOLEAN WINAPI CreateSymbolicLink(
  __in  LPTSTR lpSymlinkFileName,
  __in  LPTSTR lpTargetFileName,
  __in  DWORD dwFlags
);

Luckily it turns out that Windows 7 also provide mklink.exe (Vista has it too; although new to me mklink.exe is old news to the world) to handle all your command prompt NTFS linking needs. You can even create directory junctions (ref) with it if so inclined! Plus Windows Explorer now handles deletion of a directory junction graciously - the files don't get deleted if you delete the junction directory expecting the original/other access point in the file system to stick around.

The mklink.exe arguments are nice and simple:

>mklink /?
Creates a symbolic link.

MKLINK [[/D] | [/H] | [/J]] Link Target

        /D      Creates a directory symbolic link.  Default is a file
                symbolic link.
        /H      Creates a hard link instead of a symbolic link.
        /J      Creates a Directory Junction.
        Link    specifies the new symbolic link name.
        Target  specifies the path (relative or absolute) that the new link
                refers to.

Somewhat curiously (at least to me) creating a directory junction to link up my Dropbox replay to the Starcraft 2 directory tree did not require elevated permissions but creating a directory symlink required me to run cmd.exe as admin. Apparently this is by design but it seems a bit odd; I would have thought a junction was a bigger deal than a symlink. Anyway, once running as admin the link creates as easily as one could hope:

C:\Users\Imaginary\Documents\StarCraft II\Accounts\########\1-S2-1-######\Replays\Multiplayer>mklink /D DropboxSymLink "P:\Dropbox\My Dropbox\SC2-Replay"
symbolic link created for DropboxSymLink <<===>> P:\Dropbox\My Dropbox\SC2-Replay

And now I can see the replays people drop into our shared Dropbox folder from within Starcraft 2; yay!

It may be a bit manual but until that Dropbox gets that external folders feature this is going to be very handy; I can have different parts of my Dropbox show up scattered all over my filesystem now!

Tuesday, October 5, 2010

Why did nobody tell me Windows 7 has copy path built in on a hidden menu?!

For years I've been installing Ninotech Path Copy on my Windows boxes. Recently I upgraded from XP to Win7 and after using it for a few months I finally learned from the internets that Path Copy is not for Win7 ... but it already has the most important feature - copying a full path to a file or folder - built in!

Just shift-rightclick:
Amazing. Worth the upgrade for that alone! Well ... almost. Now if only I could figure out how to tell HKEY_CLASSES_ROOT\Allfilesystemobjects I want that on the regular right-click menu instead of only showing up if I shift-right-click.

Monday, October 4, 2010

NoteToSelf: JVisualVM rules. And -XX:+UseConcMarkSweepGC messes it up.

JVisualVM (under your JDK /bin directory, on Windows something like C:\Program Files\Java\jdk1.6.0_07\bin\jvisualvm.exe), released with JDK version 6, update 7, provides an awesome view into your JVM processes.

Local processes are automagically shown, which is very helpful:

The overview will give you JVM arguments and system properties. This can be very helpful when someone claims they started with one set of arguments and you believe otherwise! The Monitor tab has a nice UI for grabbing a quick heap dump and viewing it, as well as some graphs that can often immediately point out that you have a memory leak (particularly if you don't see a nice healthy saw-tooth for the heap). The Threads tab shockingly gives you information about threads and the ability to perform thread dumps. Although it has never actually been helpful for me yet the visibility into running/waiting is very cool:

Even better, JVisualVM makes monitoring remote processes very easy. Just start the remote JVM with arguments similar to java -Dcom.sun.management.jmxremote.port=7890 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false ...whatever... and then on the client start JVisualVM and choose File>Add JMX Connection and enter hostname:7890 (or whatever number you set for com.sun.management.jmxremote.port) and to see into a remote VM. You can also use jstad but in many cases we don't start this so being able to easily boot arbitrary processes as monitorable is a major plus.

Unfortunately there is a gotcha. If the JVM arguments to the process you want to watch included -XX+UseConcMarkSweepGC you will see a little red warning in the bottom right informing you there was a NullPointerException at com.sun.tools.visualvm.jvm.MonitoredDataImpl.:

This will result in the Monitor tab being completely useless; none of the graphs will populate. Apparently this is due to a bug (see https://visualvm.dev.java.net/issues/show_bug.cgi?id=128) that is fixed. Sadly the fix seems not to be deployed into my environments yet :(