Saturday, May 21, 2011

So you have a business idea...

A friend of a friend has asked me to comment on a software business idea from a technical standpoint; if the proposed user story can be implemented, how much it would cost and if there already exists something similar that could be customized.

Instead of responding directly, I thought I would conserve my keystrokes and blog about it, first answering the technical questions and secondly providing business suggestions.  I might not be a business expert, but I do play one on TV the internet through my vicarious learning from reading [articles linked from] Hacker News as well as blogs such as Joel On Software, Software by Rob, Business of Software and Balsamiq Mockups.

 

The technical side of things

The idea is generally about internet video integration.

Can it be done?

The software to implement the idea is certainly possible (most ideas are, for a price!).  While I haven't built something exactly like what is described, I have worked on web applications before and nothing jumps out as difficult, aside from the video part.

The video part sounds slightly scary because video files can be big, which makes the storage and transmission costs significant (compared to the ongoing costs of the rest of the application).  A few years ago, you would have had to worry about designing (and building!) a system to store all those files, receiving and sending them quickly across the internet, but nowadays, you can save on infrastructure costs by using Amazon Web Services (AWS), which is pretty neat because you only pay for what you use.

 

How much would it cost?

A lot of it depends on the sophistication, which really boils down to the number and complexity of features.  It would be a matter of establishing priorities and deciding on a time box to work in, after which the software would be released to the first batch of potential customers for feedback.  Depending on how well that goes, the project could then be killed, pivoted or evolved.

Rob Walling suggests a time frame of 2 months, which he estimates would cost about $40 000 if he did everything himself (based on his consulting rate) and between $8 000 and $27 000 if he hired out the work, depending on the cost of living where the work is done.  That's just to build the software and the website.

I mentioned that video would add a noticeable cost for storage and transmission, but that depends on the number of videos, their quality, their length and how often they are accessed.  If estimates can be provided for these factors, some numbers can then be plugged in to the AWS calculator.

Let's play along with this sample video: it is 1:40 long and clocks in at 6.61 megabytes (MB) (the YouTube page it is on is estimated at 1.12 MB, although that's only if you have never been to YouTube before - the cache will avoid the re-transmission of almost 1 MB).  YouTube calls this quality level "360p"; the video was resized (by YouTube) to 480x368.  Let's say a typical user would view 10 videos like this one every week and let's say you have 1000 active users doing that.  That gives us 6.61 * 10 * 1000 = 66 100 MB ~= 66 GB/week.  Let's assume you have 10 000 such video files to choose from, so that's 10 000 * 6.61 MB ~= 66 GB of storage.  I plugged those into the S3 and CloudFront calculators and came up with a monthly bill of $91.34.  That's not bad at all (almost downright cheap!), but remember that's just to store and transmit video you already have for 10 000 views/week (a similar exercise would have to be performed for uploading videos - it costs more to upload).  The hosting costs for the rest of the website (i.e. the parts around the video) would likely be much less, but that depends on the features and the technologies involved (i.e. do you want to resize the uploaded videos, too?).

Lastly, there are costs around supporting and evolving the software.  Unfortunately, while the first might be stable, the second one is most certainly a race against your competitors, otherwise you might lose customers to the shiny new stuff the other guys added.

 

Is there a package that can be customized or adapted?

That part, I don't know.  This would require a decent amount of searching and evaluation.  If there is something out there, then the customizations done to it better be extensive, otherwise anybody else can come along and copy you.

 

The business side of things

I figured I would provide a lot of information on the business side because I was afraid the idea could be easily copied by one of the existing players, making for a very rough start.

How good is the idea?

Is the idea solving a gnarly problem or is it low-hanging fruit that a bigger company could simply copy?  Are you creating value for your users or introducing a Rube Goldberg way of doing things?  If you are trying to compete with an existing player, are you at least making the cheese bigger or reducing the electric shocks?  What market are you targeting and should you target another one entirely?

The idea sounds good to you, but have you tried asking your friends?  (oh, right!)  What about potential customers?  Is it something you would use?  If it already existed, would YOU pay for it?  The Start Small, Stay Small book recommends testing the waters by creating a "coming soon" website that talks about and/or shows a preview of the idea and gives visitors the opportunity to provide their e-mail address to be notified of the release.  This is great because you either end up with a great list of leads or confirmation that people aren't even willing to give you their e-mail address.

Don't worry too much about the idea, though:  You might find that the idea changes or evolves as you get feedback or, better yet, customers!

 

How will the idea spread?

What kind of sales & marketing are you planning?  Is the value of the idea dependent on how many users you have?  This is called the Network effect and you have to consider how you will attract users regardless; the best case scenario is the idea is so awesome that users can't help but tell all their friends about it (i.e. "go viral").  One trending idea is to encourage (and sometimes force!) the word-of-mouth by broadcasting to social networks use of the product or service (such as "I just started playing Super Cabbage Town") or - even better - achievements (such as "I just jogged 5 km with the help of Jogz Pro").

If the idea requires users have an account, unless your idea has a seriously big cheese, sign-ups will be difficult, because for glaven out loud, I don't want to have to remember yet another user name and password unless it's really worth it.  Thankfully, your service could probably piggy-back on top of Facebook Connect or OpenID, the former giving you the aforementioned ability to spam inform friends and family.

 

How will you make money?

Don't try to compete on price - especially if you copy a competitor - because you'll always be playing catch-up as they add new features (unless you enjoy that sort of thing or are somehow able to add features faster than they can).

Ads don't work if users are too engaged. There might be ways of making Facebook ads better but then again, is Facebook a Ponzi Scheme?  Users will be paying for the product or service one way or another, you just have to decide on one or more of the following options:

  • displaying ads
  • selling user information, usage patterns, etc.
  • have users pay first; lump sum, payment plan or subscription?
  • "freenium" (offering free & premium versions, although freenium doesn't always work)
  • upselling (upgrades, add-ons, complements)

Pricing isn't easy, either.  Here's another point-of-view on the topic in the form of a free(!) e-book called Don't Just Roll the Dice.

 

Shit happens

Sometimes you have an idea and then evil corporate lawyers show up.  As a new and small business, you have the advantage of agility and can adjust to something like that.  Speaking of Network Hippo, these guys have a way of reducing the "switching cost" (i.e. reducing the electric shocks) by importing your contact list from your LinkedIn network.  Facebook offers to do the same thing with their Friend Finder that scans your e-mail accounts.  Is there something you can do to make it easier for people to get started with your product or service?  If When a competitor implements a similar "smooth transition" to their product or service (from yours!), will you have enough value to encourage your users to stay?

 

Closing links

Determination is key.  Execution is what matters.  Passion can be a competitive advantage.  Channel your inner Leeroy Jenkins.

Sunday, March 20, 2011

Hacking IE9's font smoothing

If you have been following my blog for a bit, you know I am quite set on my web browser's content readability.  Well, it happened again with Internet Explorer 9 and this time, my font of choice was there but it was rendered with ClearType even though I turned it off with the ClearType Text Tuner:ClearType Text Tuner is clearly off

See?  It's clearly off, but noooo, someone's feeling waaaay too important to follow the operating system's settings.

Anyway, after a Wednesday afternoon of mad hacking (followed by a Saturday afternoon of fixing and cleaning), the result is the DWrite.dll wrapper, which is available for free download.

I just hope this doesn't turn into a game of walls and ladders.

Friday, September 24, 2010

Console2 and Altap Salamander: together at last!

Introduction

Not unlike Nuts and gum, I wanted Altap Salamander to play nice with Console2 by having the former launch the latter whenever I hit the / key above the numeric keypad. Console2 is awesome: it supports using Courier New as a font, has much more natural text selection, features copy & paste keyboard shortcuts, etc.

The problem

I used to connect the two together by launching Salamander with a batch file that would override the COMSPEC environment variable to point to Console2, which mostly worked, until a process launched by Salamander itself would try to use a shell. It turns out Console2 is NOT a shell and thus I would often get really weird behaviour out of those programs.

The solution, part 1

It turns out an Automation plug-in now exists for Salamander. Using it, I wrote the following quick script, adapted from the Launch Elevated Command Prompt.vbs sample script:

Set ShellApp = CreateObject("Shell.Application")
ShellApp.ShellExecute "E:\Program Files (x86)\Altap Salamander 2.54\Console2\Console.exe", "-d """ & Salamander.SourcePanel.Path & """", ""


The Plugins Manager allows me to bind keyboard shortcuts to individual scripts, so I selected Alt+- since just about everything else was taken and you can't use a shortcut key that Salamander itself already uses. A few dialog dismisses later and Alt+- indeed opens up Console2 to the current panel's path. Not bad, but my brain has fused the numpad / as the go-to key for opening a command prompt, so I need to fix that, too.



The solution, part 2



Another thread in the Salamander forums suggested using AutoHotKey to intercept NumpadDiv when Salamander has focus and using it to launch Console2. That kind of works, minus having Console2 launched at the current panel's folder. Well, remember the shortcut key I gave my automation script earlier? I can get AutoHotKey to intercept the numpad / and instead emit Alt+- when Salamander has focus:



#IfWinActive,ahk_class SalamanderMainWindowVer25
NumpadDiv::SendEvent !-
RETURN

Kazaam! Shazam!

Conclusion



All of this brouhaha could have been avoided if Salamander would simply let me configure what gets launched when I hit numpad /, hint, hint, nudge, nudge.

Thursday, June 24, 2010

Don Norman at Business of Software 2009

There's an excellent video at Neil Davidson's blog of Don Norman talking about successful products and services, which he summarizes to the following ten points (1 and 10 are meant to be the same):
  1. It's all about the experience
  2. Design systems
  3. Everything is a service
  4. Everything is a product
  5. Don't be too logical
  6. Memory is more important than actuality
  7. Complexity is good: Complicated is bad
  8. Design for the real world
  9. Design for people
  10. It's all about the experience

Tuesday, June 22, 2010

A case for code reviews

THIS is a fabulous example why your organization/team should be performing code reviews.

We're not talking about poring over the entire code base once a month (that's just cruel); no, you check the code your teammate or colleague wrote or modified before it even goes into the main branch of the source control repository, just like how open-source projects do it.

Use Review Board: it's free and works with the modern source control systems. If you have one of the source control systems that isn't supported, have one of your teammates come to your desk to give your changes a once-over until you figure out a software solution that allows you to send them the changes so they can review it on their own time while you start working on something else.

Seriously. Stop the bad software madness in its tracks TODAY.

Monday, May 24, 2010

The coolest code

At [the appropriately-numbered] revision 42 in a certain source control repository lies, without a doubt, the biggest masterpiece of software ever written since the advent of the parentheses:
public static IEnumerable<T> PreOrder<T>(this T startingPoint, Func<T, IEnumerable<T>> children)
{
    yield return startingPoint;
    foreach (var child in children(startingPoint))
    {
        var preOrderedChildren = PreOrder(child, children);
        foreach (var preOrderedChild in preOrderedChildren)
        {
            yield return preOrderedChild;
        }
    }
}
"What is it?" you say? It's a generic, recursive generator, implemented as an extension method with a functor.

"Uhh, so... what does it do?" you counter? It traverses a tree of items (of type T) by yielding them, starting at the provided startingPoint and obtaining the children of a given instance of T using the provided children function object.

"I am from Missouri. You have got to show me." Sure thing! Suppose we have this Node class:
public class Node : IEnumerable<Node>
{
    private readonly IList<Node> _children = new List<Node>();
    public IEnumerable<Node> Children { get { return _children; } }

    private readonly string _name;
    public string Name { get { return _name; } }

    public Node(string name)
    {
        _name = name;
    }

    public Node Add(string nodeName)
    {
        return Add(new Node(nodeName));
    }

    public Node Add(Node node)
    {
        _children.Add(node);
        return node;
    }

    #region IEnumerable<Node> Members
    public IEnumerator<Node> GetEnumerator()
    {
        return Children.GetEnumerator();
    }
    #endregion

    #region IEnumerable Members
    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return Children.GetEnumerator();
    }
    #endregion
}
...we can then use the Node class to represent its own high-level parse tree:
var compilationUnit = new Node("Node.cs")
{
    new Node("namespace Test")
    {
        new Node("public class Node : IEnumerable<Node>")
        {
            new Node("public IEnumerable<Node> Children")
            {
                new Node("get;")
            },
            new Node("public string Name")
            {
                new Node("get;")
            },
            new Node("public Node(string name);"),
            new Node("public Node Add(string nodeName);"),
            new Node("public Node Add(Node node);"),
            new Node("#region IEnumerable<Node> Members")
            {
                new Node("public IEnumerator<Node> GetEnumerator();"),
            },
            new Node("#region IEnumerable Members")
            {
                new Node("System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator();"),
            },
        },
    },
};
Notice how the Node constructor accepts both regular arguments (in this case, a string representing the node's Name) as well as a list of Node instances? That feature -- collection initializers -- is made available to classes that implement IEnumerable as well as an Add method.

The PreOrder method can then be called on the compilationUnit instance, as follows, due to the extension method feature:
var sequenceOfNodes = compilationUnit.PreOrder(n => n.Children);
The second argument to the method is a lambda expression that, given an instance of Node, returns an IEnumerable<Node>. In other words, it explains to PreOrder how to get a sequence of Node instances given a single Node instance. In our case, it is rather simple, as the Node class has the Children property for that purpose (and it could have been even simpler than that, since Node implements IEnumerable and thus the second parameter could have been written as n => n).

Anyway, calling the PreOrder method looked like it did nothing and it almost did indeed do nothing, which is the point of a generator: until you start pulling on the IEnumerable, no work is performed and no items are generated. All that was done by calling PreOrder was setting up the generator instance in the sequenceOfNodes variable. Let's actually start generating (a.k.a. yielding):
foreach (var node in sequenceOfNodes)
{
    Console.WriteLine(node.Name);
}
...when that loop starts executing, the code in PreOrder kicks in and the first item yielded is the startingPoint, which was initialized in compilationUnit, so its Name is printed to the console. The children of startingPoint are obtained by calling the children functor on startingPoint itself. As you will remember, that's simply the Children property. The process repeats recursively behind the scenes, yielding a node, then its children, while our loop doesn't need to worry about all of that. The loop will end up printing a flat list version of the original tree.

Conclusion (a.k.a. Too long; didn't read)

In 12 lines of code, I made use of the following groovy C# 3.5 compiler features:
  1. Extension methods: extend a closed type with a static method that appears like an instance method
  2. Lambda expressions: inline, anonymous methods that replace private classes that implement an interface AND can operate on local variables
  3. Generators (a.k.a. Iterators): the yield return keyword in methods that return IEnumerable
  4. Implicitly-typed local variables: the var keyword, to avoid repeating yourself

Bonus

If this modest display of mad skillz hasn't convinced you to switch to .NET 3.5, well, you don't even need to! You can compile all this code with the C# 3.5 compiler but still target the .NET 2.0 runtime or even the JVM!

Targetting the .NET 2.0 runtime

Not only can you make use of the new compiler features, you can also make use of the new IDE features, such as call hierarchy and reference highlighting.
  1. Open Visual Studio (this should work in Visual Studio 2008 and 2010)
  2. File > New > Project...
  3. Select .NET Framework 2.0 from the drop-down list on the right:
  4. Create the ExtensionAttribute replacement by adding a file called ExtensionAttribute.cs in your project with the following contents:
    namespace System.Runtime.CompilerServices
    {
       public class ExtensionAttribute : Attribute { }
    }
    
  5. Create a Delegates.cs file that contains the missing Action and Func delegates from the System namespace (you probably only need up to 4 arguments)
  6. Start writing cool code!

Targetting the JVM

This one is more complicated, but the tool you need to download is Mainsoft Grasshopper. You'll need to perform similar additions of missing attribute and delegates as above, but then you should be fine.

Wednesday, May 19, 2010

Arrange, Act, Assert

There's a pattern in unit test writing that I noticed a few years back, but it wasn't until recently that I discovered this pattern actually has a name: Arrange, Act, Assert. These represent the three terrifying waves distinct phases of a good unit test: the first part prepares the necessary conditions that simulate a scenario or use-case (arrange), the second invokes the functionality being tested (act) and the third checks that some post-conditions hold (assert).

Here's some Java code I was writing at the time I first noticed the pattern (November 2007):
/**
 * Tests the <i>shuffle</i> method against the Collections.shuffle(List<?>,
 * Random) implementation from which it was derived.
 */
@Test
public void shuffle_AgainstReference ( )
{
    // { initialization
    Random randomSource;
    int length = 20;
    double[] sourceArray = new double[length];
    List<Double> expectedList = new ArrayList<Double> ( length );
    for ( int i = 0; i < length; i++ )
    {
        sourceArray[i] = i;
        expectedList.add ( (double) i );
    }
    // }

    // { double-check
    for ( int i = 0; i < length; i++ )
    {
        String message = "Source array is different at index [" + i + "]";
        assertEquals ( message, expectedList.get ( i ), sourceArray[i] );
    }
    // }

    randomSource = new Random ( 42 );
    ArrayUtil.shuffle ( sourceArray, randomSource );

    randomSource = new Random ( 42 );
    Collections.shuffle ( expectedList, randomSource );

    // { validation
    for ( int i = 0; i < length; i++ )
    {
        String message = "Shuffled array is different at index [" + i + "]";
        assertEquals ( message, expectedList.get ( i ), sourceArray[i] );
    }
    // }
}
That code tests that my implementation of ArrayUtil.shuffle() on an array of doubles works just like the implementation of Collections.shuffle(). One will notice that I called the first block or phase "initialization" and the last one "validation" (which, come to think of it, should have been called "verification" -- more on this at Wikipedia). The block labeled "double-check" should probably have been taken out into its own test.

I recently noticed the more formalized use in the NamedStringFormatSolution.zip project (more about this project at Phil Haack's Named Formats Redux blog post), where the 3 phases of unit testing were explicitly called out by comments in the code:
[Fact]
public void Eval_WithNamedExpressionAndFormat_EvalsPropertyOfExpression()
{
    //arrange
    var expr = new FormatExpression("{foo:#.##}");

    //act
    string result = expr.Eval(new { foo = 1.23456 });

    //assert
    Assert.Equal("1.23", result);
}
The arrange phase is sometimes so trivial that its contents is folded into the act phase, but unless a value is repeated in several tests that it becomes cleaner or less error-prone to extract it out in a constant, it should remain in the test for maximum clarity.

So there you have it: the next time you write a test, design it to execute in three distinct phases of arrangement, acting and assertion. Your test will be better designed, easier to read and other maintainers will thank you for it.

Saturday, April 10, 2010

Reformulate to avoid misunderstandings

Some of the best advice on technical writing that I have ever read suggested the writer try to explain their ideas in more than one way, usually through the magical phrase in other words.

The technique works by introducing your topic in one writing style, vocabulary, point of view, abstraction level, etc. and then the following sentence conveys mostly the same information, but from a different angle, using synonyms, using more/less details and/or introducing requirements or consequences of the idea. (See how I did that?)

It may seem like it is adding superfluous redundancy, but it helps avoid ambiguity and the clever use of synonyms should help make your document easier to find by search engines.  Just be careful not to overuse the technique.  In other words, some ideas are simple enough that they do not need to be restated.

Thursday, April 08, 2010

Jamie Cansdale is my hero

Jamie Cansdale (of Mutant Design) is the author of TestDriven.Net, a Visual Studio add-in that "makes it easy to run unit tests" (and it certainly lives up to that description).  A simple, casual feature request sent by e-mail two days ago was followed by fabulous customer support that culminated today in a private build that fully implements the feature, even with our unusual environment at work.

Jamie's e-mails came in rapidly and always with offers for more help in getting me to better use his already-fantastic product.  I sometimes had trouble keeping up with him!  The conversation was to the point and I felt I was being treated as a very important customer, not just some [l]user who needs to be clued in to the basics of computers by some first tier customer support who gets paid minimum wage and doesn't care about their job.

Jamie and I iterated through 6 private builds first by e-mail and then by instant messaging to explore the requirements and smash all the bugs in the implementation so that TD.Net could fully support the DeploymentItemAttribute, just like MSTest, even when the files being referenced were marked as read-only due to the nature of the source control system in use.  I came out of this experience a very satisfied customer, as - even though I use the software as a student to write open-source software - I decided to put my money where my mouth is and purchased a professional license.

If you're still reading, what are you waiting for?  Go buy the product and/or go provide customer service so good that your users will write blog posts about it!

Monday, November 09, 2009

How to secure your wireless network

Note: The following is a repost of how-to of the same name I wrote tonight at my IT notes site.

Intro

Do you have a wireless router and a wireless device and want to make sure your neighbours aren't freeloading your internet connection or snooping through your network? Follow these instructions to lock down your wi-fi.

Get what you need

  1. A router that has both wired ports and wireless capability.
  2. A computer with a wired connection to the router.
  3. A computer with a wireless network connection (it could be the same computer as in #2).
  4. A piece of paper large enough to write 5 lines of text on it. You will be filling in the following template:
    IP Address


    User Name


    Password


    SSID


    Passphrase

  5. A pen.
  6. Some adhesive if the paper in #4 did not have its own.

Let's get started!

  1. Plug your computer into the router (and switch off or unplug the wireless network adapter if your computer has both). This makes sure you're configuring the right router (and not your neighbour's) as well as gives you a way to fix it if you accidentally lock yourself out. Some routers only allow you to configure them through a wired connection anyway.

  2. Find out what your gateway's IP Address is. It will be something like 192.168.0.1 but it depends on the manufacturer and model, as well as the initial set-up.

    • Windows XP:
      1. Start > Control Panel
      2. Double-click on Network Connections
      3. Double-click on the Local Area Connection icon
      4. Click on the Support tab
      5. Write down the numbers that are next to Default Gateway as the IP Address.

    • Windows Vista and 7:
      1. Show the Start menu
      2. Type network connections in the search box and select View network connections
      3. Double-click on the Local Area Connection icon
      4. Click on the Details... button
      5. Write down the numbers that are next to IPv4 Default Gateway as the IP Address.

  3. Open your web browser and in the address bar, type http:// followed immediately by the gateway's IP address. For example: http://192.168.0.1

  4. You should be prompted for a user name and password. If you have never set this, then you have a few options for figuring out what the default user name and password are:

    • Find the little user's manual that came with the device. It should have a section on administration or remote access. Worst case, you flip through the 10-20 pages looking for either an image similar to what's on your screen right now or some mention of any of the following words: admin, login, password or user name.

    • Find a digital version of the user's manual:

      1. Go to the manufacturer's website. If it's not printed on the router anywhere, search the web.
      2. Navigate to their Support section of the website.
      3. Look for a Product or Model Search and type in the model number of your router.
      4. One of the links for your router's page (when you eventually find it) should be for the manual, usually as a PDF file.

    • Guess! Here are some common combinations:

      1. user name admin and no password
      2. no user name and admin as the password
      3. user name admin and password admin
      4. no user name and no password

    • Now that you have found the default user name and password, you should change them as soon as possible. Look under Tools, Advanced or Maintenance. Invent a password you don't use for anything else and write both the User Name and the Password on your piece of paper.

  5. Look for a Wireless or WLAN menu. You will know you have found the right page when you see words like SSID, WEP, WPA, Passphrase and Channel.

  6. Your Network Name is commonly called SSID. You change this from its default of default (or sometimes it's the manufacturer's name) and set it to something that's easy for you and your guests to know it's your router. Keep it simple and a single word if possible, like the name of a pet. Whatever you choose, write it down on your piece of paper.

  7. The next part is a little trickier. You usually need to select what kind of security you want. If you're reading this, you want to change it from None or Disabled to WPA.

    • Side note: Avoid WEP at all costs as it is too easy to break in. Protecting your wireless network with WEP is like putting a sign in front of a pool, kindly asking people not to go swimming. Protecting your wireless network with WPA is like building a fence around your pool. The most determined swimmers can still climb your fence, but casual swimmers that would not have been deterred by the "please don't swim" sign will go look for easier pools to break into.

    • Side note number 2: Your router might allow you to select WPA2. In my experience, this protocol is not as compatible with as many devices, so I would not recommend it.

  8. Now you get to pick a Passphrase (also known as a shared key). A passphrase is like a password, but it's longer and it can contain spaces. Pick an easy to spell one-liner that you and your guests can easily type, something like I am one with everything. It usually needs to be at least 8 characters long and, just like a password, it will be case-sensitive. When you have typed it twice, click whatever Save button there is and you're almost done!

  9. At this point, your router might want to think for a few seconds or maybe it will automatically reboot. In any case, wait 10-30 seconds and then try connecting to your router using a wireless-capable device. If you are using the same computer, unplug the network cable and then switch on (or plug in) your wireless network adapter. After another 10-15 seconds, you should get a message saying some wireless networks were detected and to click to see a list. You should see your newly-named wireless network in that list with a little padlock indicating it needs a key. If your network is not listed, you can wait another minute or two and then start over in case you got something wrong along the way.

  10. It works! Yeehaw! Now attach your piece of paper under your router using some light adhesive so that the next time you need to fix something, you will be able to skip steps 2 and 4. It will also be useful when you want to give your guests access to your wireless network; you won't have to try to remember anything, you can just lift up your router and read off the SSID and Passphrase to them.

Wednesday, August 19, 2009

HOWTO: Shop for a laptop

Note: The following is taken verbatim from an e-mail I sent a relative who was interested in purchasing a laptop. It's reposted here so I can share it with the world and subsequently link to it the next time I'm asked about it. That and some feedback from the public at large couldn't hurt!

The best way I found to shop for laptops is to find as many flyers as possible and cut them apart so you have playing-card sized pieces with the picture of the laptop, its price and its specs. The Future Shop and Best Buy flyers are great for this and sometimes the Dell, The Source and Staples flyers are useful too.

You then start to whittle it down by eliminating obviously too-expensive laptops and too-low end laptops. (i.e. those in the $3000 range and those with less than 2 GB of RAM) You just toss the pieces in the garbage. For the next iteration, you start looking more carefully at the specifications, pictures, etc. and tossing out those that don't fit your wants and needs. Repeat until you have about two or three left.

What you do next is you go to the stores, find where the two or three you have left are, and play with them for 5-10 minutes to see if there's a clear winner. If you can't tell the difference between the them, well, the least expensive wins. I recommend you sleep on it at this point.

Laptops usually come with a one-year warranty and you can purchase a few more years after that for up to 3 or 4, depending on the store. That can easily add $300 to the cost of the laptop, so you may want to decide on that before you even step into the store, otherwise you'll be asked to decide at check-out and making a decision about something that's almost 1/3 the cost of the product is not something you want to do quickly.

I would not recommend purchasing a laptop (over a desktop) unless the computer needs to be mobile and moved around a lot. You'll pay more for the privilege of having small pieces that fit inside a portable package and thus the same money could be spent on something more powerful with a bigger screen, etc.

Wednesday, April 22, 2009

On uninstalling Google Chrome

I finally gave up with Google Chrome on my laptop. Here's what popped up when I completed the uninstallation process, with my answers:

Google Chrome has been uninstalled.

Thanks for trying out Google Chrome. Please help us improve Google Chrome by telling us why you uninstalled it. Your feedback is much appreciated.

Which of the following best describes you?
I am uninstalling Google Chrome for now, but I may try it again in the future
I am permanently uninstalling Google Chrome
I am only uninstalling briefly. I am going to reinstall straight away (e.g. changing computer, upgrading operating system)

Why are you uninstalling Google Chrome? Please check all that apply
It doesn't load some web pages properly (please list any examples below)
It's missing some features that I use (please provide detail in the box below)
I'm concerned about privacy (please provide detail in the box below)
It seems to slow down my computer
It crashes too often
Other (please provide more details in the text box below)

Please provide any additional detail on your reason for uninstalling:

There are too many Firefox add-ons that I have come to rely on and using Chrome just leaves me "naked" in comparison. I blogged about this potential problem when Chrome initially came out: http://oliiscool.blogspot.com/2008/09/world-in-verdana.html

I first installed Chrome when I heard Gmail and Google Reader had exceptionally good performance in Chrome. This was true, but as soon as I clicked a 3rd-party link from within either, I felt I was abandoned and frequently would end up copy/pasting the URL into Firefox and continue from there.

I definitely appreciate the engineering skills and value of having a process-per-tab kind of browser, but that functionality alone is not sufficient for a heavy add-on user such as myself. In fact, I have checked the "slow down" checkbox since that model seems to in fact be a liability when viewing YouTube videos: I definitely notice much higher CPU usage from Chrome when visiting a site containing an embedded video than visiting the same page with Firefox.

Perhaps not all is lost: the process-per-tab model could still be applied to Firefox and a "Chromium Tab" Firefox add-on that worked similarly to the "IE Tab" add-on (in that it would enable the Webkit/Chromium engine on a per-tab basis) would be fantastic in my books.



So maybe I'll eventually re-install it. There's definitely value in the "application mode" as well, which hides the address bar and makes a "tab" its own window using the site/application's "favico" as the application window's icon, but that breaks quickly - as I described above - when you need to go outside that site/application. That "application mode" would also be a neat add-on/extension for Firefox. :)

Friday, April 03, 2009

Do you still not use a router?

I was helping out an extended family member over the phone the other night when I realized he would connect one of his two computers directly to the cable modem, in turn, depending on which computer he was going to use to get internet access.

This struck me as being very odd. Ten years ago, I was using hubs and later migrated to switches and then router/switch combinations to network computers. I just can't imagine what life would be like to have more than one computer in a house and not some sort of network between them.

More scary was that he called me on April 1st, the day the Conficker author(s) decided to freak everyone out. Since Conficker's primary infection mechanism was to exploit a vulnerability in a Windows service and he had just reinstalled Windows on that computer, I was worried he could get infected and warned him about the dangers of doing so.

In this case, a router would have not only removed the "unplug this computer, plug that computer" inconvenience but also have acted as a firewall between his computer(s) and the internet, therefore making it difficult to get infected by worms like Conficker just because your computer is connected to a [hostile] network.

Thankfully, I have an extra router lying around that I'll give him the next time I see him, but seriously, if you don't have a router, go spend $50 on a networking device that doubles as a firewall. Most internet software is NAT-aware these days, so you're no longer trading off convenience for security.

Monday, February 02, 2009

Ego-driven software development

(or "How M.C. Escher Would Have Packaged His Software")

This was too "good" to not blog about. I heard about IronPython Studio, a Visual Studio-based IDE for writing Python code for/with .NET tools. In order to install this interesting gem, you first need to install its pre-requisites, which is one of flavors of the "Visual Studio 2008 Shell": Isolated Mode or Integrated Mode. Sounds easy, right? Wrong!

The actual downloads seem innocent enough (I got both, just to be safe -- as an aside it was hard to tell which one would suit me best): they arrive as executables. Here's how many levels of "packaging" there are:
  1. Running either of vs_AppEnvRedist.exe or vs_ideredist.exe will create a temporary directory where the EXE's files are extracted and an "installer" is launched

  2. You accept the EULA and click next a few times and what do you end up with? "The redistributable package has been installed". That's right, you ran an installer that installed another installer. Total disk space needed for this (at apogee): 400 MB for the original download, 400 MB for the temporary files and 400 MB for the "redistributable package" = 1200 MB

  3. As you finish the first "installer", the temporary files are cleaned up, so we're back to consuming 800 MB. You run the second installer and the first thing it does is check its signature, which consumes 400 MB of RAM. It then proceeds to extract files to another temporary directory (400 MB again, although this time it's in a bunch of smaller files - 286 MB of which is various versions of the .NET framework), thus bringing our used disk space back up to 1200 MB. Those of you following at home will notice that I haven't actually installed anything useful yet. Accept another EULA, select the only feature (wut?), pick the destination folder and go (again)! At apogee: 400 MB + 400 MB + 400 MB + whatever installed size it was (I didn't check)

  4. Oh, it looks like we're actually done! What was I installing, again?

I'm reminded of Adobe Acrobat Reader installers from, oh, I don't know, ten years ago, before one-file installers were even invented. IronPython itself is available in a single MSI file, while IronPython Studio's download options are both available as an MSI file in a ZIP file.

There is no technical reason for this. There simply is no excuse for these fractal installers except that someone (or an entire team of someones) at Microsoft decided they needed to be involved in the supply chain that brings us internauts this bare Eclipse-wannabe that does not even include a text editor. I mean, seriously, the only thing that could have been worse would have been to wrap the whole thing in a "downloader" (like Visual Studio Express) or in an ISO 9660 file (like Visual Studio 2008 Service Pack 1).

I have a special offer for the person in charge of the Visual Studio team: I will personally come over and deliver atomic wedgies to everyone responsible for these shenanigans! Just give me a call; you know how to find me.

There is some good news after all this: IronPython Studio not only just works, but so does its debugger. Kudos to that team.

Saturday, October 25, 2008

Apple sucks at XML

OK, I've officially had it with Apple. Steve Jobs may have style down cold, but his programmers were smoking something fierce when they designed the XML format for their so-called Property list. Don't let that Wikipedia page fool you on the apparent simplicity of the format. Take a look at one of Apple's own samples. Still not convinced? How about a real-world use-case: the emoticon definition file for an Adium theme, a portion of which is reproduced below:
<plist version="1.0">
<dict>
<key>AdiumSetVersion</key>
<integer>1</integer>
<key>Emoticons</key>
<dict>
<key>amazing.png</key>
<dict>
<key>Equivalents</key>
<array>
<string>=-o</string>
<string>=-O</string>
<string>:-o</string>
<string>:-O</string>
</array>
<key>Name</key>
<string>Surprised</string>
</dict>
<key>anger.png</key>
<dict>
<key>Equivalents</key>
<array>
<string>&gt;:o</string>
<string>:-@</string>
<string>:@</string>
<string>X(</string>
</array>
<key>Name</key>
<string>Angry</string>
</dict>
<key>bad_egg.png</key>
<dict>
<key>Equivalents</key>
<array>
<string>&gt;-[</string>
<string>&gt;-(</string>
</array>
<key>Name</key>
<string>Nervous</string>
</dict>
(...snip...)
</dict>
</dict>
</plist>
Do you see what the problem is? For those of you playing at home, here's a hint: how would you write an XPath expression to obtain the "equivalents" of a given image file?

Yes, it's not impossible to grab a value for a given key, but did they have to make it so hard when XML can express the same idea in a much easier format? Or, rather, did they have to be so lazy when writing the code that serializes these property lists to/from XML?

In any case, if you ever have the need to process an XML file created by an Apple program, the following stylesheet will (likely) help restore your sanity. Simply pre-process the XML with my stylesheet and then your XML code or stylesheet will be much easier to write (and read!):
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">

<xsl:output method="xml" encoding="utf-8" indent="yes" />

<xsl:template match="* | @* | node()">
<xsl:copy>
<!-- if the previous sibling is a 'key' element -->
<xsl:if test="name(preceding-sibling::*[position()=1]) = 'key'">
<xsl:attribute name="key">
<xsl:value-of select="preceding-sibling::key[position()=1]/text()" />
</xsl:attribute>
</xsl:if>
<xsl:apply-templates select="* | @* | node()" />
</xsl:copy>
</xsl:template>

<xsl:template match="key" />

</xsl:stylesheet>
For an example, let's take another look at the sample XML I showed earlier and compare that with the XML sexiness that is generated by applying my stylesheet against it (some spacing was added to the "after" version to better illustrate how they compare to each other):
BeforeAfter
<plist version="1.0">
<dict>
<key>AdiumSetVersion</key>
<integer>1</integer>
<key>Emoticons</key>
<dict>
<key>amazing.png</key>
<dict>
<key>Equivalents</key>
<array>
<string>=-o</string>
<string>=-O</string>
<string>:-o</string>
<string>:-O</string>
</array>
<key>Name</key>
<string>Surprised</string>
</dict>
<key>anger.png</key>
<dict>
<key>Equivalents</key>
<array>
<string>&gt;:o</string>
<string>:-@</string>
<string>:@</string>
<string>X(</string>
</array>
<key>Name</key>
<string>Angry</string>
</dict>
<key>bad_egg.png</key>
<dict>
<key>Equivalents</key>
<array>
<string>&gt;-[</string>
<string>&gt;-(</string>
</array>
<key>Name</key>
<string>Nervous</string>
</dict>
(...snip...)
</dict>
</dict>
</plist>
<plist version="1.0">
<dict>

<integer key="AdiumSetVersion">1</integer>

<dict key="Emoticons">

<dict key="amazing.png">

<array key="Equivalents">
<string>=-o</string>
<string>=-O</string>
<string>:-o</string>
<string>:-O</string>
</array>

<string key="Name">Surprised</string>
</dict>

<dict key="anger.png">

<array key="Equivalents">
<string>&gt;:o</string>
<string>:-@</string>
<string>:@</string>
<string>X(</string>
</array>

<string key="Name">Angry</string>
</dict>

<dict key="bad_egg">

<array key="Equivalents">
<string>&gt;-[</string>
<string>&gt;-(</string>
</array>

<string key="Name">Nervous</string>
</dict>
(...snip...)
</dict>
</dict>
</plist>

...isn't that a sight for sore eyes? You're welcome.

Wednesday, September 03, 2008

The world in Verdana

OK, by now everybody and their cat has heard about Google Chrome. The general buzz on Slashdot and blogs seems to be "it's not Firefox" and, more specifically, a general lament of the chicken and egg problem surrounding the release of a new platform when there isn't any software (in this case "plug-ins" or "add-ons") to run on it. Well, OK, there are some Chrome plug-ins available, but Chrome extensions are currently NOT supported. On the other hand, the source code is out there and given that it has the "Google brand", it won't be hard to find motivated geeks hacking some neat software to [eventually] bring Chrome on par with Firefox, with said geeks' hidden agenda of being noticed by Google and be offered a nice googlejob where they get to sit on their googlechair, etc.

It's technically not such a bad chicken-and-egg situation, with the massive and thorough testing Google [claims to] have performed. Plus, the browser is more than a fine replacement for users of Internet Explorer or plain, out-of-the-box Firefox, thus making all us geeks appear crazy when non-techies ask us why we're not using Google Chrome, when we use everything else Google throws at us.

Anyway, I didn't set out to write yet another review, but to post solutions to problems I encountered:

Installing Chrome as a non-administrator


I hit issue 119 ( Install Fails on W2K8 with low-rights user ) when I tried to install on my computer as a non-administrator and got the following:



Thankfully, the work-around by stephen.oakman in comment 6 worked and I found the elusive chrome_installer.exe in a folder matching the pattern C:\Documents and Settings\[user]\Local Settings\Application Data\Google\Update\Download\[guid]\chrome_installer.exe and was able to install it successfully.


Setting the minimum font size


I also had the same accessibility lament about minimum font sizes and font family overrides. For example, here is the Getting Started page in Firefox 2, with Verdana @ 14pt bliss:



...and this is what Chrome gave me with the same page:



OK, yes, the Firefox version looks weird with the title text not lining up with the logo, etc. but all the content is perfectly legible, which is more important to me. Well, more legible than the fonts picked by the web developer.

Thankfully, I have solved the first half the problem with a few quick searches in the source code and now my C:\Documents and Settings\[user]\Local Settings\Application Data\Google\Chrome\User Data\Default\Preferences file contains this little gem:


"webkit": {
"webprefs": {
"cursive_font_family": "Verdana",
"default_fixed_font_size": 14,
"default_font_size": 14,
"fantasy_font_family": "Verdana",
"fixed_font_family": "Courier New",
"minimum_font_size": 14,
"minimum_logical_font_size": 14,
"sansserif_font_family": "Verdana",
"serif_font_family": "Verdana"
}
}



...which gives me a slight improvement in readability in Chrome:



You can find out what the names of the [other] supported hidden preferences are by peeking into chrome/common/pref_names.cc and cross-referencing with WebContents::GetWebkitPrefs() in chrome/browser/web_contents.cc. In particular, you'll find (as of this writing) that the other half of my problem is already identified in a comment:



// User CSS is currently disabled because it crashes chrome. See
// webkit/glue/webpreferences.h for more details.



...with the more details being:



// TODO(tc): User style sheets will not work in chrome because it tries to
// load the style sheet using a request without a frame.
bool user_style_sheet_enabled;
GURL user_style_sheet_location;



D'oh. Maybe this will inspire someone else to fix that part of the code or otherwise provide the elusive "let me choose my own damn fonts" setting that I rely on for keeping my sight and posture in good shape.

Friday, August 08, 2008

Effort vs. Results

I remember having a conversation with my father when I was a kid about his company's employees. I was shocked to discover they were being paid by the hour. I remember following up with a question along the lines of "Wouldn't that encourage them to take lots of time to do their work?" He assured me that, although it was a possibility, it didn't happen very often. (and presumably he explained that people who did that could lose their job, so it was to their long-term advantage to not slack off)

Fast forward a few years later, when I'm in high-school and a student asks the teacher - after she explained a homework assignment - if any marks would be given for effort. I couldn't help but laugh out loud, thinking he was pulling the teacher's leg. He didn't join me in laughing or smiling (and probably shot me a dirty look). Uh oh. He was serious!?!?

Maybe I found that proposition silly because I figured there was no way for it to be reliably measured: it would have to be self-reported. How hard would it be to say "I spent 100 hours on this" when handing it in? And if I had completed the assignment in 10 hours and produced equivalent results to his, would that mean he would get more marks than I got because he spent more time?? Worse yet, even if it wasn't self-reported, how would it be measured??? And what about the difference between "brain time" and "body time"?

Nowadays, I understand why people are [usually] compensated by how much their skills are in demand and that it is possible to be 10 times better than someone else at what you do. Maybe I also did as a kid? That might explain my reaction in both instances. Could it also explain my drive for correctness? My passion for getting things done and done right?? Now here's a doozy for you: would this knowledge at a younger age have helped other students??? Or maybe I'm just being arrogant and that I should just shut up for being the nerd that didn't have any trouble with his assignments????

Discuss.

P.S.: Please go easy on me as I have been working on this blog post since November and it's only now that I have been able to finish it.

Saturday, July 26, 2008

I got in a "jam"...

Round 1A of Google's Code Jam programming contest (in which I participated) ended about an hour-and-a-half ago (there are three sub-rounds of Round 1 and contestants can compete in two of them to try to move on to Round 2).

A failure (on my part) to pay close attention to the requirements of the first problem meant I implemented an algorithm with a complexity of O(n! * n!), which means 25 401 600 iterations for n = 7 (sort of reasonable) but 1 625 702 400 iterations for n = 8, something my laptop wouldn't be able to finish in any reasonable amount of time. It's much worse when you consider that n was expected to go as high as 800! Once I read that, it occurred to me that I had been going at it entirely the wrong way... About an hour-and-a-half of going the wrong way, which involved implementing (and debugging) a nice "permute the items of this list" method.

You see, there was a trick to the problem. Once I realized this, I replaced the double permutation loop with two calls to Sort() and a single O(n) loop. D'oh! My solution to the small input was judged as correct, so I proceeded to the large input. It ran just as fast and so I submitted its output, too. Well, again, I screwed up with the requirements and it turns out my math was overflowing left, right and center and thus, when the contest ended, I got a measly 5 points (out of a possible 15 for that problem and out of a possible 100 for all 3 problems!), which means I ranked 2363 out of 2394. (you don't find out if your submission to the large version is correct until the end of the contest - I also only attempted the first problem)

Oh, well... I might try again in Round 1C (Sunday at 05:00 local time!), but in the meantime I thought I'd publish some of the source code that came out of this. I used Visual Studio 2008, which meant I could try out the neat features of the C# that came out with .NET 3.5, such as:
  • the var keyword
  • extension methods
  • LINQ

OK, so I didn't need to use LINQ, nor did I use extension methods until after the contest, but here's my touched-up Permutations iterator method, generalized to any IList<T> instance:
public static class Extension {
public static IEnumerable<IList<T>> Permutations<T> ( this IList<T> input ) {
int numElements = input.Count;
var slotOffsets = new int[numElements];
var slotBusy = new bool[numElements];
bool allDone = false;
while ( !allDone ) {
#region Set slotBusy flags to false
for ( int i = 0; i < numElements; i++ ) {
slotBusy[i] = false;
}
#endregion

IList<T> permutation = new List<T> ( numElements );
int lastSelected = -1;
for ( int i = 0; i < numElements; i++ ) {
for ( int j = 0; j < numElements; j++ ) {
int selectedSlot = ( lastSelected + 1 + j + slotOffsets[i] ) % numElements;
if ( !slotBusy[selectedSlot] ) {
slotBusy[selectedSlot] = true;
permutation.Add ( input[selectedSlot] );
lastSelected = selectedSlot;
break;
}
}
}
yield return permutation;

#region Update offsets
for ( int i = 0; i < numElements; i++ ) {
slotOffsets[i]++;
if ( slotOffsets[i] < ( numElements - i ) ) {
break;
}
else {
if ( i == numElements - 1 ) {
allDone = true;
}
else {
slotOffsets[i] = 0;
}
}
}
#endregion
}
}
}

...which you can use as follows (notice how it's magically a method on any IList<> implementation?):
IList<int> inputList = new List<int> ( new int[] { 1, 2, 3 } );
foreach ( List<int> permutation in inputList.Permutations ( ) ) {
StringBuilder sb = new StringBuilder ( );
sb.Append ( "[" );
bool isFirst = true;
foreach ( var item in permutation ) {
if ( !isFirst ) {
sb.Append ( ", " );
}
else {
isFirst = false;
}
sb.Append ( item );
}
sb.Append ( "]" );
Console.WriteLine ( sb.ToString() );
}

...and it should produce the following output:
[1, 2, 3]
[2, 3, 1]
[3, 1, 2]
[1, 3, 2]
[2, 1, 3]
[3, 2, 1]

I just wish I had prepared this method before the contest, although I think some actual practice in solving these kinds of problems would have helped me more. Maybe next time... :)

Tuesday, June 10, 2008

Announcing the web-screen-saver project

I finally got around to posting a little tidbit of source code I had lying around on my computer as the new open-source project web-screen-saver (really lame name, I know). Not only that, but the premise seems a bit lame, too. Who would want a screen-saver that runs in their web browser?

It turns out I do! I have been learning about aviation weather and the best way to stay fresh is to practice every day, so I took a really old Toshiba laptop (Pentium MMX @ 166 MHz, 16 MB RAM and a 2.1 GB HD), got it running again (which involved soldering frankensteining an equally-as-old power supply from a Futjitsu laptop, not to mention putting the disassembled laptop back together from memory) and set it on top of my refrigerator. This way, whenever I'm preparing meals, washing dishes, feeding the cat, etc. in the kitchen, I can take a minute or so to browse the latest weather.

Next up was figuring out how to get various semi-frequently-updated images to show up on the screen on a cycle. Various options popped up, such as:


  1. a cron job that would trigger wget on a few URLs with one of:

    1. a companion program that would convert the HTML pages to images and rescale pure images appropriately and a corresponding client (any of many file-system-based screen-savers)

    2. a static page that would cycle the pre-downloaded pages or images using a bit of JavaScript

    3. a number of static pages that serve up one pre-downloaded page or image for a little bit of time before redirecting to the next



  2. a static page that would cycle the live pages or images using a bit of JavaScript

  3. a number of static pages that serve up one live page or image for a little bit of time before redirecting to the next



Some of the above options don't make that much sense until you consider that aviation weather is the kind of information that's sensitive to updates (or the lack thereof) and so it is of the utmost importance to always have the latest version of, say, a TAF or a GFA cloud & weather map. With some web browsers reportedly overzealously caching pages or images coupled with sometimes unreliable internet connections, I was trying to explore the space of options so that I could potentially detect the worse case and do something about it.

I ended up deciding to see whether the current browsers that I use/care about indeed [still] suffered from that problem, since it would eliminate an entire class of problems/solutions. Turns out that improper browser caching no longer appears to be a problem, although this could also be due to proper configuration programming at the server end. That, and the prospect of having to write client-server code that scanned files coming from remote computers sounded more stupid every second I continued to think about it since, you know, web browsers do that sort of thing already!

A few evenings of light programming (i.e. tweaking a few lines of code here and there during a few boring moments of TV watching) and I've got what I think is "version 1.0"-worthy. It's running right now on the trusty laptop in Internet Explorer 6[1] in full-screen mode (hide the status bar, hit F11, then right-click on the toolbars to select auto-hide), although with this much RAM it just swaps and thrashes like hell for a minute or two on every reload. I also need to reboot it every so often because the USB wireless adapter's driver is as stable as the U.S. economy. *cough*

Download


Download version 1.0: awesome-web-screen-saver-1.0.html.
View source of version 1.0: index.html
Live demo (latest version): Aviation Weather Electronic Summary Of Major Events

How to use


Once everything loads, all but the first page/image will be "hidden" (it's really a clever lame container resizing trick). After 2 minutes, the next page/image will be shown, etc. until about 33 minutes at which point the whole thing will reload. You can, of course, scroll up and down, but you may want to use the left and right arrow keys, since they are programmed to jump back and forward (respectively) at page/image boundaries.

Roadmap for future versions



  • JavaScript code to live in its own file

  • META-based refresh to be adjusted based on the number of "pages"

  • better handling of unavailable content (i.e. keep retrying - with suitable back-off - iframes or images that returned anything other than HTTP 200)

  • use Google App Engine for the following features:

    • configurable content profiles (i.e. pick your own frequently-changing content)

    • content caching (i.e. Google's servers can withstand more hits and will probably replicate content to various globally-distributed data centers for higher availability)

    • content download scheduling (i.e. while you have both of the previous two features, you might as well configure content with an expected lifetime or scheduled release, so that it is only pulled once, per publishing, from its source)

    • stats collection

    • general geekiness





Notes


[1] This will probably cost me a few geek points, but the laptop is still running the Windows 98 copy it came with - I couldn't get any GNU/Linux distro to boot/install and I have better things to do with my time!